Files
DodoSSH/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
T
jaap-jan 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00

315 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.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 and makes a team to own it, so you can invite 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>