Move SFTP and S3 into the tab strip, and the host list into a card grid

Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
This commit is contained in:
2026-08-03 15:08:48 +02:00
parent 416f233657
commit 208443b932
38 changed files with 2252 additions and 1432 deletions
+345 -197
View File
@@ -3,86 +3,106 @@
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostsScreen"
x:DataType="vm:MainWindowViewModel">
x:DataType="vm:VaultViewModel">
<!--
The hosts screen: the list of machines, and what this application has to say about the one that is
selected.
The hosts screen: every group, then every host, as cards — and a drawer for whichever one is selected.
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
promised: an overview.
── IT WAS A 268-PIXEL LIST BESIDE A MOSTLY EMPTY COLUMN. ────────────────────────────────────────────
That list was doing two jobs at two-thirds size. It had to be narrow so the editor beneath it could be
a column, and being narrow is what made forty machines a scroll rather than a glance: a host row was a
name, an address and an auth word stacked in 268 pixels, and the column beside it repeated all three
for the one that happened to be selected.
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
has to reproduce by hand.
Cards split the two jobs. The grid is about all of them — how many groups there are, which machines are
in one, which have a shell open — and it gets the full width to say so. The drawer is about one, and it
is only there when there is one. See HostDrawer and VaultViewModel.IsDrawerOpen.
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
two cannot be put on one element.
── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────────
The design this was drawn from puts a view-mode switch, a tag filter, a calendar and a share control in
the toolbar, and offers Serial beside Terminal. None of the five exists: there is one view, no saved
filters, nothing dated, no sharing outside a team vault, and no serial transport in the SSH layer. Five
disabled controls would teach nobody anything. See docs/design-import-gaps.md.
── ITS DATA CONTEXT IS THE VAULT, and it used to be the shell. ──────────────────────────────────────
The shell was needed only so that the old sidebar could be handed the vault on its own element while
everything around it bound Vault.*. The drawer needs no such thing — it is a child of this control and
inherits what this control has — so the indirection went with the sidebar, and every binding in this
file is now a property of the vault.
That is worth more than the tidiness: a control whose data context is the shell cannot be laid out by
the layout harness without building an entire MainWindowViewModel, and this is the screen with the most
worth measuring. The window wraps it in a Panel that carries IsHostsScreen, which is the same pattern
every other screen there already uses — see MainWindow.axaml's note on why the two cannot go on one
element.
-->
<Grid ColumnDefinitions="268,*">
<Grid ColumnDefinitions="*,Auto">
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
one of them is fine.
============ FIND ============
One wide box, which is what the width bought. It narrows the grid below and nothing else — the
connect path, the selection and the pinned host key list all read the unfiltered collection — so a
filter left in the box can hide a host but can never break one.
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter.
-->
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
<Border Grid.Row="0" Padding="16,12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
<!--
============ THE TOOLBAR ============
Only what makes something new. EDIT and DELETE are not here: they act on one host, and one host is
what the drawer is about — a pair of buttons over a grid of forty is a pair whose subject the user
has to work out. The group's own EDIT and DELETE sit beside the group cards for the same reason.
-->
<Border Grid.Row="1" Padding="16,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="+ NEW GROUP" Command="{Binding NewGroupCommand}"
ToolTip.Tip="A heading for this grid, and the port, username and key the hosts under it inherit." />
</StackPanel>
</Border>
<StackPanel Grid.Row="1">
<StackPanel Grid.Row="2">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
<Border Padding="16,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
IsVisible="{Binding HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
<SelectableTextBlock Classes="mono" Text="{Binding PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
<Border Padding="16,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
IsVisible="{Binding HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
@@ -93,20 +113,18 @@
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
and shown; without this panel it would be last-writer-wins with a longer explanation.
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
for as long as it did because no test could lay this markup out; that is the other half of why this
file exists.
Bounded and scrollable. It sits on an Auto row above a star row, and an ItemsControl with no
ceiling grows without limit — so a pass that merged twenty items would push the grid off the
bottom of a screen nobody could scroll.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
<Border Padding="16,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
IsVisible="{Binding HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
@@ -114,7 +132,7 @@
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="12"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
@@ -124,174 +142,304 @@
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
Command="{Binding AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
The overview proper: what is known about the host the list has selected.
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
terminal until this screen stopped hosting one, and filling it with something that needed new state
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
list of forty.
-->
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<Panel Margin="24">
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
VerticalAlignment="Center" />
<Border Classes="chip" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
<TextBlock Text="CONNECTED" />
</Border>
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
</StackPanel>
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
MaxWidth="440" TextWrapping="Wrap"
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</ScrollViewer>
<!--
Groups: making them, renaming them, and taking them away.
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
in the host's own editor, on the left, for the same reason its key and its password are.
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
happen.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
</StackPanel>
<!-- ============ THE GRID ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,14" Spacing="16">
<!-- ============ GROUPS ============ -->
<!--
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
third of this column to say what a row of chips says in one line.
Absent entirely from a vault nobody has filed anything in, which is what makes groups cost
nothing to ignore — the same rule the host list has always followed for its headings.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding Vault.HasGroups}">
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
Background="Transparent" MaxHeight="72">
<StackPanel Spacing="8" IsVisible="{Binding HasGroups}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="GROUPS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The group's own two actions, beside the group cards rather than in the toolbar, because they
act on the selected card and this is where the selection is made. Hidden rather than
disabled while DELETE's question is up, as every other pair in this application is, so it
cannot be pressed twice — see VaultViewModel.ShowsGroupActions.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SHOW ALL"
Command="{Binding ClearGroupFilterCommand}"
IsVisible="{Binding IsFilteredByGroup}"
ToolTip.Tip="Stops narrowing the grid to one group." />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
</StackPanel>
</Grid>
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
A ListBox rather than an ItemsControl of buttons, and that is what marks the chosen group for
free: selection is a state the control already has, and the tile style draws it the same way
every other list in this application draws a selected row. Buttons would have needed a
Classes.active binding per card and a second copy of "which one".
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
for why the phone's own use of SelectedGroup must not.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<StackPanel Margin="2,4" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
<Border Classes="tile">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Accent}">
<TextBlock Text="▤" FontSize="14" Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!-- A Grid rather than a horizontal StackPanel; see the note on the host card. -->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<!--
A group is no longer only a name, so this is no longer only a box. The four fields under the
name are what the hosts inside inherit when they say nothing themselves; every one of them may
be left empty, and empty means "lend nothing" rather than "unset". The parent picker leaves out
this group and everything beneath it, so a cycle cannot be made here — which is a courtesy
rather than the guarantee, because one assembled offline on two machines was never offered this
list. See HostInheritance.
-->
<StackPanel Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorParentChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedParent}"
MinWidth="150" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<NumericUpDown Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" PlaceholderText="default port"
Width="120" FontSize="11" MinHeight="26" />
<TextBox Text="{Binding Vault.GroupEditorDefaultUsername}"
PlaceholderText="default username" Width="150"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}"
MinWidth="180" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="EDIT SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
two panels share one pending deletion, and the sidebar checks the same way.
-->
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<!-- ============ HOSTS ============ -->
<StackPanel Spacing="8">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!-- The collection's own count, so it follows both filters with no second number to keep in step. -->
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding VisibleHosts.Count}"
FontSize="11" Foreground="{StaticResource TextFaint}"
VerticalAlignment="Center" />
</Grid>
<!--
Named because it is where keyboard focus lands when the terminal gives it back, and because
every gesture on it is wired in the code-behind.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty
list has none. Without it the release-the-keyboard path is a measured no-op: it takes Win32
focus off the terminal's child window and then calls Focus() on something that refuses it,
leaving the window with nothing focused and the keystrokes going nowhere.
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
reasons, and neither is decoration: a grid showing every group at once has to say which cards
belong to which, and a heading is the drop target that takes a host out of one group and into
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
container, and the code-behind for what a drag does with one.
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
DragDrop.AllowDrop="True"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
and the commands here are the vault's. The code-behind selects whatever was right-clicked
before the menu opens, so the selection-based commands act on the card under the pointer,
and cancels the menu outright over a group heading, which has no host to act on.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.DataTemplates>
<!--
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
card: it names the run of cards under it, and a heading the width of one card would read
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
width it asks for and starts a new line when the line cannot hold it, so a child asking
for more than one line holds is alone on its own.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" MinWidth="640"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
Foreground="{StaticResource TextFaint}" FontSize="9"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" HorizontalAlignment="Left" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Border Classes="tile">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!--
◆ A GRID AND NOT A HORIZONTAL StackPanel, on both of these rows, and the two
look interchangeable until the text is too long for the card.
A horizontal StackPanel measures every child with infinite width, so a
TextBlock inside one never learns it is short of room and TextTrimming never
fires — the text simply runs on past the card's border and over the card beside
it. The card is a fixed 248 and a host name is not, so that is the ordinary
case rather than the edge one.
The star column is the part that gives way and the Auto column is the part that
must not: a badge saying "not synced" and the word that says how a host
authenticates are both short and both meaningless trimmed, where a hostname
with its tail cut is still the machine you were looking for.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<!--
The address and how it authenticates, on one line. Which of the three ways is
here because only one of them wants a password typed, and an empty password box
on a key-authenticated host is otherwise indistinguishable from one somebody
forgot to fill in.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}"
FontSize="10.5" Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Authentication}"
FontSize="10.5" Margin="6,0,0,0"
Foreground="{StaticResource TextFaint}" />
</Grid>
<!--
Which vault this host is in, and only when there is more than one to be in. It
decides who else can see the host and where an edit goes back to, so on a grid
spanning several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!--
Connected, and nothing more. Green means a terminal is open on this host right
now; grey means there is not one. It is deliberately not reachability — nothing
here pings anything, and a dot that meant "up" would be a claim this application
never checks.
-->
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Top" Margin="6,3,0,0" />
</Grid>
<!--
The tags this host wears. Under the summary rather than beside it, because a host
can wear several and the line above is three fixed-width facts where these are not.
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See
HostRowViewModel.TagLabels.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="5,0" Margin="0,0,4,2">
<TextBlock Text="{Binding}" FontSize="9.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<!--
Nothing to show, and the two reasons it can happen are different questions. An empty keychain
is an invitation; a filter that matches nothing is a filter that has gone too far, and saying
"add a host" to somebody who has thirty would be answering the wrong one.
-->
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap" MaxWidth="480"
HorizontalAlignment="Left" Margin="0,4,0,0"
IsVisible="{Binding !HasVisibleHosts}"
Text="{Binding NoVisibleHostsMessage}" />
</StackPanel>
</StackPanel>
</Border>
</ScrollViewer>
</Grid>
<!--
The drawer, and it takes its 304 pixels only while there is something in it — which is what gives the
grid the full width for most of the time anybody is looking at it.
Wrapped so that its own IsVisible is the shell's binding and its data context is the vault; see
MainWindow.axaml's note on why the two cannot be put on one element.
-->
<Panel Grid.Column="1" IsVisible="{Binding IsDrawerOpen}">
<views:HostDrawer x:Name="Drawer" />
</Panel>
</Grid>
</UserControl>