Files
DodoSSH/src/DodoSSH.Client.App/Views/NavRail.axaml
T
jaap-jan cec73010d3
ci / android head (pull_request) Failing after 12s
ci / build and test (pull_request) Failing after 12s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
Colour the window's frame, inset Hosts like its neighbours, drop Pins
Three things one pass over the shell's chrome turned up, none of them related
to the others beyond having been looked at together.

◆ A PALE STRIP ACROSS THE TOP OF THE WINDOW ON WINDOWS, and it is not this
application's titlebar. Avalonia's Win32 backend gives a BorderOnly window
WS_BORDER | WS_THICKFRAME and then calls DwmExtendFrameIntoClientArea with
one-pixel margins on all four sides — read out of WindowImpl.UpdateWindowProperties
in 12.1.1 rather than guessed at. So DWM owns a hairline of every edge and fills
it with the system's caption and border colours, which follow the user's
personalisation settings: with "show accent colour on title bars and window
borders" on, that is blue against a near-black shell. Nothing in the visual tree
painted those pixels, which is why nothing in the visual tree could cover them.

NativeWindowFrame sets DWMWA_BORDER_COLOR and DWMWA_CAPTION_COLOR to the
window's own Background, so the hairline still exists — the resize grip is on
it, the drop shadow hangs off it — and cannot be seen. Deliberately not
DWMWA_COLOR_NONE, which removes the border outright and leaves a near-black
window with no edge at all on a dark desktop. Windows 10 gets the dark-mode
attribute and nothing else, because the two colour attributes are Windows 11
and DwmSetWindowAttribute simply answers E_INVALIDARG there.

Called from OnOpened, not the constructor: there is no platform handle until
the window is shown, and calling early is a silent no-op — which looks exactly
like a fix that does not work.

Verified on screen on Windows 11.

◆ THE HOSTS HEADER SAT A STEP LEFT OF AND ABOVE EVERY OTHER SCREEN'S. Keychain,
Snips, Logs and Pins all frame their content with Margin="26"; Hosts was on 16
a side and 20 on top. It is 26 all round now, stated per row rather than once on
the root, because the board's ScrollViewer is deliberately full-bleed so that
its scrollbar rides the pane's edge, and because a root margin would also inset
the drawer, which draws its own.

That cost the cards ten pixels, and the layout suite is what said so:
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen failed, because
Border.tile's 224 was derived from the board's old 16-pixel margins and the grid
quietly collapses to one column at exactly the size this application guarantees.
224 becomes 214, with the arithmetic in App.axaml rewritten — it had also gone
stale in a way that hid itself, still citing the 1016 minimum and 190 rail from
before v5b, whose two changes happened to cancel.

◆ PINS LEAVES THE RAIL, and only the rail. KnownHostsScreen is still built and
still one click away, from "Host keys" on the Keys screen's own header, which
was always the second way in. The row was kept through v5b on the grounds that
the mock has no screen for approved host keys — a reason for the screen to
exist, and never a reason for a rail entry once the keychain had a door to the
same place. Two rows landing on one screen is a rail that has to be read twice.
MainWindowViewModel.IsKnownHostsShowing stays: it names a real shell state and
ShellFlowTests still asserts on it.

design-import-gaps.md recorded that row as a deliberate deviation and
manual-checks.md Phase 1.1 walked the rail entry by entry; both are corrected,
and the manual check now reaches the screen the way a user would.

The layout suite's rail row count moves from six to five with it.

153 layout tests and 446 shell tests pass. The frame is confirmed by eye; the
Hosts inset and the rail are covered by the layout suite but were not seen
running, because the instance launched to check them came up locked.
2026-08-12 10:43:37 +02:00

318 lines
20 KiB
XML

<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.NavRail"
x:DataType="vm:MainWindowViewModel">
<!--
The window's destinations, down the left edge.
── 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:
── 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>.
── 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.
── 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.
── 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 one deviation this rail still
keeps on purpose: the S3 segment above. Pins was the other, and it is gone — see the remark where that
row used to sit, between Keys and Snips.
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="255" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<!--
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" Spacing="18">
<!--
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.
-->
<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>
</Border>
<StackPanel Spacing="8">
<!--
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 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>
<!--
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>
<!--
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
keychain material, and that button was already the second way to reach them. Two rail rows away
from each other, both landing on the same screen, is a rail that has to be read twice.
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
the grounds that the design has no screen for approved host keys at all — see the file-level
remark — which is true of the design and was never a reason for a rail entry once the keychain
had a door to the same place.
-->
<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>
<!--
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>
<!--
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
the shared popup rule was not simply widened to cover it.
-->
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
<StackPanel Width="227" Spacing="4">
<!--
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" Margin="11,4,11,6"
Foreground="{StaticResource TextGhost}"
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
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" Margin="11,4" Background="{StaticResource BorderMid}" />
<!--
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
mode's own default landing page; Vaults and Preferences open directly on the page they name,
which is also what anything that used to navigate to ShellScreen.Vaults or
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
sharing one before this wave: the mock's Settings area was a family of screens that did not
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
-->
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<!--
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
label was TextGhost where the other two rows' was Text, so a menu of five equally live
destinations drew two of them in the colour this window uses for something switched off. The
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
— but "quieter than the word beside it" and "dimmed" are not the same statement.
-->
<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 Text}" />
</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 Text}" />
</StackPanel>
</Button>
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!--
The existing sign-out flow, with its own confirm card — see
MainWindowViewModel.SignOutFromPopover for why this goes through the Account settings page
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 TextGhost}" />
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</Button>
</DockPanel>
</Border>
</UserControl>