Public Access
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.
Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.
The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.
Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.
What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.
The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.
That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.
Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.
1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
315 lines
20 KiB
XML
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 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>
|