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