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

This commit is contained in:
jaap-jan committed 2026-08-08 00:49:59 +02:00
1 parent 1b76c51fbb
commit 43c939b697
30 files changed
+4433 -1772

No files matched your search

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