Give the window its v5b chrome and each session surface its own shell

This commit is contained in:
2026-08-08 00:49:59 +02:00
parent 1b76c51fbb
commit 43c939b697
30 changed files with 4433 additions and 1772 deletions
+315 -113
View File
@@ -305,6 +305,22 @@
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<!--
The titlebar's search pill. A plain <c>Border</c> rather than a styled <c>Button</c> template, because
the pill sits inside a <c>Button.flat</c> that already owns the click — see <c>TitleBar.axaml</c> — and
what changes under the pointer is the pill's own border, not a fill behind it. Declared on the parent
button's <c>:pointerover</c> rather than the border's own, because a <c>Border</c> has no pointer state
of its own to key a selector on.
-->
<Style Selector="Border.searchpill">
<Setter Property="Background" Value="{StaticResource SearchPill}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.search:pointerover Border.searchpill">
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<!--
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
cards. The headings went when the grid became cards and the group cards above it became the thing that
@@ -331,178 +347,301 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
row carries, so the window has one vocabulary for selection rather than one per control.
-->
<!--
A destination in the sidebar. v2 turns these from 54-pixel stacked labels into 32-pixel rows with a
glyph, a word and a count, and the active one becomes a filled rounded row rather than a label with an
accent bar down its left edge. The bar is gone because a filled row at this width already reads as
chosen, and the bar was carrying that on its own when there was no room for a fill.
The nav rail. v5b redraws it against TitleBar.dc.html's sibling NavRail.dc.html rather than against
the v2 mock this replaces — see the comment block at the top of NavRail.axaml for what moved and why.
The active destination is now a filled accent row rather than a wash with a bar down its edge; the
idiom is otherwise the same one the switcher below and a selected list row already use.
TextGhost rather than TextFaint for the resting label: the mock computes rgb(124,127,152) for an
inactive row, which is TextGhost's own #7C7F98 exactly, not TextFaint's paler #9C9EB4.
-->
<Style Selector="Button.nav">
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="10,0" />
<Setter Property="Height" Value="35" />
<Setter Property="Padding" Value="11,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="LetterSpacing" Value="0.1" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Hover}" />
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Button.nav.active">
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<!--
The three parts of a sidebar row. Separate classes rather than inline setters because nine rows draw
them and the day one of the three moves is the day eight of them would not have.
The glyph and the label inherit the row's own foreground, so they light with it; the count does not,
because a number that lit with its row would compete with the word beside it for the same emphasis.
The two parts of a sidebar row: a 19-pixel Material glyph and its word, ten pixels apart. v2's third
column — a count read off the vault — left with the row it decorated; see the remark on
<c>Button.nav</c> above for where that number went and why it is not replaced here.
-->
<Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="14" />
<Setter Property="Width" Value="20" />
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="19" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.navlabel">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.navcount">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
A terminal tab, and since v2 a pill rather than a filing-cabinet tab: 30 tall inside a 42 strip, its
own rounded outline, and the active one filled instead of marked along an edge. The edge mark is gone
because a pill has no edge to share with its neighbour — the gap between two of them is the divider
the old top-and-right border was standing in for.
The segmented SSH / SFTP / S3 switcher at the rail's head. A track the width of the rail's own content
column, 2 pixels of padding holding three equal segments — so the three are Buttons with
HorizontalAlignment="Stretch" inside a Grid of three equal columns, the same "buttons carry no state"
reasoning <c>NavRail.axaml</c>'s own remarks give for every row beneath them.
-->
<Style Selector="Button.tab">
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
<Setter Property="Padding" Value="12,0,8,0" />
<Setter Property="Height" Value="30" />
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Style Selector="Border.navtrack">
<Setter Property="Height" Value="31" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="Padding" Value="2" />
</Style>
<!--
◆ ORDER IS THE BEHAVIOUR HERE. The base pill comes first, then its hover, then the exceptions — and
that ordering is the fix for a bug this arrangement had when v2 first drew it.
A pill paints its own Background, which the flat tab it replaced did not. That one detail moves where
the hover has to live: `Button.flat:pointerover` is declared far above and used to supply it, and the
moment `.tab`'s template rule set a Background of its own, the later declaration won and every tab in
the strip lost its pointer feedback silently. The `+` lost more than that — it sits below as an
exception, so a `.tab` rule declared after it was overriding the very thing that made it an exception,
and it drew as a filled outlined pill contradicting the comment above it.
Avalonia has no specificity; the later declaration wins. The same trap is recorded further down this
file for Border.rowmark. Anything added below must be an exception to what is above it, never a
restatement of the base.
-->
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Style Selector="Button.navseg">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="27" />
<Setter Property="CornerRadius" Value="7" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="LetterSpacing" Value="0.1" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Button.tab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
<Style Selector="Button.navseg /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="Button.navseg.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<!--
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
close box, so it takes its padding back on the right: the base rule is short there to leave room for
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
off-centre beside one that has a reason for it.
Nothing else differs, deliberately. These are tabs and have to read as tabs — the whole point of the
strip is that "where the window is" is one row of one kind of control.
The rail's foot: the user chip that opens the popover. Flat until the pointer arrives, the same idiom
every other row in this rail follows, at its own height and radius because it is a chip rather than a
destination — see <c>NavRail.axaml</c> for what it opens and why a <c>Flyout</c> is safe here.
-->
<Style Selector="Button.tab.fixed">
<Setter Property="Padding" Value="12,0" />
<Style Selector="Button.navuser">
<Setter Property="Height" Value="34" />
<Setter Property="Padding" Value="13,7" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.navuser:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
draws them as one list and a row that looked different from its neighbours would read as a separator
that is not one.
-->
<Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" />
</Style>
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The check square beside a shown vault in the popover — magenta rather than the accent, because the
accent already means "press this" everywhere else in the window and a vault switch is a fact, not an
action waiting to be taken. See Palette.axaml's remark on Magenta.
-->
<Style Selector="Border.vaultcheck">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="12" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Magenta}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
── v5b: SESSION TAB ROW ─────────────────────────────────────────────────────────────────────────────
The tab strip's replacement, per <c>Terminal.dc.html</c>/<c>SFTP.dc.html</c>: rounded only at the top,
fused visually to the bordered container it sits above rather than a free-floating pill — see
<c>SessionTabRow.axaml</c>. It went from a 42-pixel strip spanning the window to a 38-pixel row inside
each of the two screens that carry one, which is why the layout budget in <c>LayoutHarness</c> no
longer subtracts a tab strip's height from every screen: only these two now pay it, out of their own
26-pixel padded column rather than out of the window's own chrome.
◆ ORDER IS THE BEHAVIOUR HERE, as it was for the pill this replaces. The base tab comes first, then its
hover, then <c>.active</c> — each later rule is an exception to what came before it, because Avalonia
has no specificity and settles two rules matching one element by declaration order alone.
-->
<Style Selector="Button.sesstab">
<Setter Property="Padding" Value="16,10,12,10" />
<Setter Property="Height" Value="38" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalAlignment" Value="Bottom" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9,9,0,0" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="13.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.sesstab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9,9,0,0" />
</Style>
<Style Selector="Button.sesstab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The tab whose pane the container below is showing: DeepChrome body — the same value the container's
own host header and status bar are painted in, which is what fuses the two into one shape rather than
a tab floating above a separate box — and a 2px top border in one of two colours. Which colour is set
by the row's own <c>Classes="sftp"</c>, from the usage site in <c>MainWindow.axaml</c>: unmarked is the
terminal row's <c>TerminalTabAccent</c>, and <c>.sftp</c> is the SFTP row's <c>Magenta</c> — see the
remark on both keys in <c>Palette.axaml</c>. Marked on <c>TerminalTabViewModel.IsSelected</c> rather
than <c>IsShowing</c>, unlike the strip this replaces: the SFTP row's own active tab is not "the pane
the terminal surface is showing" at all, so the narrower flag would leave it permanently dark. See
<c>MainWindowViewModel.SelectFilesHostCommand</c>.
-->
<Style Selector="Button.sesstab.active">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DeepChrome}" />
<Setter Property="BorderThickness" Value="0,2,0,0" />
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
</Style>
<Style Selector=".sftp Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{StaticResource Magenta}" />
</Style>
<!--
The button that opens a connection, reusing the strip's own shape rather than a new one: no active
state, because it is never the thing showing, and no outline, because it is not one of the things
being chosen between. Both of those are cleared rather than merely omitted — the base rule above sets
a fill and a border, so an exception has to say so.
being chosen between.
-->
<Style Selector="Button.tab.plus">
<Style Selector="Button.sesstab.plus">
<Setter Property="Padding" Value="0" />
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="28" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Style Selector="Button.sesstab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Style Selector="Button.sesstab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.tab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
because they do two things, drawn as one pill because they are one destination — so the pair meets in
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
are.
Both the Button and its ContentPresenter carry a CornerRadius above, so both have to be squared here:
setting only one leaves a rounded outline inside a square hit area, which shows as a hairline of the
strip's background cutting through the join.
After Button.tab.fixed rather than beside it, because .caret takes that rule's padding back to zero
and Avalonia has no specificity — the later declaration is the one that wins. See the ordering note
above.
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
-->
<Style Selector="Button.tab.split">
<Setter Property="Margin" Value="0" />
<Setter Property="CornerRadius" Value="8,0,0,8" />
<Style Selector="Button.headerghost">
<Setter Property="Height" Value="32" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="16,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8,0,0,8" />
<Setter Property="BorderThickness" Value="1,1,0,1" />
<Style Selector="Button.headerghost /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.tab.caret">
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Style Selector="Button.headerghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.tab.caret /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<!--
── v5b: THE SESSION SIDEBAR'S ROWS ──────────────────────────────────────────────────────────────────
QUICK ACCESS's pins and, on the terminal surface, SNIPS — both h33, radius 9, a glyph and a mono
label, flat until the pointer finds them. See <c>SessionSidebar.axaml</c>.
-->
<Style Selector="Button.sidebarrow">
<Setter Property="Height" Value="33" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebarrow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebarrow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
-->
<Style Selector="Button.sidebaradd">
<Setter Property="Height" Value="30" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.sidebaradd /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebaradd:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
@@ -661,6 +800,34 @@
<Setter Property="Background" Value="Transparent" />
</Style>
<!--
── v5b: THE SFTP PANES' OWN FILE ROWS ───────────────────────────────────────────────────────────────
A third list shape, on the same reasoning A LIST OF TILES gives above it: the design draws a file row
with 7-pixel rounded corners and a single fill — Track, not Hover or AccentWash — for both hover and
selected, and neither the accent wash nor a square-cornered highlight peeking out from behind a rounded
row is the mock's own choice. See TransfersScreen.axaml's local and remote listings.
No selected+unhovered accent strip either, unlike Border.rowmark's own rows: the design has nothing to
distinguish "selected" from "hovered" beyond which one is true at the moment, and :selected alone
already answers that once the pointer has moved on — a strip drawn on top would be a second, redundant
mark for the one state this list bothers to keep after the pointer leaves.
Declared after ListBox's own base rules, for the reason recorded three times over already in this file:
Avalonia has no specificity, and an exception declared above the rule it excepts does nothing at all.
-->
<Style Selector="ListBox.filerows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
@@ -914,6 +1081,41 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
── v5b: THE SFTP PANE HEADERS' QUIET GLYPHS ─────────────────────────────────────────────────────────
Two variants of the icon-only button above, for the two places wave C's SFTP restyle draws one that is
not the drawer's own chrome: a pane's UP/REFRESH/DELETE affordances — see TransfersScreen.axaml, whose
remote pane keeps DELETE's own destructive colour rather than borrowing the plain one — and a pane's
drive picker, which is a chip rather than a square icon and so borrows Border.chip's own geometry
instead, for the reason Button.chiptoggle borrows it too.
-->
<Style Selector="Button.paneicon.danger">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<Style Selector="Button.paneicon.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerWash}" />
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<Style Selector="Button.panechip">
<Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.panechip /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style Selector="Button.panechip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
The status dot, in one place rather than as a converter in code.
+232 -195
View File
@@ -9,15 +9,14 @@
Icon="/Assets/dodossh.ico"
Width="1180"
Height="760"
MinWidth="1016"
MinHeight="574"
MinWidth="1081"
MinHeight="583"
Background="{StaticResource Canvas}"
SystemDecorations="BorderOnly"
Focusable="True">
<!--
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
status bar.
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
@@ -26,6 +25,17 @@
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
somewhere nobody was looking.
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
stays unchanged at 826, because the rail is the only thing beside a page that grew.
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
near-black application is the one part of the window that would look borrowed.
@@ -37,12 +47,19 @@
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
chrome worth having.
TWO SURFACES, ONE RECTANGLE.
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
visible and reachable from every other one. What that costs is that the terminal and the pages now share
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
enum rather than two flags, so there is no way to write the state where both are showing.
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
it simply is not drawn as chrome above every screen while it does.
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
to write the state where both are showing.
THE OCCLUSION RULE, which every arrangement in this file obeys.
@@ -67,7 +84,9 @@
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
fires only for its own IsVisible.
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
with the control that much further from the Panel that used to be its only parent.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
@@ -77,209 +96,227 @@
<Panel Grid.Row="1">
<!-- The unlocked application. -->
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<!--
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
It was the other way round for a reason that stopped being true: while every tab was a terminal,
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
would have been a row of tabs above a column of destinations they had nothing to do with.
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
showing at all. Above and full width is the arrangement that matches what selects what.
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
to give up its own first row to. See the remark below on where a session's tabs live now.
-->
<views:TerminalTabs Grid.Row="0" />
<views:NavRail Grid.Column="0" />
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Panel Grid.Column="1">
<!--
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
-->
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
connecting card are all layered on top of one another there, which is right for three things
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
and IsVisible="False" collapse to nothing when there is nothing to show, so a page screen or an
empty terminal loses no height to a row it never draws — see ShowsPinStrip, which is false on
every page and every tab whose host has no pins.
ActiveTabPinnedPaths is MainWindowViewModel's, not the vault's: which tab is selected and
whether its session is live are the shell's business, and RefreshConnectedHosts is where the
two lists — Tabs and Vault.Hosts — are already walked together to paint the status dots. This
rides along on the same walk rather than opening a subscription of its own.
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding ShowsPinStrip, FallbackValue=False}">
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] is enough to reach back to the shell: this
ItemsControl's own DataContext is MainWindowViewModel, the window's root, so one hop up
from the chip's string DataContext lands on it directly — no #Board-style named-element
indirection needed, because there is no second level of template nesting here.
-->
<Button Classes="ghost" Padding="8,4" ToolTip.Tip="{Binding}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="12" />
<TextBlock FontFamily="{StaticResource MonoFont}" Text="{Binding}" FontSize="11.5"
MaxWidth="220" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Panel Grid.Row="1">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ SFTP ============ -->
<!--
Inside this Panel although it is a tab rather than a rail screen, and that is not an
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
occlusion question and is true of all three fixed tabs; which of them is showing is the
separate question each child below answers. Keeping the two apart is what lets the terminal
stay collapsed under one rule rather than under four.
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
this screen is laid out at the full window width.
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
visibility is the shell's business and its data context is the transfers view model, and
putting both on one element resolves IsVisible against that view model, where
IsTransfersScreen does not exist.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ S3 ============ -->
<!--
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
-->
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist.
-->
<Panel IsVisible="{Binding IsKeychainScreen}">
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ VAULTS ============ -->
<!--
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
business and the data context is the vaults view model, and both on one element would resolve
IsVaultsScreen against a type that does not have it.
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
is one vault's contents and is what the keychain and hosts screens above draw.
-->
<Panel IsVisible="{Binding IsVaultsScreen}">
<views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ SFTP ============ -->
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
has a session or it does not — which is what makes drawing it here safe under the occlusion
rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
a host header and a status bar around the screen's own content, and a 300px sidebar.
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
not the WebView", which is the occlusion question, and which mode the switcher is on is this
wrapper's own separate question.
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
this application, and building one is out of this wave's scope; see the notes' own open
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
exists that does not.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<Grid RowDefinitions="Auto,*" Margin="26">
<views:SessionTabRow Grid.Row="0" Classes="sftp"
TabCommand="{Binding SelectFilesHostCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open terminal"
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
EmptyText="{Binding Transfers.Status}" />
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Panel>
<!-- ============ S3 ============ -->
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process tree, so twenty tabs would cost twenty of them.
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
calls it now; see NavRail.axaml.
A sibling of the page area rather than a child of any screen, which is the structural half of
the tab rework: the terminal belongs to the window now, not to the hosts screen.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
could show. Wave B's own scope is the terminal and SFTP surfaces; S3 stays the plain screen it
already was, and this is the recorded reason rather than an oversight.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist.
-->
<Panel IsVisible="{Binding IsKeychainScreen}">
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ VAULTS ============ -->
<!--
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
business and the data context is the vaults view model, and both on one element would resolve
IsVaultsScreen against a type that does not have it.
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
is one vault's contents and is what the keychain and hosts screens above draw.
-->
<Panel IsVisible="{Binding IsVaultsScreen}">
<views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
</Panel>
<!-- ============ TERMINAL ============ -->
<!--
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
around the real terminal control rather than around a page.
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
a padded column, a bordered container and a header row rather than sitting directly beside the
page area the way it used to.
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
called out as unverified, and nesting the control deeper without touching its own binding is not
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
the WebView's own visibility flip together on every surface change, driven by the same property
change rather than one waiting on the other.
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
-->
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open SFTP"
OpenCommand="{Binding SelectFilesHostCommand}"
OpenCommandParameter="{Binding SelectedTab}"
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
<Panel Grid.Row="1" Background="{StaticResource Pane}">
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not
exist yet, or never will. Exclusive with the WebView below by construction — a
selected tab either has a session or it does not — which is what makes drawing it here
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
browser process tree, so twenty tabs would cost twenty of them.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
IsVisible then falls back to its default of true, and the occlusion comes back
silently. Not reachable at runtime — the DataContext is set before the window is shown
— but it is what the previewer does.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
"Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Grid>
</Panel>
</Grid>
<!--
+267 -131
View File
@@ -7,159 +7,295 @@
<!--
The window's destinations, down the left edge.
Still called NavRail although v2 makes it 190 pixels wide and gives every entry a word: the type is
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
255. The counts v2 added beside every row are gone — the mock's own row is an icon and a word, nothing
else, and this pass follows it rather than keeping a feature the mock never had; nowhere else on screen
states the number instead, so it is simply not drawn any more. And two things this rail used to answer
to the tab strip now answer to the rail itself:
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
is a question you would otherwise have to open the screen to answer.
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
three-way here rather than two live segments and one dimmed — the mock leaves S3 unstyled as future
work, and this application already has bucket browsing, so it is wired like its two neighbours. See
<c>MainWindowViewModel.IsSshShowing</c>, <c>IsTransfersShowing</c> and <c>IsBucketsShowing</c>.
A count is drawn only where one is real. Logs and Preferences have none — a log has no total until it is
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
number on every row; a zero beside Logs would be a fact this application never computed.
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
the v3 file carried, and <c>MainWindowViewModel.IsVaultsTab</c>, which still exists and still answers
the question it always did. What changed is <c>MainWindow.axaml</c>: the rail is part of the window's
own furniture now, the same as the titlebar and the status bar, so it stays up beside SFTP and S3 and
beside an open terminal — which is exactly what makes the switcher above worth having here at all.
── SFTP AND S3 ARE NOT HERE, and that is the tab strip's doing. ──────────────────────────────────────
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
in. See MainWindowViewModel.IsVaultsTab.
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
which read the same three flags the switcher above lights — so the row and the segment can never name
two different modes between them.
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
counting buckets is going anyway.
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
in identity this application actually has — a display name and, where the server sent one, an email —
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
window, and the button's own comment says which is which.
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
of those hold the selection themselves, so a click moves the highlight before the shell can decide
anything. Buttons carry no state and cannot disagree with the screen that is showing.
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
back — and an entry lit while a terminal filled the window would be pointing at a screen that is not
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
mark, in the strip, and two "you are here" marks is one too many.
The design pins a "Team vault" card to the foot of this list, beside a Team entry in the list itself.
Two routes to one screen, one of them carrying a seat count nothing here can produce, so what sits at
the foot is Preferences — which is where it already was, and which is the one entry that is about the
machine rather than about the keychain.
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
the selection themselves, so a click would move the highlight before the shell decided anything, and a
Button carries no state to disagree with the screen that is actually showing.
-->
<Border Width="190" Background="{StaticResource Sidebar}"
<Border Width="255" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False">
<!--
Border rather than Chip for the right-hand rule, although the mock's own value — rgb(26,26,40) — is
Chip's exact #1A1A28. Chip is the fill behind a tag, and reusing it here as a line would answer a
later "why does a rail border share a key with a bucket chip" with "it doesn't, they just happen to
match" — where Border, at #1E1E2C, is one shade off and reads identically at one pixel wide.
-->
<DockPanel LastChildFill="False" Margin="14">
<StackPanel DockPanel.Dock="Top" Margin="8,10,8,0" Spacing="2">
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="▤" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Hosts" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Hosts.Count}" />
</Grid>
</Button>
<StackPanel DockPanel.Dock="Top" Spacing="18">
<!--
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
with the list under it. It no longer does: buckets have their own entry above, so this number and
this screen now count the same things.
The segmented switcher. Three equal columns in a Grid rather than a StackPanel with Width="*" on
each child — Avalonia gives a StackPanel's children their desired size, not an even split, and the
mock's three segments are exactly a third each.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
<Border Classes="navtrack">
<Grid ColumnDefinitions="*,*,*">
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding IsSshShowing}"
Command="{Binding ShowTerminalCommand}"
ToolTip.Tip="The terminal, and every shell you have open">
<TextBlock Text="SSH" />
</Button>
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<TextBlock Text="SFTP" />
</Button>
<Button Grid.Column="2" Classes="navseg" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<TextBlock Text="S3" />
</Button>
</Grid>
</Button>
</Border>
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◈" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Pins" />
<!--
The vault's pins, not the screen's VisiblePins — that one is the filtered list, so a sidebar
bound to it would count what the Pins screen's own filter box happens to match and would
change as somebody typed in it. Every other count here is a total; this one has to be too.
-->
<TextBlock Grid.Column="2" Classes="navcount"
Text="{Binding Vault.KnownHostPins.Count}" />
</Grid>
</Button>
<StackPanel Spacing="8">
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
</Grid>
</Button>
<!--
The mode-dependent first row: Terminal, Files or Buckets, matching whichever segment above is
lit. Active follows !IsVaultsTab rather than a property of its own — that is already exactly
"the terminal surface, or the files screen, or the buckets screen", which is what this row is.
-->
<Button Classes="flat nav" Classes.active="{Binding !IsVaultsTab}"
Command="{Binding ShowFirstRailItemCommand}"
ToolTip.Tip="The terminal while SSH is selected, or whichever file picker SFTP or S3 chose">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="{Binding FirstRailItemIcon}" />
<TextBlock Classes="navlabel" Text="{Binding FirstRailItemLabel}" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
</Grid>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE875;" />
<TextBlock Classes="navlabel" Text="Hosts" />
</StackPanel>
</Button>
<!--
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
the server authorises against; it is no longer something anybody has to make, name or think about,
so the rail names the thing people came for. See VaultsViewModel.
<!--
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
passwords; see ShellScreen.Keychain for the name that did not move with the label.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE73C;" />
<TextBlock Classes="navlabel" Text="Keys" />
</StackPanel>
</Button>
It shares a word with the tab strip's first tab, which is a different level of the window: that
tab is "this application rather than SFTP or S3", and this is one of the nine screens under it.
-->
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}"
ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
<!--
No count. The vault list is the session's and could be counted here — but who is in each one
is read from the server when the screen is opened, not on unlock, and a number naming only
half of what the screen is about would be the one figure on this list that has to be
explained.
-->
<TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
</Grid>
</Button>
<!--
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
than picked afresh so the one concept reads as one glyph everywhere it appears.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE946;" />
<TextBlock Classes="navlabel" Text="Pins" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xEAD3;" />
<TextBlock Classes="navlabel" Text="Snips" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE26C;" />
<TextBlock Classes="navlabel" Text="Logs" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
ToolTip.Tip="Preferences, and this machine's device key">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
<!--
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
switcher replaced, which sat directly above that rectangle and had to select a page before opening
one for exactly that reason.
-->
<Button x:Name="UserChip" DockPanel.Dock="Bottom" Classes="flat navuser" Click="OnUserChipPressed">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="20" Height="20" CornerRadius="60"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="7.5" LetterSpacing="0.2"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="10,0,0,0" Spacing="4">
<TextBlock FontWeight="Bold" FontSize="10.5" LetterSpacing="0.1"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="8" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
Text="&#x25BC;" />
</StackPanel>
</Grid>
<FlyoutBase.AttachedFlyout>
<Flyout Placement="TopEdgeAlignedLeft">
<StackPanel Width="227" Spacing="8">
<!--
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
is no organisation concept behind a vault, only the vault itself, which the rows below name.
-->
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
Foreground="{StaticResource TextGhost}"
Text="{Binding Email}"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
One row per readable vault — the strip's old "SHOW ITEMS FROM" chips, restyled: a 14-pixel
initial square (Chip's own fill, since no per-vault colour exists to draw honestly) and a
magenta check where the vault's items are shown. Toggling one leaves the Flyout open, the
same as the chips it replaces did — this is a switch to flip, not a place to leave from.
-->
<ItemsControl ItemsSource="{Binding VaultToggles}" IsVisible="{Binding HasVaultSwitches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultToggleViewModel">
<Button Classes="poprow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="14" Height="14" CornerRadius="2"
Background="{StaticResource Chip}">
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="9"
Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Grid.Column="1" Margin="10,0" FontSize="10"
Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="{Binding Display}" TextTrimming="CharacterEllipsis" />
<Border Grid.Column="2" Classes="vaultcheck" IsVisible="{Binding IsShown}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="10"
Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="poprow" Click="OnPopoverNewVaultPressed"
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="New vault" FontSize="10" Foreground="{StaticResource TextGhost}" />
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text="&#xE145;"
FontSize="12" Foreground="{StaticResource TextGhost}" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<!--
Settings and Preferences both land on the one screen this application actually has — the
mock's own Settings area is a family of screens that do not exist yet; see the decided
deviation in design-notes/v5b-fidelity-notes.md. Two doors to one room rather than one door
removed, so neither word on the mock goes unanswered.
-->
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE84F;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
</StackPanel>
</Button>
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE429;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
</StackPanel>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<!--
The existing sign-out flow, with its own confirm card — see
MainWindowViewModel.SignOutFromPopover for why this goes through Preferences rather than
calling SignOutCommand directly from wherever the popover happened to be opened.
-->
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE9BA;" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</Button>
</DockPanel>
+77 -1
View File
@@ -1,9 +1,85 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The five destinations down the left edge of the unlocked window.</summary>
/// <summary>The window's destinations, down the left edge the switcher, the six rail rows and the user chip.</summary>
internal sealed partial class NavRail : UserControl
{
public NavRail() => InitializeComponent();
/// <summary>Opens the user popover.</summary>
/// <remarks>
/// A handler rather than relying on the click that opening a <c>Flyout</c> answers to on its own: a
/// named method is a thing a test can call directly, where an implicit open is not.
/// </remarks>
private void OnUserChipPressed(object? sender, RoutedEventArgs e)
{
if (sender is Control chip)
{
FlyoutBase.ShowAttachedFlyout(chip);
}
}
/// <summary>Hides the popover, whatever handler is about to navigate.</summary>
private void ClosePopover()
{
if (this.FindControl<Button>("UserChip") is { } chip)
{
FlyoutBase.GetAttachedFlyout(chip)?.Hide();
}
}
/// <summary>Leaves for the vaults screen with the new-vault form open, shutting the popover behind it.</summary>
/// <remarks>
/// The popover is closed first: the command navigates, and a popup left open would be hanging over a
/// screen it has nothing to do with. A <c>Flyout</c> does not close on its own when something inside it
/// is pressed — which is what the vault switches above it want, and not what this wants.
/// </remarks>
private void OnPopoverNewVaultPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowNewVaultCommand.Execute(null);
}
}
/// <summary>Settings and Preferences both land here — see the remark in the markup.</summary>
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
}
}
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
}
}
/// <summary>
/// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/>.
/// </summary>
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.SignOutFromPopoverCommand.Execute(null);
}
}
}
@@ -0,0 +1,49 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionHeader"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
forward" button — none of those are facts this application has.
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
surface-aware property, so this control asks no question about which screen it is on. What differs
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
-->
<Border Height="60" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
<!--
The address, only while a session/host context is active — see SessionAddress's own remark for what
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
form to fill in.
-->
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
<Button Grid.Column="1" Classes="headerghost"
Content="{Binding #Root.OpenLabel}"
Command="{Binding #Root.OpenCommand}"
CommandParameter="{Binding #Root.OpenCommandParameter}" />
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,58 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
/// See the remark at the top of SessionHeader.axaml.
/// </summary>
internal sealed partial class SessionHeader : UserControl
{
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
internal static readonly StyledProperty<string?> OpenLabelProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
/// <summary>What the cross-surface ghost button runs.</summary>
/// <remarks>
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
/// </remarks>
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
internal static readonly StyledProperty<string?> EmptyTextProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
public SessionHeader() => InitializeComponent();
internal string? OpenLabel
{
get => GetValue(OpenLabelProperty);
set => SetValue(OpenLabelProperty, value);
}
internal ICommand? OpenCommand
{
get => GetValue(OpenCommandProperty);
set => SetValue(OpenCommandProperty, value);
}
internal object? OpenCommandParameter
{
get => GetValue(OpenCommandParameterProperty);
set => SetValue(OpenCommandParameterProperty, value);
}
internal string? EmptyText
{
get => GetValue(EmptyTextProperty);
set => SetValue(EmptyTextProperty, value);
}
}
@@ -0,0 +1,108 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionSidebar"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session sidebar ────────────────────────────────────────────────────────────────────────────────
300 pixels, Sidebar background, a 1px left border — the design's own right-hand column on both the
terminal and the SFTP screen. It replaces the pin chip strip that used to sit above the terminal: see
MainWindowViewModel.ActiveTabPinnedPaths and OpenPinnedPathCommand, both reused here unchanged, and
MainWindow.axaml for where the old strip's Border used to live.
QUICK ACCESS is on both surfaces. SNIPS is the terminal's own — gated on <see cref="ShowsSnips"/>, which
the terminal usage in MainWindow.axaml sets true and the SFTP usage leaves false, rather than a second
copy of this file: the two sections share nothing surface-specific except which one is drawn at all.
Every row here is a command the shell already exposes for exactly this purpose — see
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
so this control carries no logic of its own beyond the list it draws and the click it forwards.
-->
<Border Width="300" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" Margin="16,20">
<!-- ============ QUICK ACCESS ============ -->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
Foreground="{StaticResource TextGhost}"
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
</Grid>
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="11" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session sidebar: QUICK ACCESS on both surfaces, SNIPS on the terminal's own. See the remark at
/// the top of SessionSidebar.axaml.
/// </summary>
internal sealed partial class SessionSidebar : UserControl
{
/// <summary>Whether the SNIPS section draws below QUICK ACCESS.</summary>
/// <remarks>
/// Set from the usage site rather than inferred from a surface flag on the shell, for the same reason
/// <see cref="SessionTabRow.TabCommand"/> is: which sections a particular instance of this control shows
/// is a fact about where it was placed in <c>MainWindow.axaml</c>, not one this control can read off its
/// own <c>DataContext</c>.
/// </remarks>
internal static readonly StyledProperty<bool> ShowsSnipsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
public SessionSidebar() => InitializeComponent();
internal bool ShowsSnips
{
get => GetValue(ShowsSnipsProperty);
set => SetValue(ShowsSnipsProperty, value);
}
}
@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionStatusBar"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
key/credential name; none of the three is reachable from this shell today — see the wave B report this
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
them is drawn, omitted rather than faked, per this project's honesty rule.
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
the same state the header answers with its own empty-state sentence; the elapsed timer, off
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
to it.
-->
<Border Height="37" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
VerticalAlignment="Center" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
IsVisible="{Binding IsSessionConnected}" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !IsSessionConnected}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="UTF-8"
IsVisible="{Binding #Root.ShowsEncoding}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,30 @@
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
/// </summary>
internal sealed partial class SessionStatusBar : UserControl
{
/// <summary>
/// Whether "UTF-8" is drawn on the right.
/// </summary>
/// <remarks>
/// Set true only by the terminal surface's own usage in <c>MainWindow.axaml</c>. It is a fact about this
/// client's renderer and its write path — see the remark on <c>SessionStatusBar.axaml</c> — and has
/// nothing to do with an SFTP session, which moves bytes rather than decoded text.
/// </remarks>
internal static readonly StyledProperty<bool> ShowsEncodingProperty =
AvaloniaProperty.Register<SessionStatusBar, bool>(nameof(ShowsEncoding));
public SessionStatusBar() => InitializeComponent();
internal bool ShowsEncoding
{
get => GetValue(ShowsEncodingProperty);
set => SetValue(ShowsEncodingProperty, value);
}
}
@@ -0,0 +1,102 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionTabRow"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's in-screen tab row, the replacement for TerminalTabs ───────────────────────────────────────────
One pill per open terminal and the button that opens another — the same list <c>TerminalTabs</c> drew
above the whole window — now drawn inside each of the two screens the design gives a tab row: the
terminal surface and the SFTP surface. See <c>MainWindow.axaml</c> for where the strip itself went and
<c>Palette.axaml</c>'s v5b remark for why the row is 38 tall rather than the old strip's 42.
One control rather than two copies of the same markup, because the two rows share everything except two
things: which colour marks the active tab, and what a click on a tab actually does. Both are handed in
from the usage site rather than branched on a mode property here.
◆ THE COLOUR is <c>Classes="sftp"</c> on this control's own usage in <c>MainWindow.axaml</c> — unmarked
is the terminal row and reads <c>TerminalTabAccent</c>; <c>.sftp</c> reads <c>Magenta</c>. Both are
<c>App.axaml</c> selectors keyed off that class on this element, which is why the two rows need no
binding of their own for it: <c>Button.sesstab.active</c> and <c>.sftp Button.sesstab.active</c> are
the whole of it.
◆ THE CLICK is <see cref="TabCommand"/>, a plain <c>ICommand</c> this control exposes rather than reads
off the shell — the terminal row binds it to <c>SelectTabCommand</c> and the SFTP row to
<c>SelectFilesHostCommand</c>, and neither of those is a decision this control has any business making.
Every tab button's own <c>CommandParameter</c> is the tab itself, exactly as the strip's was.
The close box, the middle-click gesture and the "+" are not parameterised: closing a tab ends its shell
regardless of which screen it was clicked from, and "+" always opens the same palette. See
<c>MainWindowViewModel.CloseTabCommand</c> and <c>ToggleSearchCommand</c>.
-->
<Border Height="38">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Spacing="6">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<!--
Marked on IsSelected rather than IsShowing, unlike the old strip: IsShowing is "the terminal
surface is showing this tab's pane", which the SFTP row's own active tab is never true of. A
selected tab survives navigating away from the terminal surface — that is the whole point of
a selection outliving a screen — so both rows agree on what "active" means without either
one needing a flag scoped to just one surface. See App.axaml's own remark on Button.sesstab.
-->
<Button Classes="sesstab"
Classes.active="{Binding IsSelected}"
Command="{Binding #Root.TabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
<!--
Two states, as the strip's own dots always were: green while the shell behind this tab is
running, grey while it is connecting and once it has ended. The design's third, amber,
state has no meaning here — nothing in this application checks whether a host is merely
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
-->
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="&#x2715;" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Opens the quick-connect palette, on both rows: "the existing new-connection/quick-connect meaning"
the notes ask this button to keep. See TerminalTabs' own remark, carried over unchanged, on why
this is a palette and never a flyout menu over the terminal's own rectangle.
-->
<Button Classes="sesstab plus"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -0,0 +1,63 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b in-screen tab row: one pill per open terminal, and the "+" that opens another — parameterised by
/// <see cref="TabCommand"/> so the terminal surface and the SFTP surface can each wire a click to a different
/// meaning over the same list. See the remark at the top of SessionTabRow.axaml.
/// </summary>
internal sealed partial class SessionTabRow : UserControl
{
/// <summary>What a left click on a tab runs, with the tab itself as the command parameter.</summary>
/// <remarks>
/// A plain <see cref="ICommand"/> rather than a bound property read off the shell, because which command
/// that is is the one thing this control cannot decide for itself — the terminal surface wants
/// <c>SelectTabCommand</c> and the SFTP surface wants <c>SelectFilesHostCommand</c>, and only the caller
/// in <c>MainWindow.axaml</c> knows which screen this instance is on.
/// </remarks>
internal static readonly StyledProperty<ICommand?> TabCommandProperty =
AvaloniaProperty.Register<SessionTabRow, ICommand?>(nameof(TabCommand));
public SessionTabRow() => InitializeComponent();
internal ICommand? TabCommand
{
get => GetValue(TabCommandProperty);
set => SetValue(TabCommandProperty, value);
}
/// <summary>
/// Closes a tab on a middle click. See the identical remark on the strip this control replaced,
/// <c>TerminalTabs.axaml.cs</c>, for why this is <c>PointerUpdateKind</c> rather than
/// <c>IsMiddleButtonPressed</c>, why it fires on press rather than release, and why it is wired on the
/// tab's own template root rather than on the row.
/// </summary>
/// <remarks>
/// Not parameterised like <see cref="TabCommand"/>: closing a tab ends its shell regardless of which
/// screen the middle click landed on, so both rows want the same answer — <c>CloseTabCommand</c>, read
/// directly off this control's own <see cref="StyledElement.DataContext"/>, which is the shell on both.
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
e.Handled = true;
shell.CloseTabCommand.Execute(tab);
}
}
@@ -1,314 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TerminalTabs"
x:DataType="vm:MainWindowViewModel">
<!--
The window's tab strip: three fixed tabs, then one per open terminal.
── IT IS NOT ONLY TERMINALS ANY MORE, and the type is still called TerminalTabs. ─────────────────────
Vaults, SFTP and S3 sit at the head of the strip and are always there. The name stays because the strip
is named in MainWindow, in the layout harness's height budget and in its own suite, and renaming a type
to track what it grew into is a rename across four files that leaves the product identical. What the
name now under-describes is written here instead.
── THE THREE FIXED TABS ─────────────────────────────────────────────────────────────────────────────
None of the three can be closed, and that is the difference between them and a terminal rather than a
styling choice. A terminal tab owns a shell and closing it ends that shell; these three own nothing —
they are three places this window goes, and a close box on one would be asking whether to destroy a
destination.
Vaults is first and is the only one with anything under it: the nav rail, and whichever of its screens
the rail points at. SFTP and S3 were rail entries until this strip existed, and they moved because they
are the two destinations you *stay in* while something runs. The rail is drawn only under Vaults; see
MainWindowViewModel.IsVaultsTab for why that is expressed as a page test rather than as a surface.
S3 carries no count although the rail entry it replaces did. There is room for one, and a number on two
of five tabs reads as a fact about those two rather than as the tab's own state — a terminal tab has
nothing to count, and the eye reads the strip left to right expecting the same shape.
── THE TERMINAL TABS ────────────────────────────────────────────────────────────────────────────────
Every one is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session.
The strip spans the whole window rather than one screen, which is what it is for: a connection you
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
that state.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Tabs became pills: taller, rounded, each with its own outline, on the sidebar's surface rather than the
chrome's. The design puts a "Hosts" pill at the head of this strip and hides the sidebar while a session
is showing, so that pill is the only way back. The three fixed tabs are that idea taken at its word and
one step further: the rail is not hidden, but it belongs to the Vaults tab, and the head of the strip is
where you go to get back to it.
-->
<Border Height="42" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<!--
Everything in one scrolling row: the three fixed tabs, a rule, the terminals, the button that opens
another, then the sentence for when there are none. The strip stays rather than collapsing — a row of
chrome that appears and disappears would move every screen up and down by 42 pixels each time the last
tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
<!--
The three that are always here. Buttons with no close box, marked active from the shell's own
state rather than holding a selection of their own — the same reason the terminal tabs below are
buttons and not a TabStrip.
Each is lit by a different property and the three are exclusive by construction: IsVaultsTab is
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
IsBucketsShowing that both heads already use. Nothing here can light two at once.
-->
<!--
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
one button with a menu, because the tab's job is to go somewhere and that must stay a single
click — a tab you cannot press without being asked a question is not a tab.
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
rectangle, and whether a popup dropping into it composites above a native child window is not
something this project treats as settled without a screenshot.
What makes the question not arise here is the order in the handler. OnVaultMenuPressed selects
the Vaults tab *first*, which sets the shell's surface to a page and collapses the renderer — so
by the time the flyout opens there is no native child window under it. Exactly the move
QuickConnect already makes. It is also the behaviour a user expects: the caret belongs to the
Vaults tab, so pressing it going to Vaults is not a surprise.
The handler is explicit rather than Button.Flyout's own open, so that ordering is a thing the
code states and the headless suite can assert, rather than a thing the framework happens to do.
-->
<StackPanel Orientation="Horizontal" Spacing="0">
<Button Classes="flat tab fixed split" Classes.active="{Binding IsVaultsTab}"
Command="{Binding ShowVaultsCommand}"
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="Vaults" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="VaultMenu" Classes="flat tab fixed caret" Width="22"
Classes.active="{Binding IsVaultsTab}"
Click="OnVaultMenuPressed"
ToolTip.Tip="Choose which vaults this window shows, or make a new one">
<TextBlock Text="⌄" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
<FlyoutBase.AttachedFlyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="230" Spacing="8">
<!--
Chips rather than checkable menu items. Nothing in this application uses a checkable
MenuItem, and binding one needs an ItemContainerTheme to reach ToggleType and IsChecked
plus a composed collection to put a fixed entry after a bound one — where the chip
toggle beside every host's tags already says on-and-off in this window's own language.
-->
<TextBlock Classes="label" Text="SHOW ITEMS FROM"
IsVisible="{Binding HasVaultSwitches}" />
<ItemsControl ItemsSource="{Binding VaultToggles}"
IsVisible="{Binding HasVaultSwitches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultToggleViewModel">
<Button Classes="chiptoggle" Classes.worn="{Binding IsShown}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="0,0,0,4"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Display}" FontSize="11"
TextTrimming="CharacterEllipsis" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
A hint rather than a disabled switch, because the personal vault's chip is drawn lit and
pressing it says the same thing in the status bar. One sentence under the list is where
somebody looks when a chip does not move.
-->
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasVaultSwitches}"
Text="Switching a vault off only stops it being listed here. It still syncs, and hosts that authenticate with its keys still connect." />
<Border Height="1" Background="{StaticResource BorderSubtle}"
IsVisible="{Binding HasVaultSwitches}" />
<!--
A handler rather than a Command binding, because this one navigates and the menu has to
shut on the way. A Flyout stays open when something inside it is pressed — which is
right for the chips above, where switching two vaults off is one visit — and wrong for
the one entry that leaves.
-->
<Button Classes="ghost" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Content="New vault…"
Click="OnNewVaultPressed"
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" />
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</Button>
</StackPanel>
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="⇅" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="SFTP" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The same screen as SFTP over the same view model — an object store and an SFTP host are both an
IRemoteFileStore — and a separate tab anyway, because which picker is offered is decided by the
destination rather than by a toggle inside the screen. See ShowFiles, which also explains why
pressing this while an SFTP session is open refuses instead of arriving.
-->
<Button Classes="flat tab fixed" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="◳" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="S3" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
What separates the fixed tabs from the terminals. Without it the strip is five pills of the same
shape and the user has to read all five to learn that three of them are places and two are
machines. It is a rule rather than a gap because a gap at this width reads as the strip having
been laid out carelessly.
-->
<Border Width="1" Height="18" Margin="6,0,10,0" VerticalAlignment="Center"
Background="{StaticResource Border}" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<!--
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
for the tab to its right.
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
event handled — so a left press on the cross does not also select the tab. It deliberately
does not handle any other button, which is exactly what lets a middle press bubble out of
the cross and reach the handler below.
Marked active on IsShowing rather than on IsSelected, which are not the same question. The
selection survives navigating away — that is what makes the strip a way back to a terminal —
so a tab lit while preferences filled the window would be a second "you are here" mark
pointing at something nobody can see. See TerminalTabViewModel.IsShowing.
-->
<Button Classes="flat tab"
Classes.active="{Binding IsShowing}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey while it is connecting and once
it has ended. The pane keeps its scrollback either way, which is usually why somebody is
still looking at a tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
What a tab with no pane has to say for itself: "connecting…" while the handshake runs,
and the refusal once one has failed. It is here rather than only on the card because the
whole point of not blocking the window is that the user is somewhere else — the strip is
the one piece of chrome that is on screen wherever that is.
-->
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="10.5"
MaxWidth="180" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !HasSession}" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
looking for it.
-->
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
honestly, and there is one way to start a connection rather than two that have to agree.
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
dropping into it composites above a native child window depends on whether Avalonia gives it its
own platform window. docs/platform-flags.md records what this project already paid for treating a
rendering claim as settled without a screenshot. The palette has no such question — opening it
collapses the terminal outright.
-->
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<!--
Nothing open, and this is where that is said. It is also the only place near the terminal that can
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="10.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -1,108 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The tab strip, above every screen.</summary>
internal sealed partial class TerminalTabs : UserControl
{
public TerminalTabs() => InitializeComponent();
/// <summary>
/// Closes a tab on a middle click.
/// </summary>
/// <remarks>
/// <para>
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
/// </para>
/// <para>
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
/// and for every press during a middle drag. The question here is which button caused this press, and
/// that is the one thing only <c>PointerUpdateKind</c> answers.
/// </para>
/// <para>
/// On press rather than on release, which is what every browser and every terminal does. Matching a
/// release to its press would need capture tracking, to buy the ability to change your mind about a
/// middle click — a gesture nobody makes by accident and nobody aborts.
/// </para>
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
e.Handled = true;
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
// it. Its failures are the workspace's to report, not this strip's.
shell.CloseTabCommand.Execute(tab);
}
/// <summary>
/// Opens the vault menu, on the Vaults tab.
/// </summary>
/// <remarks>
/// <para>
/// <b>The tab is selected before the menu opens, and that order is the whole reason this is a handler
/// rather than <c>Button.Flyout</c>.</b> Selecting it puts the shell on a page, which collapses the
/// renderer — so the popup never has to drop over the WebView's native child window, and the question
/// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the
/// caret in the markup, and <c>docs/platform-flags.md</c> for what treating such a question as settled
/// has already cost this project.
/// </para>
/// <para>
/// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at
/// Vaults is the same gesture as pressing the tab, with a menu on the end.
/// </para>
/// </remarks>
private void OnVaultMenuPressed(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel shell || sender is not Control caret)
{
return;
}
shell.ShowVaultsCommand.Execute(null);
FlyoutBase.ShowAttachedFlyout(caret);
}
/// <summary>Leaves for the teams screen with the new-vault form open, shutting the menu behind it.</summary>
/// <remarks>
/// The menu is closed first, because the command navigates and a flyout left open would be hanging over
/// a screen it has nothing to do with. A <c>Flyout</c> does not close when something inside it is
/// pressed — which is what the switches above it want, and not what this wants.
/// </remarks>
private void OnNewVaultPressed(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel shell)
{
return;
}
if (this.FindControl<Button>("VaultMenu") is { } caret)
{
FlyoutBase.GetAttachedFlyout(caret)?.Hide();
}
shell.ShowNewVaultCommand.Execute(null);
}
}
+55 -50
View File
@@ -9,86 +9,83 @@
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
is that a 38-pixel grey system bar above a near-black application is the one part of the window that
is that a 53-pixel grey system bar above a near-black application is the one part of the window that
would look borrowed.
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
overlapping it would be painted underneath and its close button would not be clickable.
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
Redrawn against TitleBar.dc.html rather than the v3 mock this replaced, and three things left with the
redraw. The vault chip and the account name are gone from here — both now live on the rail's user chip,
which is where the design's own "who is signed in" lives too, one destination lower than the window's
own name. 44 pixels became 53, which is the design's own height and not a number this file chose; see
<c>LayoutHarness.TitleBarHeight</c> and the matching 9-pixel rise in <c>MainWindow.axaml</c>'s own
<c>MinHeight</c>, which is what keeps every screen the exact height it was designed against despite the
bar above it growing.
The design's kbd chip reads ⌘K; this one reads CTRL K, because a Windows build is not where the ⌘ key
lives — the same substitution the search box's own tooltip already made before this pass touched it.
SYNCED stays, on the right, past the window buttons' own left edge: a documented deviation from a design
whose titlebar has no home for it at all — see design-notes/v5b-fidelity-notes.md.
-->
<Border Height="44" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0,10,0">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<!--
Filled rather than outlined since v2, and rounded. The same mark the phone's header draws and the
same one the launcher icon carries, so the three cannot drift.
24 pixels and radius 7, the design's own tile — up from v3's 20/6, which was this bar's own
approximation before there was a mock to measure against. Filled rather than outlined, and the
same mark the phone's header draws and the launcher icon carries, so the three cannot drift.
-->
<Border Width="20" Height="20" CornerRadius="6" Background="{StaticResource Accent}">
<Border Width="24" Height="24" CornerRadius="7" Background="{StaticResource Accent}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<!--
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
sans face while the address, the account and the fingerprint beside it stay monospaced.
19 bold at -0.2 tracking, the design's own numbers. Named, because a nightly says so here — see
the code-behind, where the release build is exactly what this markup says and nothing changes
for it.
-->
<!--
Named, because a nightly says so here. See the code-behind: the release build is what this
markup says and nothing changes for it.
-->
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<!--
The design puts an organisation here — "dodotech / platform". There are no organisations: the
server has team tables and no endpoint that reads them, so the only name this application can
truthfully print is the one on the vault it has open. The account is beside it because a machine
can be enrolled to one account at a time and knowing which is the point of the chip.
-->
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
<TextBlock Text="{Binding Vault.VaultName}" />
</Border>
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="220" />
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="19" FontWeight="Bold"
LetterSpacing="-0.2" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<!--
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
514 wide and centred, which is what the design states for this box specifically — MaxWidth rather
than Width, so the button is free to shrink at the window's minimum instead of arranging outside its
own parent when there is no room for all 514 of it.
-->
<!--
Stretch-to-a-maximum, not a fixed width. The design draws this box at exactly 380 and centred, and
stating that as a Width on the Border is what makes it wrong: the Button around it is free to
shrink when the account name or the vault chip beside it is long, and a Border that will not shrink
with it arranges outside its own parent — over the name on one side and over the window buttons on
the other. MaxWidth on the stretching button gives the same 380 whenever there is room and gives
way when there is not.
-->
<Button Grid.Column="1" Classes="flat" MaxWidth="380" Height="28" Margin="16,0"
<Button Grid.Column="1" Classes="flat search" MaxWidth="514" Height="35"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="8" Padding="10,0">
<Border Classes="searchpill" CornerRadius="10" Padding="16,0,8,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" Text="&#xE8B6;"
FontSize="15" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!--
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
wider promise of running a command — a snippet is inserted from its own screen, and a box that
offered to run one would be offering something this palette does not do.
-->
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13" Margin="8,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
Foreground="{StaticResource TextFaint}" />
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
Background="{StaticResource KbdChip}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Border>
@@ -112,20 +109,28 @@
<Border Width="1" Height="16" Background="{StaticResource Border}"
IsVisible="{Binding IsUnlocked}" />
<!--
The window controls, restyled to the design's own quiet Material glyphs: remove, crop_square,
close, rather than the dashes and box this bar drew before there was a mock to measure them
against. What each does is unchanged — see the code-behind.
-->
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
ToolTip.Tip="Minimise">
<TextBlock Text="" FontSize="13" Foreground="{StaticResource TextDim}"
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE15B;" FontSize="14"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
ToolTip.Tip="Maximise">
<TextBlock Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C6;" FontSize="13"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
<TextBlock Text="✕" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CD;" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
+341 -178
View File
@@ -35,40 +35,71 @@
<UserControl.Styles>
<!--
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
described as deliberately rare. This is the one place it is spent.
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
A directory used to be marked by colour alone — this application shipped no icon set when that rule
was written. It ships one now: v5b embeds Material Icons as IconFont, already spent on the nav rail,
the drawer and the session shell's own sidebar, and per SFTP.dc.html a file row draws a 14px glyph of
its own — folder for a directory, and the closest classic Material Icons has to the design's own
Material-Symbols-only "draft": insert_drive_file, a plain document rather than a page with a folded
corner. Recorded as a deviation rather than silently swapped, because "draft" is simply not a glyph
this font contains.
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
characters that actually say so. The two never compete for one TextBlock, which is what lets a
world-writable executable show both facts instead of one winning an argument.
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
screen no longer borrows it.
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
string is the compromise: the eye lands on the column, and the string itself is the detail.
A second colour still comes from the mode, and it still overrides the first rather than competing with
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
the row beside it is asking for the folder colours or the file ones. PERMS keeps its own, separate
amber for a mode anyone may write to, on the same reasoning as before: NAME says what a row is, PERMS
says what is notable about how it is set, and the two never compete for one TextBlock.
-->
<Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<!--
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
hue two steps apart, which is a shade rather than a distinction.
So an executable is Live green now. It is the one use of that colour that is not about a session, and
it earns it on the same grounds — it is a fact about the file rather than something to press, and it
is the colour this marker already was before the accent moved out from under it.
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
execute bit off a file's own mode and nothing else's.
-->
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Info}" />
</Style>
<Style Selector="TextBlock.entry.exec">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<!--
The glyph beside NAME. One quiet colour regardless of folder or file — the design's own renderVals
gives both the same default iconFg — because the glyph itself already says which one a row is; the
colour underneath it is furniture, on the same TextFaint-family step TextBlock.fieldglyph already uses
for a mark meant to be skipped rather than read.
-->
<Style Selector="TextBlock.entryicon">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<!--
SIZE and MODIFIED, both quieter than the design's own literal values ask for — rgb(93,95,116) and
rgb(110,112,137) sit a step below every text colour this palette already names. Per
design-notes/v5b-fidelity-notes.md's own instruction for values this close, the nearest existing steps
are reused rather than two new keys added for a difference nobody would see: TextGhost for SIZE, which
the design draws a shade darker than MODIFIED, and TextFaint — one step lighter — for MODIFIED, which
keeps the two in the same order the design puts them in even though neither is the design's exact hex.
-->
<Style Selector="TextBlock.entrysize">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="TextBlock.entrydate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
@@ -84,6 +115,40 @@
<Style Selector="TextBlock.perms.loose">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<!--
── v5b: the TRANSFERS strip's own progress track ────────────────────────────────────────────────────
5px, 3px radius, Chip's own fill under an AccentGradient indicator — the design's own slim bar. A real
0% is what a queued transfer draws with this: the indicator's width is nothing, which reads exactly as
the design's own queued sample row does (a bar the same colour as its own track). No indeterminate
animation is drawn for it — a queued transfer really is at zero, so zero is the honest thing to show,
not a state this screen has to invent furniture for.
-->
<Style Selector="ProgressBar.transferbar">
<Setter Property="Height" Value="5" />
<Setter Property="MinHeight" Value="5" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource AccentGradient}" />
</Style>
<!--
The strip's own right-aligned status word. Quiet by default — a stopped transfer's "stopped" gets no
rule of its own and falls through to this — and three states get their own colour: in motion, kept, and
refused. See TransferRowViewModel.StatusWord for what word each state actually prints.
-->
<Style Selector="TextBlock.transferstatus">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.transferstatus.running">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.transferstatus.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.transferstatus.failed">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="*,Auto">
@@ -99,14 +164,34 @@
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
Per SFTP.dc.html: a 10px tracked-out label beside the current path in mono 13.5, and the pane's
affordances restyled to quiet glyphs rather than dropped. "THIS MACHINE" becomes the design's own
"LOCAL" — nothing in this file's own comments ever argued for that particular wording over the
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
recorded where it is drawn.
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
thing (go to the parent directory), and the pair reading differently is the design's own choice
faithfully carried over rather than a functional difference invented to justify it.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding LocalPath}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<!--
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
directory. Without this the pane is stuck on whichever drive the user profile is on.
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
rather than the pane's own quiet icon buttons, because a drive is named rather than a single
glyph — "C:" has no honest Material Icons equivalent.
-->
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
@@ -116,14 +201,20 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="ghost" Content="{Binding Name}"
<Button Classes="panechip" Content="{Binding Name}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D8;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshLocalCommand}"
ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
</StackPanel>
</Grid>
</Border>
@@ -150,25 +241,37 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<!--
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
glyph centred with no reserved width drifts as the font's own advance width varies between folder
and insert_drive_file. 60/56 for SIZE/MODIFIED are narrower than the design's literal 80/78 — see
the row template below for why the whole column set was chosen against 204 pixels rather than the
design's own 1920-pixel canvas.
-->
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
</Grid>
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
<!--
h38, radius 7, Track for hover and selected — see ListBox.filerows in App.axaml — and no rowmark:
the design has nothing to distinguish "selected" from "hovered" beyond which one is currently true,
and :selected alone already carries that once the pointer moves on.
-->
<ListBox Grid.Row="3" x:Name="LocalList" Classes="filerows" ItemsSource="{Binding LocalEntries}"
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -219,26 +322,41 @@
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
HOST/BUCKET keep their own words rather than adopting the design's generic "REMOTE": the comment
this replaced already carried a documented honesty reason — the pair is the difference between a
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
the style moves to the design's 10px tracked label, not the wording.
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Which kind of remote this pane is for, in the place the local pane names itself. It is the
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
difference between a directory tree and a flat namespace with inferred folders in it.
-->
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" />
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
@@ -246,27 +364,22 @@
<!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
A row of its own rather than three more cells in the header above, and the reason is arithmetic
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
off the edge. The layout suite would have caught it — which is the point of stating the number
here, so the next thing added to either row is measured against it rather than tried.
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
170-pixel chip this row used to carry would have taken most of that on its own.
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
queue's own strip, which has the width for one.
The status line still is not here, for the reason the comment this replaced gave: there is no room
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
the queue's own strip.
-->
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConnected}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
</Border>
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Grid>
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Border>
<!--
@@ -277,6 +390,11 @@
This is the strongest warning on any of these screens, and deliberately: everything else this
application deletes is a tombstone against a copy the server still has, and a file on somebody's
host is bytes with nothing behind them.
A WrapPanel for the two buttons rather than a horizontal StackPanel — v5b's own change, made once
the session shell's sidebar could take this pane down to 204 pixels: "DELETE ON THE HOST" and
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
instead of one of them going off the edge, which the WrapPanel achieves for free.
-->
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
@@ -289,11 +407,12 @@
Text="{Binding PendingRemoteDeletion.FullPath}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="DELETE ON THE HOST"
<WrapPanel>
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
</StackPanel>
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelDeleteRemoteCommand}" />
</WrapPanel>
</StackPanel>
</Border>
@@ -324,37 +443,48 @@
Making a directory sits here, beside the path it would be made in, rather than with the queue's
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
"that name is already taken" is a dead end.
v5b narrows the box from 140 to 90: at the session shell's own 204-pixel width once QUICK ACCESS
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
enough for a real directory name to be typed and read back, which is what this box is for; a
name that outgrows it scrolls inside the box the way every TextBox already does.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding IsConnected}">
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
<!--
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
-->
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
</Grid>
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -392,10 +522,11 @@
HorizontalAlignment="Center" VerticalAlignment="Center">
<!--
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
application ships no icon set — see the note on colour at the top of this file — so a glyph in
a rounded square is what an icon is here, and is already the one that means "a place things
are kept".
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
pane headers, the rows and the TRANSFERS strip and not this card — and ▤ already means "a place
things are kept" without borrowing from a font built for a different visual language.
-->
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
@@ -545,94 +676,126 @@
</Grid>
<!-- ============ The queue ============ -->
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196">
<Grid RowDefinitions="Auto,*">
<!--
============ The TRANSFERS strip ============
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
DeepChrome and a top border, per SFTP.dc.html, in place of the plain Sidebar fill this used to carry —
the same surface the session shell's own header and status bar are painted in, which is what fuses the
strip into that frame rather than leaving it read as another pane. Two Borders share this row and
exactly one is ever visible: the design's own header — TRANSFERS plus a count chip — with the queue
underneath while there is one, and a single quiet line otherwise. That is the "collapse" wave C's own
brief asks for: not zero height, because the honest sentence that used to live inside the full strip
still has somewhere to be read, but far short of the full header-plus-rows shape.
<Border Grid.Row="0" Padding="12,7">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
be exactly the fabricated fact this project's honesty rule forbids. Recorded as a deviation rather than
silently dropped.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196" IsVisible="{Binding HasTransfers}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16,12">
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="TRANSFERS" VerticalAlignment="Center" />
<!--
h18, radius 5, Chip fill with no border — the design's own count chip, distinct from Border.chip's
usual bordered-and-unfilled shape elsewhere in this application. ActiveTransfersLabel is
"N active", off TransfersViewModel.ActiveTransfers: queued counts as active there for the reason
its own remark gives, which matches what this chip is naming.
-->
<Border Grid.Column="1" Height="18" CornerRadius="5" Background="{StaticResource Chip}"
Padding="8,0" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="10.5" FontWeight="Medium"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="one at a time · nothing lands at its final name until it is complete" />
Text="{Binding ActiveTransfersLabel}" />
</Border>
</Grid>
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one — unchanged in meaning
from the strip this replaced, only in paint. Right-aligned beside the queue's own "one at a time"
policy sentence, which stays for the reason it always did: the queue refuses to overwrite, and
nothing else on this screen says so.
-->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,10">
<TextBlock Grid.Column="0" Classes="mono" FontSize="10.5" Foreground="{StaticResource TextGhost}"
Text="one at a time · nothing lands at its final name until it is complete" />
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
It is here rather than beside DISCONNECT because this row spans the window and that one spans
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
width. Only while something is open — the other half of the time it is inside the invitation
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<!--
Per SFTP.dc.html: a 320-pixel ellipsized "source → destination" label, a slim progress track
and a right-aligned status word — see TransferRowViewModel.Label/StatusWord. STOP, RETRY/
RESUME and DISCARD are the app's own, kept per wave C's brief rather than dropped for having
no equivalent in the design's static mock: icon-only now, restyled to the pane headers' own
quiet-glyph idiom, with a tooltip carrying the word the button used to print.
-->
<Grid ColumnDefinitions="320,*,64,Auto" Margin="0,5" ColumnSpacing="14">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
Right-aligned in a free column, so it reads as this row's other end rather than as a third
clause of the sentence to its left.
-->
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
</Border>
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
ToolTip.Tip="{Binding Progress}" />
<ScrollViewer Grid.Row="1">
<StackPanel>
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
IsVisible="{Binding !HasTransfers}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
<TextBlock Grid.Column="2" Classes="mono transferstatus"
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="0,0,8,0">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}" />
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
<TextBlock Text="{Binding StateLabel}" />
</Border>
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
IsVisible="{Binding HasTransfers}" />
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
ToolTip.Tip="Stop this transfer"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5CD;" />
</Button>
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
ToolTip.Tip="{Binding RetryLabel}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
ToolTip.Tip="Discard this row"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
</Grid>
</Border>
<!--
The collapsed shape: no header, no count chip, no rows — just the honest sentence the full strip
carried before anything was ever queued, kept per wave C's own brief rather than dropped along with the
rest of the strip's furniture.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12" IsVisible="{Binding !HasTransfers}">
<TextBlock Classes="hint" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
</Border>
<!--
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
they arrive here on their own because this is a separate connection — a host trusted for a shell is
@@ -114,6 +114,12 @@ internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry
_ => "changed",
};
/// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
/// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
@@ -192,6 +198,24 @@ internal sealed partial class LogsViewModel : ObservableObject
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
+ "names of the fields that changed, never their contents.";
/// <summary>
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
/// actually records.
/// </summary>
/// <remarks>
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
/// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
/// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
/// error over this fact when there is one to show.
/// </remarks>
internal string SectionFact => Section is LogSection.Connections
? "an entry is written once, when a connection closes"
: "one row per write, per device";
/// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
/// <summary>Shows one of the two logs.</summary>
[RelayCommand]
private void ShowSection(LogSection section) => Section = section;
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
OnPropertyChanged(nameof(ShowsConnections));
OnPropertyChanged(nameof(ShowsActivity));
OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(SectionFact));
OnPropertyChanged(nameof(HeaderStatusLine));
}
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
}
/// <summary>A connection that is open right now.</summary>
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
/// <summary>One vault, as a switch in the rail's user popover.</summary>
/// <remarks>
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
/// settable property would be a second copy of a fact the cache already holds.
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
/// of a fact the cache already holds.
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
@@ -37,6 +40,14 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
/// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
/// <remarks>
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
/// letter is the honest half of the same badge.
/// </remarks>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
/// <summary>Whether this vault can be switched off.</summary>
/// <remarks>
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
@@ -328,6 +339,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
private CancellationTokenSource? lastConnectedTick;
/// <summary>
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
/// </summary>
/// <remarks>
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
/// case nothing else does: sitting still on a connected screen while a minute passes.
/// </remarks>
private readonly CancellationTokenSource sessionElapsedTick = new();
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -449,6 +474,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
_ = TellRendererTheFontSizeAsync();
StartSessionShellTracking();
}
/// <summary>
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
/// </summary>
/// <remarks>
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
/// </remarks>
private void StartSessionShellTracking()
{
transfers.PropertyChanged += OnTransfersPropertyChanged;
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? accountName;
/// <summary>
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
/// </summary>
/// <remarks>
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
/// never invented from the subject or the display name the way a naive fallback would.
/// </remarks>
[ObservableProperty]
private string? email;
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
/// <remarks>
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
/// mock's own two-letter shape.
/// </remarks>
internal string AvatarInitials
{
get
{
if (string.IsNullOrWhiteSpace(AccountName))
{
return string.Empty;
}
var words = AccountName.Split(
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
return words switch
{
[] => string.Empty,
[var only] => only[..1].ToUpperInvariant(),
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
};
}
}
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
/// <summary>
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
/// </summary>
/// <remarks>
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
/// did not.
/// </remarks>
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
}
[ObservableProperty]
private VaultViewModel? vault;
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.ManualStatus = string.Empty;
}
// ---- The desktop's fixed tabs ----
// ---- The rail's own page grouping ----
/// <summary>
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
/// </summary>
/// <remarks>
/// <para>
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
/// which is the screen you are looking at.
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
/// </para>
/// <para>
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
/// </para>
/// <para>
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> The
/// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
/// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
/// <summary>
/// Which page the Vaults tab returns to.
/// </summary>
/// <remarks>
/// <para>
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
/// screen.
/// </para>
/// <para>
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
/// worth stating because the two look alike. That one would have been a second copy of "which page",
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
/// remembering its page, the way any tab does — and nothing else reads it.
/// </para>
/// </remarks>
private ShellScreen vaultsScreen = ShellScreen.Hosts;
// ---- The rail's segmented switcher and its mode-dependent first entry ----
//
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
/// <remarks>
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
/// used to treat both as "not SFTP, not S3".
/// </remarks>
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
/// <remarks>
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
/// straight off the same three flags the switcher above lights, so the row and the segment can never
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
/// for the matching glyph and the command the row runs.
/// </remarks>
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
/// <summary>
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
/// </remarks>
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
/// <summary>Runs whichever of the three the row is currently naming.</summary>
/// <remarks>
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
/// answer to, so asking again here would be a second place those three facts could disagree.
/// </remarks>
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
private void ShowFirstRailItem()
{
if (IsBucketsShowing)
{
ShowFiles(RemoteKind.Bucket);
}
else if (IsTransfersShowing)
{
ShowFiles(RemoteKind.Host);
}
else
{
ShowTerminal();
}
}
// ---- Which vaults this window is showing ----
@@ -1299,15 +1431,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
/// </summary>
/// <remarks>
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
/// than the keyring can.
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it the
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
/// opened, so it cannot outlive the session any more than the keyring can.
/// </remarks>
private VaultVisibility? visibility;
/// <summary>
/// One switch per readable vault, for the menu on the Vaults tab.
/// One switch per readable vault, for the rail's user popover.
/// </summary>
/// <remarks>
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
@@ -1779,7 +1911,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
/// <remarks>
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
ShowScreen(ShellScreen.Preferences);
SignOut();
}
/// <summary>
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
/// </summary>
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
LiveSessionCount = workspace.LiveSessionCount;
AccountName = null;
Email = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
// object would otherwise leak.
StopLastConnectedTick();
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
sessionElapsedTick.Dispose();
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
@@ -2984,13 +3141,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
AdoptTab(adopted);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
tab.StartedAt = clock.GetUtcNow();
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
/// </summary>
/// <remarks>
/// <para>
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para>
/// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
/// sites for the list of moments that counts as.
/// </para>
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
ActiveTabPinnedPaths.Clear();
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
return;
}
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
// The active tab's host, and only when it is actually connected: the strip is for a session that
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
// The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
}
/// <summary>
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
/// </summary>
/// <remarks>
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
/// only where they are drawn.
/// </remarks>
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
/// <summary>
/// Whether the pin strip draws at all.
/// Whether the v5b session sidebar draws at all.
/// </summary>
/// <remarks>
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
/// the rare frame between selecting a terminal tab and navigating away from it.
/// <para>
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface &amp;&amp; HasActiveTabPinnedPaths</c>
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
/// own "hides when no session is active".
/// </para>
/// <para>
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
/// </para>
/// </remarks>
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
internal bool ShowsQuickAccessSidebar =>
(IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected);
/// <summary>
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
/// </summary>
/// <remarks>
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
}
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
/// <summary>
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
/// </summary>
/// <remarks>
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
/// underneath it agrees what it is editing.
/// </remarks>
[RelayCommand]
private void AddSnippetFromSidebar()
{
ShowScreen(ShellScreen.Snippets);
SnippetsScreen?.NewCommand.Execute(null);
}
/// <summary>
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
/// </summary>
/// <remarks>
/// <para>
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
/// writing to the renderer directly — that command already carries the whole safety story the snippets
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
/// that runs. A second insert path here would be a second place that story could go stale.
/// </para>
/// <para>
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
/// </para>
/// </remarks>
[RelayCommand]
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
{
if (SnippetsScreen is not { } screen || snip is null)
{
return;
}
screen.Selected = snip;
if (screen.CanInsert)
{
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
return;
}
ShowScreen(ShellScreen.Snippets);
}
/// <summary>
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
/// </summary>
/// <remarks>
/// The closest honest affordance rather than a new one: this application has no way to open the host
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
/// </remarks>
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
ShowScreen(ShellScreen.Hosts);
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>
/// <remarks>
/// <para>
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
/// it and land the remote pane there.
/// </para>
/// <para>
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
/// </para>
/// </remarks>
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
SelectedTab = tab;
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
}
/// <summary>
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
/// </summary>
/// <remarks>
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
/// </remarks>
[RelayCommand]
private async Task OpenTerminalForFilesHostAsync()
{
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
{
return;
}
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>
/// The account and endpoint the session shell's header and status bar are about right now, or null when
/// neither surface has one.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
/// </remarks>
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
_ => null,
};
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
/// <remarks>
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
/// that is still connecting has an address — it is what the connecting card names — but no live shell
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
/// </remarks>
internal bool IsSessionConnected => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IsLive is true,
_ when IsTransfersShowing => Transfers.IsConnected,
_ => false,
};
/// <summary>
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
/// </summary>
/// <remarks>
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
/// </remarks>
internal string? SessionElapsedText
{
get
{
var startedAt = Surface switch
{
ShellSurface.Terminal => SelectedTab?.StartedAt,
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
_ => null,
};
if (startedAt is not { } started)
{
return null;
}
var elapsed = clock.GetUtcNow() - started;
if (elapsed < TimeSpan.Zero)
{
// The clock this ran on and the clock the session opened on can disagree by a hair when both
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
// rather than a session that has not started yet.
elapsed = TimeSpan.Zero;
}
return string.Create(
CultureInfo.InvariantCulture,
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
}
}
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
/// <remarks>
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
/// </remarks>
private void RaiseSessionState()
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
}
/// <remarks>
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
/// fact the header, the status bar or the SFTP tab row's active mark reads.
/// </remarks>
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo))
{
RaiseSessionState();
}
}
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
OnPropertyChanged(nameof(SessionElapsedText));
}
}
catch (OperationCanceledException)
{
// The shell is closing; see DisposeAsync.
}
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
RaiseSurfaceState();
// What the Vaults tab comes back to; see the field.
if (IsVaultsPage(value))
{
vaultsScreen = value;
}
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void RaiseSurfaceState()
{
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
// would answer correctly the next time anything else asked it.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
// off a terminal tab to a page screen, even though the property itself would answer correctly the
// next time anything else asked it.
RaiseSessionState();
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
// The rail's switcher and its mode-dependent first row read the same three flags above, so
// whatever moved them has to repaint these too — see the remarks on each.
OnPropertyChanged(nameof(IsSshShowing));
OnPropertyChanged(nameof(FirstRailItemLabel));
OnPropertyChanged(nameof(FirstRailItemIcon));
RaiseTerminalState();
}
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsConnectingShowing));
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
// page reached by clicking away from a terminal tab.
RaiseSessionState();
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
@@ -187,11 +187,34 @@ internal sealed partial class SnippetsViewModel : ObservableObject
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
/// <remarks>
/// Off while the move panel is up, which takes their place — the same rule the host pane's
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
/// otherwise still be there offering to open it again.
/// Off while the move panel or the delete confirmation is up, either of which takes their place — the
/// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
/// that opened one would otherwise still be there offering to open it again.
/// </remarks>
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
internal bool ShowsSelectionActions => HasSelection && !IsMoving && !IsConfirmingDelete;
/// <summary>
/// ── v5b ── Whether the desktop's own delete confirmation card is up.
/// </summary>
/// <remarks>See <see cref="RequestDelete"/> for why this exists beside <see cref="DeleteAsync"/> rather than in it.</remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
private bool isConfirmingDelete;
/// <summary>Which snippet the open confirmation card is about.</summary>
private SnippetRowViewModel? pendingDelete;
/// <summary>The confirmation card's own question, naming the snippet.</summary>
internal string DeleteQuestion => pendingDelete is { } row ? $"Delete \"{row.Label}\"?" : string.Empty;
/// <summary>
/// The confirmation card's own consequence line — vault-wide reach, a tombstone, no undo — in the same
/// words <c>VaultViewModel.HowFarADeletionGoes</c> uses for every other kind of item.
/// </summary>
internal string DeleteConsequence => pendingDelete is { } row
? $"This snippet lives in the {row.VaultName} vault — deleting removes it for everyone who holds "
+ "that key. This writes a tombstone; there is no undo."
: string.Empty;
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
/// <remarks>
@@ -334,6 +357,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
}
/// <summary>Deletes the selected snippet.</summary>
/// <remarks>
/// Unchanged and still reachable on its own — the phone's DELETE row still calls this directly, full
/// width and below EDIT and MOVE, with no question in front of it; see the phone's own
/// <c>SnippetsScreen.axaml</c> remarks for why a counted confirmation was never drawn there. The
/// desktop's own DELETE goes through <see cref="RequestDelete"/> instead — see that command's remarks —
/// so this one command now serves two different confirmations rather than the desktop growing a second
/// deletion path.
/// </remarks>
[RelayCommand]
private async Task DeleteAsync(CancellationToken cancellationToken)
{
@@ -347,6 +378,55 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status;
}
/// <summary>
/// ── v5b ── Asks before deleting, the way every other keychain item's DELETE already does.
/// </summary>
/// <remarks>
/// Additive rather than a change to <see cref="DeleteAsync"/> itself: that command is what the phone's
/// own DELETE row still calls, uncounted, and repurposing it here would leave a phone build with a
/// button that arms a card nothing on that screen ever draws — a delete that silently stopped working.
/// The desktop's own confirmation lives here instead, mirroring <c>VaultViewModel.PendingDeletion</c>'s
/// shape without touching that type: a snippet has no <see cref="DeletionTarget"/> of its own to add
/// there, and Snippets.dc.html's own modal copy — vault-wide reach, a tombstone, no undo — is exactly
/// what <c>HowFarADeletionGoes</c> already says for every other kind, said here in the same words.
/// </remarks>
[RelayCommand]
private void RequestDelete()
{
if (Selected is not { } row)
{
return;
}
pendingDelete = row;
IsConfirmingDelete = true;
}
/// <summary>Carries out the deletion the confirm card just agreed to.</summary>
[RelayCommand]
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
{
if (pendingDelete is not { } row)
{
return;
}
IsConfirmingDelete = false;
pendingDelete = null;
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
Status = vault.Status;
}
/// <summary>Thinks better of it.</summary>
[RelayCommand]
private void CancelDelete()
{
IsConfirmingDelete = false;
pendingDelete = null;
}
/// <summary>
/// Opens the panel that asks which vault the selected snippet should move to.
/// </summary>
@@ -515,6 +595,15 @@ internal sealed partial class SnippetsViewModel : ObservableObject
CloseMovePanel();
}
// The confirmation card folds away with the selection it was opened about, the same rule the move
// panel above follows and for the same reason: a filter or a reload that moved the selection
// elsewhere would otherwise leave a "delete this?" card pointed at a row nobody is looking at.
if (IsConfirmingDelete && value?.EntityId != pendingDelete?.EntityId)
{
IsConfirmingDelete = false;
pendingDelete = null;
}
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
@@ -88,6 +88,20 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; }
/// <summary>
/// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is
/// none.
/// </summary>
/// <remarks>
/// Set by <c>MainWindowViewModel</c> from its own clock at the same moment it calls <see cref="Opened"/>
/// or constructs a tab that already carries a session id, rather than read here from
/// <see cref="TimeProvider.System"/> directly — a view model with its own notion of "now" is a second
/// clock the shell's tests would have no way to control. Left on a tab whose session has since ended:
/// the pane still holds the scrollback, and "how long that shell ran" stays a true fact about it after it
/// stops being live.
/// </remarks>
internal DateTimeOffset? StartedAt { get; set; }
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -40,6 +40,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
/// <remarks>
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
/// </remarks>
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
/// <remarks>
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
@@ -84,6 +93,9 @@ internal sealed class LocalEntryRowViewModel(LocalEntry entry)
internal bool IsFile => !entry.IsDirectory;
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
@@ -132,8 +144,64 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
? Transfer.RemotePath
: Transfer.LocalPath;
/// <summary>
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
/// </summary>
/// <remarks>
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
/// instead.
/// </remarks>
internal string Label
{
get
{
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
// translation to name the file at the end of it.
var source = System.IO.Path.GetFileName(sourcePath);
return $"{source} → {destinationDirectory}";
}
}
internal double Percent => Transfer.Fraction * 100;
/// <summary>
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
/// </summary>
/// <remarks>
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
/// the bar alone cannot state exactly.
/// </remarks>
internal string StatusWord => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running => string.Create(
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
TransferState.Completed => "done",
TransferState.Cancelled => "stopped",
_ => "failed",
};
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
internal bool IsInProgress => Transfer.State is TransferState.Running;
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
internal bool IsDone => Transfer.State is TransferState.Completed;
/// <summary>
/// What the row says about where it has got to.
/// </summary>
@@ -192,6 +260,9 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StatusWord));
OnPropertyChanged(nameof(IsInProgress));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsFinished));
OnPropertyChanged(nameof(CanResume));
@@ -297,6 +368,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
/// <summary>
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
/// connected.
/// </summary>
/// <remarks>
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
/// the first, for no reader that could not already reach it here.
/// </remarks>
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
private bool disposed;
/// <param name="sftp">Opens SFTP sessions.</param>
@@ -342,7 +425,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// which is the life of the process.
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
Transfers.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasTransfers));
OnPropertyChanged(nameof(ActiveTransfersLabel));
};
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
// vault on every synchronisation pass, so the invitation these flags decide between has to change
@@ -619,6 +706,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
/// <summary>
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
/// </summary>
/// <remarks>
/// A string of its own rather than a converter in the markup, for the reason every other label in this
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
/// that shape rather than a chip claiming activity that has already finished.
/// </remarks>
internal string ActiveTransfersLabel => string.Create(
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
@@ -1653,6 +1754,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
Transfers.Add(new TransferRowViewModel(e.Transfer));
}
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
// rows count as active without the collection itself gaining or losing a row, so the strip's own
// count chip needs its own raise here.
OnPropertyChanged(nameof(ActiveTransfersLabel));
ActivityChanged?.Invoke(this, EventArgs.Empty);
});
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
internal string Label => key.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
internal string Description => key.Secret switch
{
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
internal string Label => credential.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: the account, never the password.</summary>
/// <remarks>
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
@@ -1101,8 +1119,66 @@ internal sealed record VaultItemRowViewModel(
{
/// <summary>Whether this row has anything to say about its sync state.</summary>
internal bool HasBadge => Badge.Length > 0;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <remarks>
/// Additive: every other kind of row in this application already carries this fact (see
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
/// nothing does or the kind has no such fact.
/// </summary>
/// <remarks>
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
/// showing a region or an endpoint under a column that promises "who uses this".
/// </remarks>
internal string UsedBySummary { get; init; } = string.Empty;
/// <summary>Whether this row has anything to say about who uses it.</summary>
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
/// <summary>
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
/// </summary>
/// <remarks>
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
/// carries no algorithm field (see docs/design-import-gaps.md).
/// </remarks>
internal string IconGlyph => Kind switch
{
VaultItemKind.Key => "",
VaultItemKind.Credential => "",
VaultItemKind.Tag => "",
VaultItemKind.ObjectStore => "",
_ => string.Empty,
};
}
/// <summary>
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
/// </summary>
/// <param name="Label">The host's name.</param>
/// <param name="IsConnected">
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
/// </param>
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
internal enum DeletionTarget
{
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
/// <summary>
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
/// </summary>
/// <remarks>
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
/// that does nothing, on the same "narrows the list and nothing else" reasoning
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
/// so what matches is never a surprise to whoever typed it.
/// </remarks>
[ObservableProperty]
private string itemFilter = string.Empty;
/// <summary>
/// The selected row of the vault table.
/// </summary>
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
/// </summary>
/// <remarks>
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
/// no binding concept to scan at all.
/// </remarks>
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
{
0 => string.Empty,
1 => "in use · 1 host",
var n => $"in use · {n} hosts",
};
/// <summary>Whether the "in use" chip has anything to say.</summary>
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
/// <summary>Whether the selected row is one with an editor behind it.</summary>
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<SshKeyRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.SshKeys
.ListAsync(vault.VaultId, cancellationToken)
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Keys.Clear();
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<CredentialRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.Credentials
.ListAsync(vault.VaultId, cancellationToken)
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Credentials.Clear();
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
"TAG",
tag.Description,
tag.Badge,
tag.HasUnsyncedChanges));
tag.HasUnsyncedChanges)
{
// A tag's own "used by" is the host count it already carries — the same fact
// Description prints, so the two never disagree.
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
});
}
}
@@ -12047,6 +12180,61 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
/// </summary>
/// <remarks>
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
/// </remarks>
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
{
var names = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind && binding.EntityId == entityId)
.Select(row => row.Label)
.ToList();
return names.Count switch
{
0 => string.Empty,
1 => names[0],
2 => $"{names[0]}, {names[1]}",
_ => $"{names[0]} +{names.Count - 1}",
};
}
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
/// the filter has no opinion about which kind a row is.
/// </remarks>
private void ApplyItemFilter()
{
var needle = ItemFilter.Trim();
if (needle.Length == 0)
{
return;
}
for (var i = VaultItems.Count - 1; i >= 0; i--)
{
var row = VaultItems[i];
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
{
VaultItems.RemoveAt(i);
}
}
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
"SSH KEY",
key.Description,
key.Badge,
key.HasUnsyncedChanges));
key.HasUnsyncedChanges)
{
VaultBadge = key.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
});
}
}
@@ -12093,12 +12285,17 @@ internal sealed partial class VaultViewModel(
"PASSWORD",
credential.Description,
credential.Badge,
credential.HasUnsyncedChanges));
credential.HasUnsyncedChanges)
{
VaultBadge = credential.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
});
}
}
AddTagRows();
AddBucketRows();
ApplyItemFilter();
// The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedDetailHeading));
OnPropertyChanged(nameof(ShowsItemActions));
RebuildSelectedItemUsage(value);
// Every kind this table can delete, because one selection covers all of their lists.
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
@@ -12168,6 +12367,39 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
/// </summary>
/// <remarks>
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
/// </remarks>
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
{
SelectedItemUsedByHosts.Clear();
ResolvedBindingKind? kind = value?.Kind switch
{
VaultItemKind.Key => ResolvedBindingKind.SshKey,
VaultItemKind.Credential => ResolvedBindingKind.Credential,
_ => null,
};
if (kind is { } resolvedKind && value is not null)
{
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
{
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
}
}
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
OnPropertyChanged(nameof(SelectedItemInUseSummary));
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
}
/// <remarks>
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
partial void OnUnreadableItemsChanged(int value)
{
OnPropertyChanged(nameof(HasUnreadableItems));
@@ -30,11 +30,16 @@ internal static class LayoutHarness
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
/// than the one a user is allowed to drag to.
///
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
/// by the move, because the minimum grew by exactly what the two grew by.
/// </remarks>
internal const double MinimumWidth = 1016;
internal const double MinimumWidth = 1081;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
internal const double MinimumHeight = 583;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <remarks>
@@ -47,33 +52,95 @@ internal static class LayoutHarness
internal const double HostDrawerWidth = 320;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
/// <summary>
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
/// <remarks>
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. A test holds each control to its own
/// number, so the budget below cannot drift away from what the window actually leaves.
///
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
/// the matching rise that keeps every screen below it the same size it always measured.
///
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
/// is now inside the two screens that carry one, at its own 38-pixel height; see
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
/// </remarks>
internal const double TitleBarHeight = 44;
internal const double TitleBarHeight = 53;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
/// <remarks>
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
/// holds the titlebar and the status bar to theirs.
/// </remarks>
internal const double SessionTabRowHeight = 38;
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
internal const double SessionShellPadding = 26;
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
internal const double SessionSidebarWidth = 300;
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
internal const double SessionHeaderHeight = 60;
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
internal const double SessionStatusBarHeight = 37;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
/// </summary>
internal const double SessionShellBorderThickness = 1;
/// <summary>
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
/// this is what closes it.
/// </summary>
/// <remarks>
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
/// </remarks>
internal const double TerminalTabsHeight = 42;
internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- SessionHeaderHeight - SessionStatusBarHeight;
/// <summary>
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
/// column showing beside it.
/// </summary>
/// <remarks>
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
/// </remarks>
internal static double SessionScreenWidth(bool sidebarVisible) =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
- (sidebarVisible ? SessionSidebarWidth : 0);
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
/// <remarks>
@@ -111,12 +178,17 @@ internal static class LayoutHarness
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
/// <summary>
/// The height a full-bleed page screen actually gets at the window's minimum.
/// </summary>
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
/// this constant does not describe; a test measuring either of those two screens has to account for the
/// session shell's own geometry rather than reading it off this property.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
internal static double ScreenHeight => ContentHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
@@ -0,0 +1,338 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the rail's own switcher, mode-dependent first row and user popover answer a pointer.
/// </summary>
/// <remarks>
/// <para>
/// v5b moved three things onto this control that used to be tested elsewhere or not at all: the SSH/SFTP/S3
/// choice that used to be the tab strip's own fixed tabs (see <c>TerminalTabsTests</c>, which used to hold
/// the equivalent of the first two facts below), the mode-dependent first row the design calls its own
/// <c>mode</c> prop, and the popover that replaced the strip's vault menu. This suite is this control's
/// counterpart to that one — a minimal shell with two tabs and no vault, for the reason
/// <c>TerminalTabsTests</c> gives: nothing here reads <c>Vault</c> except the popover's vault switches,
/// which this suite therefore does not open — that is <c>VaultVisibilityTests</c>' business, over the
/// commands themselves, and this suite would only be re-testing the same command through a slower door.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the same reason the palette's and the strip's suites are one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>.
/// </para>
/// </remarks>
public sealed class NavRailTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"navrail-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <summary>
/// The switcher's three segments select the surface they name, and light up when it is the one showing.
/// </summary>
/// <remarks>
/// Driven through the segments rather than through the commands directly, for the reason
/// <c>TerminalTabsTests.TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage</c> gave for its own
/// three fixed tabs: what is being checked is that three buttons in the markup are wired to three
/// different things, which three commands called by hand would not catch if two of the three were
/// bound to the same one.
/// </remarks>
[Fact]
public async Task TheSwitcherSegmentsSelectTheirSurface_AndLightTheActiveOne()
{
await OnTheRailAsync((rail, window) =>
{
shell.IsSshShowing.ShouldBeTrue("nothing has navigated to SFTP or S3 yet");
Segment(rail, "SSH").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
Segment(rail, "SFTP").Classes.Contains("active").ShouldBeTrue();
Segment(rail, "SSH").Classes.Contains("active").ShouldBeFalse("exactly one segment lights at once");
Click(Segment(rail, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Segment(rail, "S3").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SSH"), window);
shell.IsSshShowing.ShouldBeTrue();
shell.IsBucketsShowing.ShouldBeFalse();
});
}
/// <summary>
/// The mode-dependent first row follows the same three flags the switcher above lights.
/// </summary>
/// <remarks>
/// The label is read straight off the row's own bound text rather than off <see cref="MainWindowViewModel"/>
/// state directly, because what a fidelity pass could break is the binding between the two, not the
/// property computing the right string on its own — <c>MainWindowViewModelTests</c> would already catch
/// that half.
/// </remarks>
[Fact]
public async Task TheFirstRailItemsLabel_FollowsTheSwitchersMode()
{
await OnTheRailAsync((rail, window) =>
{
FirstRow(rail).ShouldBe("Terminal");
Click(Segment(rail, "SFTP"), window);
FirstRow(rail).ShouldBe("Files");
Click(Segment(rail, "S3"), window);
FirstRow(rail).ShouldBe("Buckets");
Click(Segment(rail, "SSH"), window);
FirstRow(rail).ShouldBe("Terminal");
});
}
/// <remarks>
/// The user popover is a <c>Flyout</c>, and this is the same assertion
/// <c>TerminalTabsTests.TheCaretBesideVaults_OpensTheVaultMenu</c> made of the strip's own — the pointer
/// opens the popup, and nothing about its position or its content occlude anything, since it never
/// crosses into the terminal's own rectangle. See the remark in NavRail.axaml.
/// </remarks>
[Fact]
public async Task ClickingTheUserChip_OpensThePopover()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(chip, window);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Each vault switch in the popover draws the vault's own display name and shown state, and is wired
/// to the command that toggles it.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="MainWindowViewModel.VaultToggles"/> is fed directly rather than through a real sign-in
/// and a second vault created on a fake server. <c>VaultVisibilityTests</c>, in the Avalonia-free
/// <c>DodoSSH.Client.App.Tests</c> project, already proves <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself — that a hidden vault stays syncing, that the personal one refuses, and everything else the
/// command actually does once it runs. What is worth proving here, in the project that can lay markup
/// out at all, is only the wiring: that a row in this popover shows the right vault and calls that
/// command with that vault when pressed — the fact a fidelity pass to this file could actually break.
/// </para>
/// <para>
/// The command is read off the row rather than pressed, because <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself declines with no observable effect when <see cref="MainWindowViewModel.Vault"/> is null — which
/// it is here, for the reason above — so a press would prove nothing a reader could tell from a press
/// that reached the wrong command entirely.
/// </para>
/// </remarks>
[Fact]
public async Task PopoverVaultRows_NameTheirVaultAndAreWiredToToggleIt()
{
var shown = new VaultToggleViewModel(Guid.CreateVersion7(), "Personal", IsPersonal: true, IsShown: true);
var hidden = new VaultToggleViewModel(Guid.CreateVersion7(), "Platform secrets", IsPersonal: false, IsShown: false);
shell.VaultToggles.Add(shown);
shell.VaultToggles.Add(hidden);
await OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
var shownRow = PopoverRow(window, shown);
var hiddenRow = PopoverRow(window, hidden);
shownRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
shownRow.CommandParameter.ShouldBeSameAs(shown);
HasVisibleCheck(shownRow).ShouldBeTrue("the personal vault is always shown");
hiddenRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
hiddenRow.CommandParameter.ShouldBeSameAs(hidden);
HasVisibleCheck(hiddenRow).ShouldBeFalse("this one was switched off");
});
}
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
/// <remarks>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
[Fact]
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
[Fact]
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, label), window);
shell.Screen.ShouldBe(target);
shell.IsShowingPages.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
});
/// <remarks>
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
/// room for at all is drawn inline on that one screen while the vault is unlocked, and arming it from
/// anywhere else would be a card raised nobody could see.
/// </remarks>
[Fact]
public async Task ThePopoversLogoutRow_GoesToPreferencesAndArmsTheSignOutConfirmation()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, "Logout"), window);
shell.Screen.ShouldBe(ShellScreen.Preferences);
shell.IsConfirmingSignOut.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
});
}
// ---- Helpers ----
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail { DataContext = shell };
var window = new Window { Content = rail };
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
try
{
body(rail, window);
}
finally
{
window.Close();
}
},
Token);
private static Button Segment(Visual rail, string label) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("navseg")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static Button UserChip(Visual rail) =>
rail.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("navuser"));
/// <summary>
/// A row inside the open popover, found by its own data context. Searched from the window rather than
/// from the rail: a <c>Flyout</c>'s content is a popup, hosted in the window's own overlay layer rather
/// than nested inside the control that owns it, so it is outside <c>rail.GetVisualDescendants()</c>.
/// </summary>
private static Button PopoverRow(Visual window, VaultToggleViewModel toggle) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow") && ReferenceEquals(button.DataContext, toggle));
/// <summary>A navigation row inside the open popover, found by the word on it.</summary>
/// <inheritdoc cref="PopoverRow(Visual, VaultToggleViewModel)" path="/summary" />
private static Button PopoverRow(Visual window, string label) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
private static bool HasVisibleCheck(Button row) =>
row.GetVisualDescendants().OfType<Border>().Any(
border => border.Classes.Contains("vaultcheck") && border.IsVisible);
/// <summary>The mode-dependent first row's own label, read off its bound <c>TextBlock</c>.</summary>
private static string FirstRow(Visual rail) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("nav") && !button.Classes.Contains("navseg"))
.GetVisualDescendants()
.OfType<TextBlock>()
.First(text => text.Classes.Contains("navlabel"))
.Text ?? string.Empty;
private static void Click(Visual control, Window window)
{
var at = control.TranslatePoint(
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
}
@@ -825,7 +825,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
// usage is "deliberately not given the session shell above."
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
/// <remarks>
@@ -1144,18 +1147,30 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
// ---- The transfers screen ----
//
// ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
// control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
// MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
// container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
// it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
// TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
// SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
// the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.
/// <remarks>
/// <para>
/// The widest thing in this window and the one with the least room to give: two file listings side by
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
/// a button and a chip on one line.
/// The widest this screen gets and the one with the least room to give once connected: two file listings
/// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
/// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
/// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
/// actually chosen against; see its own remark on the column widths.
/// </para>
/// <para>
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
/// of real length rather than with fixtures chosen to fit.
/// Measured disconnected here, though, which is the state the screen opens in and the one where the
/// local pane is at its fullest it lists this machine's home directory, so the row template is
/// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
/// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
/// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
/// </para>
/// </remarks>
[Fact]
@@ -1253,14 +1268,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
/// that already carries UP, REFRESH and DELETE.
/// The state the screen is in once something is open, and the tightest one wave C's restyle has to
/// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width.
/// </para>
/// <para>
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
/// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
/// trimming where the header prints it.
/// </para>
/// </remarks>
[Fact]
@@ -1303,6 +1322,214 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
/// <remarks>
/// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
/// the fixture's own home directory — whose contents this suite does not control — by adding one row of
/// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
/// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
/// not proves alongside the two shared columns.
/// </remarks>
[Fact]
public async Task TheRowTemplateShowsSizeAndDateColumns()
{
var local = new LocalEntryRowViewModel(new LocalEntry(
"notes.txt",
Path.Combine(Path.GetTempPath(), "notes.txt"),
IsDirectory: false,
Length: 4_096,
new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));
// Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
// than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
// row added after all of those would never be built at all, which is a false pass rather than a
// proof — the assertion below would find nothing to have missed.
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(local);
var remote = new RemoteEntryRowViewModel(new SftpEntry(
"deploy.log",
"/srv/releases/deploy.log",
SftpEntryKind.File,
49_152,
new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
"-rw-r--r--"));
transfers.RemoteEntries.Add(remote);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();
texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
/// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
/// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
/// rendered onto the strip itself.
/// </remarks>
[Fact]
public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
{
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");
var expected = new[] { "queued", "64%", "done", "stopped", "failed" };
transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var words = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.Classes.Contains("transferstatus"))
.Select(text => text.Text ?? string.Empty)
.ToList();
words.ShouldBe(expected);
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
/// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
/// absent with nothing queued, and a queued transfer brings the full strip straight back.
/// </remarks>
[Fact]
public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
{
transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");
var collapsed = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Text is { } spoken
&& spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));
collapsed.IsEffectivelyVisible.ShouldBeTrue(
"the honest sentence stays reachable in the collapsed shape");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane.
/// </remarks>
[Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{
transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
0, TimeProvider.System.GetUtcNow(), string.Empty)));
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
TransferState.Running, bytesPerSecond: 6_500_000);
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
TransferState.Cancelled);
await MeasureTransfersAsync(
faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
}
// ---- The chrome ----
/// <remarks>
@@ -1355,27 +1582,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
/// the entries have to leave room for each other, which is the same failure the old four-button selector
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
/// pass every other assertion here.
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
/// rows below it and the user chip at the foot, both the rail's full content width. A single
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
/// own width now.
/// </para>
/// <para>
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
/// in. See <c>NavRail.axaml</c>.
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
/// entry silently dropping off the bottom would still pass every other assertion here.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail();
var rail = new NavRail { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
@@ -1383,18 +1610,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
var rows = buttons.Where(button => button.Classes.Contains("nav")
&& !button.Classes.Contains("navseg")).ToList();
var chip = buttons.Single(button => button.Classes.Contains("navuser"));
foreach (var button in buttons)
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
rows.Count.ShouldBe(
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
foreach (var segment in segments)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
segment.Bounds.Height.ShouldBeGreaterThan(20);
}
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
// each side that v2 gives the rows so a filled one reads as a rounded row rather
// than as a full-width band. Stated exactly rather than as a lower bound: a button
// that stopped filling the row would leave a dead strip beside a destination, which
// is precisely the kind of near-miss a bound hides.
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
foreach (var row in rows.Append(chip))
{
row.Bounds.Height.ShouldBeGreaterThan(20);
// 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
// inset v5b's own padding gives each side. Stated exactly rather than as a lower
// bound: a row that stopped filling the width would leave a dead strip beside a
// destination, which is precisely the kind of near-miss a bound hides.
row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
}
LayoutHarness.Unreachable(window).ShouldBeEmpty();
@@ -1579,8 +1817,21 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// <para>
/// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
/// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
/// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
/// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
/// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
/// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
/// not the plain screen budget.
/// </para>
/// <para>
/// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
/// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
/// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
/// either — see the two callers below, both of which select one before calling this.
/// </para>
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1594,7 +1845,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
@@ -1609,12 +1860,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
/// stop fitting when the strip is what it is drawn over.
/// <para>
/// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
/// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
/// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
/// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
/// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
/// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
/// the intent the two states share, not this control's actual bounds.
/// </para>
/// <para>
/// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
/// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
/// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
/// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
/// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
/// records is that no gap was left for a later wave to close.
/// </para>
/// </remarks>
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1852,7 +2114,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
/// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
/// <remarks>
/// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen,
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
LayoutHarness.SessionScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
/// <remarks>
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
/// the same budget every other full-bleed page gets.
/// </remarks>
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
@@ -1896,8 +2193,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
/// takes 190 and the vault list 268, leaving 558 for everything above.
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
/// widened the rail, because the minimum grew by exactly what the rail did.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
@@ -0,0 +1,391 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the v5b in-screen tab row answers a pointer, on both of the two ways it is used.
/// </summary>
/// <remarks>
/// The replacement for <c>TerminalTabsTests</c>, which measured the window-wide strip this control replaced
/// — see <c>SessionTabRow.axaml</c>'s own remark for why one control now serves both the terminal surface and
/// the SFTP surface. The gestures that do not vary by surface — middle-click closes, "+" opens the palette,
/// the tab pointer feedback — are carried over from that suite essentially unchanged; what is new here is
/// <see cref="TabRowTabClickRunsTheGivenCommand"/> and <see cref="SftpRowMarksTheColourItIsToldTo"/>, which
/// prove the one thing this control adds: the click and the accent colour are handed in rather than fixed.
/// </remarks>
public sealed class SessionTabRowTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"session-tabs-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
/// <remarks>
/// The terminal row's own click: <c>TabCommand</c> bound to <c>SelectTabCommand</c>, the same as the
/// window-wide strip's used to be.
/// </remarks>
[Fact]
public async Task ALeftClickOnATabRunsTheBoundTabCommand()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// The one thing this control adds over the strip it replaces: which command a tab click runs is handed
/// in, not fixed. Proved with a command that is not <c>SelectTabCommand</c> at all, so a row that quietly
/// ignored <c>TabCommand</c> and fell back to selecting the tab itself would fail this rather than pass
/// it by coincidence.
/// </remarks>
[Fact]
public async Task TabRowTabClickRunsTheGivenCommand()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
var seen = new List<TerminalTabViewModel>();
var strip = new SessionTabRow
{
DataContext = shell,
TabCommand = new RelayCommand<TerminalTabViewModel>(tab => seen.Add(tab!)),
};
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
var button = TabButton(strip, shell.Tabs[0]);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
seen.ShouldHaveSingleItem().ShouldBe(shell.Tabs[0]);
shell.SelectedTab.ShouldBeNull("the given command ran instead of SelectTabCommand");
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <remarks>
/// The strip's own height budget moved with it — see <see cref="LayoutHarness.SessionTabRowHeight"/> —
/// but the invariant this test protects is the same one <c>TerminalTabsTests</c> protected: a row of
/// tabs must not grow the chrome around it, however many are open.
/// </remarks>
[Fact]
public async Task TheRowIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
strip.DesiredSize.Height.ShouldBe(LayoutHarness.SessionTabRowHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
/// identically to the plus at rest by design. This proves the same two facts a different way: an inactive
/// tab still changes under the pointer, and the plus never gets the treatment an <em>active</em> tab does
/// — the coloured top border and the DeepChrome fill — which is the one visual claim "not a thing being
/// chosen between" actually has to hold.
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsAnActiveTab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
shell.SelectTabCommand.Execute(shell.Tabs[0]);
LayoutHarness.Settle(window, 900, 600);
var activeTab = TabButton(strip, shell.Tabs[0]);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(activeTab),
"the button that opens a connection never carries the active tab's DeepChrome fill");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
/// <remarks>
/// The SFTP row's own accent: <c>Classes="sftp"</c> on this control's own usage — see
/// <c>MainWindow.axaml</c> — is what selects <c>Magenta</c> over the terminal row's <c>TerminalTabAccent</c>
/// for an active tab's top border. Read as a colour rather than a class list, so this catches the App.axaml
/// selector actually resolving to a different brush rather than merely having the class applied.
/// </remarks>
[Fact]
public async Task SftpRowMarksTheColourItIsToldTo()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.SelectTabCommand.Execute(shell.Tabs[0]);
var sshRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var sftpRow = new SessionTabRow
{
DataContext = shell,
TabCommand = shell.SelectTabCommand,
Classes = { "sftp" },
};
var sshWindow = new Window { Content = sshRow };
var sftpWindow = new Window { Content = sftpRow };
LayoutHarness.Settle(sshWindow, 900, 600);
LayoutHarness.Settle(sftpWindow, 900, 600);
try
{
var sshBorder = Presenter(TabButton(sshRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
var sftpBorder = Presenter(TabButton(sftpRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
sshBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#7C5CFF"), "TerminalTabAccent");
sftpBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#DD1296"), "Magenta");
}
finally
{
sshWindow.Close();
sftpWindow.Close();
}
},
Token);
}
// ---- Helpers ----
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
private Task OnTheStripAsync(Action<SessionTabRow, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("sesstab"));
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
}
@@ -1,507 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the tab strip answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
/// binding.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
/// </para>
/// </remarks>
public sealed class TerminalTabsTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"tabs-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
// The only state the strip is ever interactive in. Assigned rather than reached through an
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
/// one, and the strip answered nothing but a left click before.
/// </remarks>
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
/// the same feature with a way to get it wrong.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
// Well right of two short tabs and the button after them, and inside the strip's own height.
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
/// <remarks>
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
/// terminal switching to something that is about to disappear.
/// </remarks>
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
/// claim this project is willing to make without a screenshot.
/// </remarks>
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <summary>
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
/// </summary>
/// <remarks>
/// <para>
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
/// Hosts, which is most of the time.
/// </para>
/// <para>
/// Driven through the strip rather than through the commands, because what is being checked is that
/// three buttons in the markup are wired to three different things. Three commands called directly
/// would pass on a strip whose SFTP tab was bound to the S3 one.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
{
await OnTheStripAsync((strip, window) =>
{
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
Click(FixedTab(strip, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
Click(FixedTab(strip, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Click(FixedTab(strip, "Vaults"), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.Screen.ShouldBe(
ShellScreen.Snippets,
"the Vaults tab comes back to the page it was left on, not to Hosts");
});
}
/// <remarks>
/// The caret is the second half of the Vaults pill and the only control in this strip that opens a
/// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in
/// the handler rather than the flyout being attached — see the test below.
/// </remarks>
[Fact]
public async Task TheCaretBesideVaults_OpensTheVaultMenu()
{
await OnTheStripAsync((strip, window) =>
{
var caret = CaretButton(strip);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(caret, window);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The occlusion guard, and the reason this strip may have a flyout at all.</b> The comment on the
/// <c>+</c> button refuses one because a popup dropping into the terminal's rectangle would have to
/// composite above a native child window, which this project does not claim without a screenshot. The
/// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens,
/// and a page surface is one where the renderer is not drawn.
/// </para>
/// <para>
/// So the assertion is on <see cref="MainWindowViewModel.IsTerminalShowing"/> rather than on anything
/// about the popup. A change that opened the flyout without moving the surface first would still show a
/// menu in every screenshot anybody took on a machine where it happened to work.
/// </para>
/// </remarks>
[Fact]
public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt()
{
await OnTheStripAsync((strip, window) =>
{
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way");
Click(CaretButton(strip), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse(
"the flyout must never have to composite over the renderer's native child window");
});
}
/// <remarks>
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
/// SFTP.
/// <para>
/// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion
/// meaning what it says: a button inside a fixed tab would still be a close box.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsCarryNoCloseBox()
{
await OnTheStripAsync((strip, _) =>
{
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
{
FixedTab(strip, label)
.GetVisualDescendants()
.OfType<Button>()
.ShouldBeEmpty($"{label} is a destination, not a session");
}
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
/// </remarks>
[Fact]
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new TerminalTabs { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// What it asks for, not what this host window gave it. Hosting it at 34 and then
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
// exactly the regression the budget needs catching.
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>
/// A tab lights under the pointer, and the button that opens one is not drawn as a tab.
/// </summary>
/// <remarks>
/// <para>
/// The only test in this suite that reads a brush rather than a rectangle, and it is here because that
/// was the gap a real regression went through. Everything else measures heights and reachability, so a
/// strip whose tabs had silently stopped answering the pointer passed all of it.
/// </para>
/// <para>
/// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity —
/// the later declaration wins — and when the tab became a pill that paints its own background, that
/// background was declared *after* the hover rule it relied on and after the exceptions the <c>+</c>
/// is made of. So every tab lost its pointer feedback and the <c>+</c> gained a fill and an outline it
/// is specifically not supposed to have. Both are one assertion each below.
/// </para>
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
// Off the strip again, so the plus is measured at rest rather than under the pointer.
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(TabButton(strip, shell.Tabs[0])),
"the button that opens a connection is not one of the connections");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
// ---- Helpers ----
/// <summary>The presenter the Fluent theme actually paints, which is where every button style lands.</summary>
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
/// <remarks>
/// The colour rather than the brush. Two <see cref="ISolidColorBrush"/> instances holding the same
/// colour are not equal, and it is the colour a user sees.
/// </remarks>
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new TerminalTabs { DataContext = shell };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
/// classes tell them apart.
/// </remarks>
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
/// <inheritdoc cref="TabButton" />
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
/// <summary>The half of the Vaults pill that opens the vault menu.</summary>
/// <inheritdoc cref="TabButton" path="/remarks" />
private static Button CaretButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("caret"));
/// <inheritdoc cref="TabButton" />
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
/// <remarks>
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
/// first, because a terminal tab could be opened on a host called SFTP.
/// </remarks>
private static Button FixedTab(Visual strip, string label) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("fixed")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static void Click(Visual control, Window window)
{
var at = Centre(control, window);
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
}
@@ -0,0 +1,104 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The titlebar's own search pill still opens the quick-connect palette after v5b's redraw.
/// </summary>
/// <remarks>
/// A narrow suite on purpose: <see cref="LayoutHarnessTests"/> already holds the bar to its declared
/// height and proves nothing inside it is unreachable, and the search pill's own accent-on-hover border is
/// a style rule with nothing to assert headlessly. What is worth a gesture is the one thing a fidelity pass
/// could quietly break without any layout test noticing — the pill no longer opening what Ctrl+K opens.
/// </remarks>
public sealed class TitleBarTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"titlebar-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
// The search pill is disabled while locked — see TitleBar.axaml's IsEnabled binding — and this
// is the only state that gesture is reachable in.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
shell.IsSearching.ShouldBeFalse("nothing has been pressed yet");
var pill = bar.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("search"));
var centre = pill.TranslatePoint(
new Point(pill.Bounds.Width / 2, pill.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the pill is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
shell.IsSearching.ShouldBeTrue(
"the pill is the click-through path Ctrl+K also opens — see ToggleSearchCommand");
}
finally
{
window.Close();
}
},
Token);
}
}
+362 -14
View File
@@ -2789,6 +2789,125 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("host");
}
/// <remarks>
/// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
/// count beside every category row, and this pins that each one is the same number the category's own
/// list already carries rather than a second, hand-kept tally that could drift from it.
/// </remarks>
[Fact]
public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddKeyAsync(vault, "backup");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.Keys.Count.ShouldBe(2);
vault.Credentials.Count.ShouldBe(1);
vault.Tags.Count.ShouldBe(1);
vault.ObjectStores.Count.ShouldBe(0);
vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
}
/// <remarks>
/// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
/// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
/// which is the whole reason the column exists.
/// </remarks>
[Fact]
public async Task EachVaultItemKindCarriesItsOwnGlyph()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.ShowSectionCommand.Execute(VaultSection.All);
var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);
byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();
new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
.Distinct(StringComparer.Ordinal).Count()
.ShouldBe(3, "three kinds on the same table read as three different glyphs");
}
/// <remarks>
/// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
/// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
/// clearing it brings every row straight back.
/// </remarks>
[Fact]
public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy-key");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ItemFilter = "deploy";
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);
vault.ItemFilter = "PASSWORD";
vault.VaultItems.Select(row => row.Name).ShouldBe(
["pg-primary"], "the type word matches too, case-insensitively");
vault.ItemFilter = string.Empty;
vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
}
/// <remarks>
/// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
/// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
/// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
/// them going stale relative to the others.
/// </remarks>
[Fact]
public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-api-01");
await AddKeyAsync(vault, "deploy");
var key = vault.Keys[0];
await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);
vault.ShowSectionCommand.Execute(VaultSection.All);
var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);
row.UsedBySummary.ShouldBe("prod-api-01");
row.HasUsedBySummary.ShouldBeTrue();
vault.SelectedVaultItem = row;
vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");
// And a key nothing authenticates with says none of this — never a zero-count claim.
await AddKeyAsync(vault, "unused");
vault.SelectedVaultItem = vault.VaultItems.Single(
item => string.Equals(item.Name, "unused", StringComparison.Ordinal));
vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
}
// ---- Keeping an open editor's pickers in step with the vault ----
/// <remarks>
@@ -5639,7 +5758,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.Status.ShouldContain("password");
}
// ---- The terminal pin strip ----
// ---- The v5b session sidebar's QUICK ACCESS ----
/// <summary>
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
@@ -5666,16 +5785,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
[Fact]
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
{
var vault = await ReadyToConnectAsync();
@@ -5684,14 +5803,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
// that happens to be selected on the hosts screen.
shell.ShowsPinStrip.ShouldBeFalse();
// Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
// to a selected tab, not to the host that happens to be selected on the hosts screen.
shell.ShowsQuickAccessSidebar.ShouldBeFalse();
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
/// <remarks>
/// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
/// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
/// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
/// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
/// </remarks>
[Fact]
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
{
var vault = await ReadyToConnectAsync();
@@ -5703,28 +5828,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
shell.ShowsQuickAccessSidebar.ShouldBeFalse(
"a page is showing, not the terminal or SFTP the sidebar sits beside");
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
}
/// <remarks>
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
/// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
/// than on a selected tab, since a session on that screen is its own connection — see
/// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
/// </remarks>
[Fact]
public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.IsTransfersShowing.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
}
/// <remarks>
/// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
@@ -5746,6 +5888,129 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
}
// ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----
/// <remarks>
/// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
/// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
/// </remarks>
[Fact]
public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
{
await ConnectedHostWithAPinAsync();
var tab = shell.Tabs[0];
shell.SelectedTab = null;
await shell.SelectFilesHostCommand.ExecuteAsync(tab);
shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.Transfers.IsConnected.ShouldBeTrue();
}
/// <remarks>
/// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
/// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
/// tab that does not exist.
/// </remarks>
[Fact]
public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.Transfers.IsConnected.ShouldBeTrue();
var tabsBefore = shell.Tabs.Count;
await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
/// that same mechanism rather than reimplementing it.
/// </remarks>
[Fact]
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
{
await ConnectedHostWithAPinAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
var row = snippets.Visible.ShouldHaveSingleItem();
await shell.InsertSnippetCommand.ExecuteAsync(row);
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
}
[Fact]
public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
{
await UnlockedAsync();
shell.AddSnippetFromSidebarCommand.Execute(null);
shell.IsSnippetsShowing.ShouldBeTrue();
shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
}
/// <remarks>
/// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
/// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
/// the hosts screen's own EDIT does.
/// </remarks>
[Fact]
public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
{
await ConnectedHostWithAPinAsync();
var vault = shell.Vault!;
shell.PinFolderFromSidebarCommand.Execute(null);
shell.IsHostsShowing.ShouldBeTrue();
vault.IsEditing.ShouldBeTrue();
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <remarks>
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
/// opens, and this reads it back through the same clock.
/// </remarks>
[Fact]
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
{
await ConnectedHostWithAPinAsync();
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
}
/// <remarks>
/// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
/// a blank or a placeholder standing in for them.
/// </remarks>
[Fact]
public async Task SessionFacts_AreAbsentWithNoSessionOpen()
{
await UnlockedAsync();
shell.IsSessionConnected.ShouldBeFalse();
shell.SessionAddress.ShouldBeNull();
shell.SessionElapsedText.ShouldBeNull();
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
{
@@ -6593,6 +6858,40 @@ public sealed class ShellFlowTests : IAsyncLifetime
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
}
/// <remarks>
/// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
/// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
/// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
/// confirming actually removes it.
/// </remarks>
[Fact]
public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
{
await UnlockedAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);
snippets.Selected = snippets.Visible.Single();
snippets.RequestDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeTrue();
snippets.DeleteQuestion.ShouldContain("restart the api");
snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");
snippets.CancelDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");
snippets.RequestDeleteCommand.Execute(null);
await snippets.ConfirmDeleteCommand.ExecuteAsync(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldBeEmpty();
}
private static SnippetsViewModel SnippetsOver(
VaultViewModel vault,
InsertTarget target,
@@ -6646,6 +6945,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
// ---- Logs ----
/// <remarks>
/// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
/// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
/// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
/// segment's own active state reads are mutually exclusive.
/// </remarks>
[Fact]
public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
logs.ShowsConnections.ShouldBeTrue();
logs.ShowsActivity.ShouldBeFalse();
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.ShowsActivity.ShouldBeTrue();
logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");
logs.ShowSectionCommand.Execute(LogSection.Connections);
logs.ShowsConnections.ShouldBeTrue();
Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
}
/// <remarks>
/// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
/// section fact this type's header comment already states truthfully, and steps aside for a refresh
/// error when refreshing just produced one.
/// </remarks>
[Fact]
public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.HeaderStatusLine.ShouldBe("one row per write, per device");
logs.Status = "network unreachable";
logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
@@ -639,7 +639,13 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
vault.IsEditing.ShouldBeFalse(vault.Status);
}
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
/// <summary>
/// Switches a vault off through <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
/// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
/// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
/// is the command's own effect; that the popover's row is wired to call it is
/// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
/// </summary>
private async Task HideAsync(Guid vaultId)
{
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);