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
+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>