Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml
T
jaap-jan ccaf7a8e72
ci / build and test (pull_request) Failing after 2m33s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.

◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.

Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.

Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.

SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.

◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.

300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.

Written through to ClientSettings.SessionSidebarOpen rather than held for the
session. It is a decision about how much of the window a terminal gets, and one
that had to be made again on every launch would not really be on offer.

---- THE FOUR SMALLER ONES ----

A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.

A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.

THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.

THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.

---- AND THE POPOVER UNDER THE USER CHIP ----

Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.

---- WHAT PROVES IT ----

Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
2026-08-10 16:49:44 +02:00

810 lines
53 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostsScreen"
x:DataType="vm:VaultViewModel">
<!--
The monogram converters, as resources rather than through x:Static: MonogramBrushConverter's own remarks
call for "a StaticResource-shaped reference" naming which half a card wants, and a resource entry is
what makes that literal — the markup below reads {StaticResource MonogramBg} and
{StaticResource MonogramFg} rather than a converter parameter that would mean nothing without reading
that file. Constructed with x:Arguments because the type takes its "which half" as a constructor
parameter, not a settable property — there is nothing to set once the instance exists.
-->
<UserControl.Resources>
<views:MonogramBrushConverter x:Key="MonogramBg">
<x:Arguments>
<x:Boolean>True</x:Boolean>
</x:Arguments>
</views:MonogramBrushConverter>
<views:MonogramBrushConverter x:Key="MonogramFg">
<x:Arguments>
<x:Boolean>False</x:Boolean>
</x:Arguments>
</views:MonogramBrushConverter>
</UserControl.Resources>
<!--
The hosts screen: every group as a heading, every host under it as a card, and a drawer for whichever one
is selected.
── v5: THE BOARD IS FLAT SECTIONS NOW, NOT A GRID OF GROUP CARDS ONE LEVEL AT A TIME. ──────────────────
The v4 shape was a directory pane: a wrap of group cards above the hosts, opened by a double-click, with
a breadcrumb trail above it saying where you were. That model traded a real cost for it — a keychain
filed under three groups took three clicks to see all of, and "where is everything" had no answer at
all, only "where am I right now". v5 draws every group at once, in label order, "No group" first, exactly
the flattening the phone's SidebarRows has always drawn — see VaultViewModel.HostSections and
VaultViewModel.FlattenIntoSections. A host is filed under a group through its own editor, or through the
chosen-hosts menu's "Change group…"; dragging a card onto a group card is gone with the cards it used to
land on. See HostsScreen.axaml.cs for the gestures this shape now needs — a shift-click's run and a
band's rectangle both have to reach across however many sections are on screen, where a single ListBox
used to answer both on its own.
── ONE SECTION, ONE ListBox, AND "Board" IS THE ItemsControl OF SECTIONS. ────────────────────────────────
Each HostSectionViewModel draws its own heading and its own ListBox of cards (Classes="sectioncards"),
which is what lets the wrap of cards stay an ordinary WrapPanel-of-cards-only rather than a single list
fighting to make a header row pretend to be as wide as the cards it sits above. "Board" is the outer
ItemsControl holding every section — named because the code-behind needs one focusable ancestor of every
card to hang KeyDown, tunnelled PointerPressed/ContextRequested and DoubleTapped from; see the remarks on
HostsScreen for why nothing here can be a single ListBox's own SelectedItem any more.
◆ WHY EVERY COMMAND REACHED FROM INSIDE A SECTION OR A CARD GOES THROUGH "#Board" RATHER THAN A PLAIN
{Binding}. This markup nests two DataTemplates deep by the time it reaches a card — HostSections's own
item template (x:DataType HostSectionViewModel) and then, inside that, a second ListBox's item template
(x:DataType HostRowViewModel) — so the ambient DataContext by the time a pencil button or a context menu
item is declared is a section or a row, never the vault. $parent[ItemsControl] cannot walk past the
inner ListBox to reach the outer one, because a ListBox answers "is this an ItemsControl" as truly as the
real target does and $parent stops at the nearest match; and a ContextMenu or MenuFlyout's content sits
in its own popup, inheriting whatever DataContext the control that opened it already has rather than
anything further up a visual chain. "#Board" sidesteps both: Avalonia's NameScope is the whole document's,
not each template's own, so an ElementName reference resolves the same way from inside a WrapPanel's card
or from inside a MenuFlyout's popup — see #Board.((vm:VaultViewModel)DataContext).SomeCommand throughout
this file. Kept as one idiom rather than mixing it with $parent[ItemsControl] where a single level of
nesting would technically allow it (the toolbar's Group ▾ and Tag ▾ flyouts are exactly that case),
because two techniques answering the same question is one more thing to get right per binding.
Cards split the two jobs the old 268-pixel list used to do at two-thirds size. The board 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 it has been asked for. See
HostDrawer and VaultViewModel.IsDrawerOpen.
◆ ASKED FOR, AND IT USED TO ARRIVE WITH THE SELECTION. Touching any card opened the drawer, so choosing a
host cost 304 pixels of board every time — including for every card passed over on the way to the one
somebody wanted. The pencil on a card is what opens it now; selecting is free again. See the item
template below and VaultViewModel.IsHostPaneOpen.
── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────────
The design this was drawn from puts a view-mode switch, a calendar and a share control in the toolbar,
and offers Serial beside Terminal, and colours the status dot amber for "reachable but not connected".
None of the four exists: there is one view, nothing dated, no sharing outside a team vault, no serial
transport in the SSH layer, and nothing here ever pings a host — a third dot colour would be a claim this
application does not check. See docs/design-import-gaps.md and hosts-v5-design-spec.md's deviations.
── 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 (directly at the root, or through #Board once a DataTemplate has
changed what is ambient).
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="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
============ THE TOOLBAR ============
"Hosts" and the count chip on the left, the two narrowing flyouts and + New host on the right. 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 board of forty is a pair whose subject the user has to work out. The group's own
Edit/Move/Delete sit on its own heading's menu for the same reason.
-->
<Grid Grid.Row="0" Margin="16,20,16,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<!--
The count chip: every host in a shown vault, unfiltered — see HostBoardTotalCount. Deliberately not
narrowed by the find box or the two flyouts below, which already say "how many cards are on screen
right now" on each section's own heading; this says how big the keychain is.
-->
<Border Grid.Column="1" Margin="10,0,0,0" MinWidth="34" Height="30" CornerRadius="9" Padding="8,0"
Background="{StaticResource Chip}" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding HostBoardTotalCount}" FontSize="12.5"
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<!--
============ Group ▾ ============
Chips rather than checkable menu items, the same idiom the tab strip's vault menu uses and for the
same reason: nothing in this application uses a checkable MenuItem, and a chip that fills when worn
is this window's own vocabulary for on-and-off already. A host passes by being in a ticked group;
empty means every group. "New group…" lives at the foot rather than being a control of its own,
because the mock has no group-creation control and this is the nearest thing to where the design
put it — see hosts-v5-design-spec.md's deviations.
-->
<Button Grid.Column="3" Classes="ghost" Height="40" Margin="0,0,8,0" Content="Group ▾"
ToolTip.Tip="Narrow the board to one or more groups">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="240" Spacing="8">
<TextBlock Classes="label" Text="FILTER BY GROUP" />
<ItemsControl ItemsSource="{Binding GroupFilterChoices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupFilterChoice">
<Button Classes="chiptoggle" Classes.worn="{Binding IsChecked}" Margin="0,0,0,4"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ToggleGroupFilterCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="11" TextTrimming="CharacterEllipsis" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Content="All groups" IsVisible="{Binding HasActiveGroupFilter}"
Command="{Binding ResetGroupFilterCommand}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<Button Classes="ghost" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Content="New group…" Command="{Binding NewGroupCommand}"
ToolTip.Tip="A heading for the board, and the port, username and key the hosts under it inherit." />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<!--
============ Tag ▾ ============
A host passes by wearing ANY ticked tag, not all of them — the same reading MatchesHostBoardFilters
gives it. No footer control: a tag is made from a host's own editor, not from this flyout.
-->
<Button Grid.Column="4" Classes="ghost" Height="40" Margin="0,0,8,0" Content="Tag ▾"
ToolTip.Tip="Narrow the board to one or more tags">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="240" Spacing="8">
<TextBlock Classes="label" Text="FILTER BY TAG" />
<ItemsControl ItemsSource="{Binding TagFilterChoices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagFilterChoice">
<Button Classes="chiptoggle" Classes.worn="{Binding IsChecked}" Margin="0,0,0,4"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ToggleTagFilterCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="11" TextTrimming="CharacterEllipsis" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Content="All tags" IsVisible="{Binding HasActiveTagFilter}"
Command="{Binding ResetTagFilterCommand}" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button Grid.Column="5" Classes="accent" Height="40" FontSize="13.5" Content="+ New host"
Command="{Binding NewHostCommand}" />
</Grid>
<!--
============ FIND ============
One wide box, restyled but doing exactly what it always has: it narrows the board 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.
◆ IT IS ALSO THE WAY OUT OF THE SECTIONS. Typing here searches every section at once, whatever a
collapse fold or the two flyouts above have done to the board — the one thing on this screen that
crosses a group boundary, and it has to be: a search that looked only at what happens to be expanded
would say "no host matches that" about a host this keychain has got. See VaultViewModel.Matches.
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="1" Margin="16,0,16,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
<!--
============ THE PANELS ============
At most one of these is up at any moment — the view model disarms the others on the way up, see
MoveChosenHostsToVault, RegroupChosenHosts, DeleteChosenHosts, MoveGroup and DeleteGroup — and every
one of them sits here, above the board, rather than laid over it: a card over the cards would hide
the very ticks or the very group it is asking about.
-->
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
<!--
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, on an Auto row rather than growing without limit — a pass that merged
twenty items must not push the board off the bottom of a screen nobody can scroll to.
-->
<Border Padding="14,10" Background="{StaticResource Panel}" CornerRadius="10"
BorderBrush="{StaticResource Border}" BorderThickness="1" 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 Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}" CornerRadius="6">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="12"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
<!--
◆ ONE GROUP'S DELETION QUESTION, arrived at from a heading's own menu rather than from a card — the
group cards this used to sit under are gone, and Edit/Move/Delete moved to the heading. See
EditGroupFromHeading, MoveGroupFromHeading and DeleteGroupFromHeading.
-->
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="10"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
◆ MOVING THE GROUP TO ANOTHER VAULT. A picker and two buttons rather than a question with a yes:
what is being asked is which vault, and a move is undone by moving it back — so it is not drawn in
the danger colours the deletion question above uses. It sits beside that question and never with
it: MoveGroup disarms a pending deletion and DeleteGroup folds this away, so at most one is drawn.
The sentence is what the panel is for. A group cannot go anywhere alone: the machines under it are
items of the vault it is leaving, and so are the groups nested inside it, so all of them are
re-sealed under the destination's key and all of them take new ids. What cannot come is the group
it is nested under, which is why it arrives at the top level.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="10"
BorderBrush="{StaticResource Border}" BorderThickness="1" IsVisible="{Binding IsMovingGroup}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE GROUP TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveGroupVaultChoices}"
SelectedItem="{Binding SelectedMoveGroupVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveGroupCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveGroupCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
============ ◆ WHAT SAYS A SET IS UP ============
Ctrl, Shift and a band dragged across the board tick cards — see HostsScreen.axaml.cs — and this is
the whole of the chrome that arrives with them: how many, the way out, and where the actions are.
The phone raises a contextual action bar in the vault header's place for the same state; a window
with room for a context menu does not need one, and a strip of eight buttons over the board would
be a second home for entries the menu already has.
-->
<Grid ColumnDefinitions="Auto,Auto,*" IsVisible="{Binding IsChoosingHosts}">
<Border Grid.Column="0" Classes="chip accent" Padding="6,1" VerticalAlignment="Center">
<TextBlock Text="{Binding ChosenHostsSummary}" FontSize="9.5" />
</Border>
<Button Grid.Column="1" Classes="flat" Margin="6,0,0,0" Padding="5,1" FontSize="9.5"
Content="CLEAR" Command="{Binding ClearHostChoiceCommand}"
ToolTip.Tip="Takes the ticks off every card · Esc, or a click on the space around them" />
<TextBlock Grid.Column="2" Classes="hint" FontSize="10" Margin="8,0,0,0"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="right-click one of them for what can be done to all of them" />
</Grid>
<!--
◆ MOVING OR COPYING THEM TO ANOTHER VAULT. One panel for both verbs, because they differ in one
word and in the sentence under the picker; what varies is bound rather than duplicated. See
VaultViewModel.ChosenHostVaultPanelTitle and its two siblings.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="10"
BorderBrush="{StaticResource Border}" BorderThickness="1"
IsVisible="{Binding IsSendingChosenHostsToAVault}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="label" Text="{Binding ChosenHostVaultPanelTitle}" />
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding ChosenHostsSummary}" />
</Grid>
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostVaultChoices}"
SelectedItem="{Binding SelectedChosenHostVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding ChosenHostVaultPanelNote}" />
<!--
◆ THE KEY, and only for a move of exactly one host — which key to carry is a fact about one
machine, and a copy that took it away would leave the original unable to connect. Unticked,
because moving a key into a team's vault hands it to everybody holding that vault's key.
-->
<CheckBox IsVisible="{Binding HasAChosenBindingToBring}"
IsChecked="{Binding BringsTheChosenBindingAlong}">
<TextBlock FontSize="11.5" TextWrapping="Wrap" Text="{Binding ChosenBindingToBringQuestion}" />
</CheckBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasAChosenBindingToBring}"
Text="{Binding ChosenBindingToBringNote}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="{Binding ChosenHostVaultPanelVerb}"
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
◆ FILING THEM UNDER A GROUP, which is what "Change group…" on the chosen-hosts menu offers instead
of a drag now that there is no group card to drop one on.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="10"
BorderBrush="{StaticResource Border}" BorderThickness="1"
IsVisible="{Binding IsRegroupingChosenHosts}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="label" Text="CHANGE GROUP" />
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding ChosenHostsSummary}" />
</Grid>
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostGroupChoices}"
SelectedItem="{Binding SelectedChosenHostGroup}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="FILE" Command="{Binding ConfirmRegroupChosenHostsCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRegroupChosenHostsCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
◆ THE DELETION QUESTION, naming a count rather than a machine — which is the reason
DeletionTarget.ChosenHosts exists: six copies of "delete prod-db?" is not a confirmation anybody
reads. The shared card draws it, as it does for a group above and for one host in the drawer.
-->
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="10"
IsVisible="{Binding IsConfirmingChosenHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
</StackPanel>
<!--
============ THE BOARD ============
A drop target that takes nothing itself — no group cards left to receive a drag. See
HostsScreen.axaml.cs.
-->
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,0,16,20" Spacing="16">
<!--
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 an ItemsControl is not by default. 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.
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
feature invisible until it is used.
-->
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="22" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostSectionViewModel">
<StackPanel Spacing="10">
<!--
============ THE SECTION HEADING ============
"{label} · {count}", the vault name beside it when more than one vault is in play, a
per-section collapse toggle, Collapse All / Expand All on the FIRST heading only — see
SidebarGroupHeader.IsFirstBoardSection — and a menu for Edit…/Move to another vault…/
Delete…, in that order, with no Open: the board draws every group at once now, so there
is nothing left to open one INTO. Absent entirely for the headerless section a groupless
keychain draws — see HasHeader on the type, and the remark on the item template above.
-->
<Grid IsVisible="{Binding HasHeader}" ColumnDefinitions="Auto,*,Auto,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Button Classes="flat" Padding="2"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding Header}"
ToolTip.Tip="{Binding Header.CollapseLabel}">
<TextBlock Text="{Binding Header.Chevron}" FontSize="11"
Foreground="{StaticResource TextGhost}" />
</Button>
<TextBlock FontSize="12.5" FontWeight="Medium" Foreground="{StaticResource TextGhost}">
<Run Text="{Binding Header.Label}" /><Run Text=" · " /><Run Text="{Binding Header.Count}" />
</TextBlock>
<TextBlock Classes="mono" Text="{Binding Header.VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding Header.HasVaultBadge}" />
</StackPanel>
<!--
Bound on every heading and shown on the first, rather than a control of the board's
own above the sections — a virtualised ItemsControl has no clean way for one realized
row to ask "am I first", so HostSectionViewModel's own header carries the answer. See
VaultViewModel.RebuildHostSections and CollapseAllLabel.
-->
<Button Grid.Column="2" Classes="flat" Padding="6,2" Margin="0,0,10,0"
FontSize="12" Foreground="{StaticResource TextGhost}"
IsVisible="{Binding Header.IsFirstBoardSection}"
Content="{Binding #Board.((vm:VaultViewModel)DataContext).CollapseAllLabel}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ToggleAllGroupsCommand}" />
<!--
Nothing to edit, move or delete about "No group" — it names no item, so the menu is
left off that heading altogether rather than opened onto three commands that would
each resolve to nothing. See VaultViewModel.GroupOf.
-->
<Button Grid.Column="3" Classes="flat paneicon" Content="⋯" Width="24" Height="24"
IsVisible="{Binding Header.GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
ToolTip.Tip="Edit, move or delete this group">
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Edit…"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditGroupFromHeadingCommand}"
CommandParameter="{Binding Header}" />
<MenuItem Header="Move to another vault…"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveGroupFromHeadingCommand}"
CommandParameter="{Binding Header}" />
<Separator />
<MenuItem Header="Delete…"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteGroupFromHeadingCommand}"
CommandParameter="{Binding Header}" />
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<!--
============ THE CARDS ============
A ListBox of its own per section — Classes="sectioncards" is what the code-behind finds
every card container through, across however many of these are realized. See the type
remarks on HostSectionViewModel for why this is a second list per group rather than one
list for the whole board.
◆ SELECTED IS ONE-WAY, WRITTEN BY HAND ON THE WAY OUT. See HostsScreen's own remarks:
a two-way binding shared by several independently-rebuilt lists would have each list's
Reset racing to null the one property every filter keystroke and every sync pass.
-->
<ListBox Classes="sectioncards" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
◆ TWO MENUS IN ONE, AND WHICH OF THEM IS DRAWN IS WHETHER ANYTHING IS TICKED. See
HostsScreen.axaml.cs's OnContextRequested for the half of this rule that lives in code:
a right click outside the ticked set drops the set first, so these entries and the
ones above are never both meaningful at once.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ConnectCommand}" />
<MenuItem Header="Details…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
<MenuItem Header="Connect" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).HasOneChosenHost}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).ConnectToChosenHostCommand}" />
<MenuItem Header="Browse files" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).HasOneChosenHost}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).BrowseChosenHostCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).HasOneChosenHost}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditChosenHostCommand}" />
<MenuItem Header="Change group…" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).RegroupChosenHostsCommand}" />
<MenuItem Header="Move to another vault…" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveChosenHostsToVaultCommand}" />
<MenuItem Header="Copy to another vault…" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).CopyChosenHostsToVaultCommand}" />
<MenuItem Header="Duplicate" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DuplicateChosenHostsCommand}" />
<Separator IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding #Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteChosenHostsCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<!--
The full address is on the tooltip and nowhere else on the card — the design's own
rule, kept: a card read while scanning a section is read for its name and whether
it is live, and an address is what somebody reads once they have found the machine,
which the tooltip and the drawer both still say.
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
card wearing four tags grows a second line rather than clipping the fourth one — a
silent truncation is exactly the kind of thing the honesty rule in
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
-->
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
ToolTip.Tip="{Binding Address}">
<Panel>
<Grid RowDefinitions="Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*">
<!--
The monogram avatar: two letters, stable-hashed into one of the mock's four
hue pairs — see HostRowViewModel.Monogram/MonogramHue and
MonogramBrushConverter, which holds the four pairs themselves. Kept beside
this one screen rather than in the shared palette, because a monogram's
colour is a fact about this card and nothing else in the theme reads it.
-->
<Border Grid.Column="0" Width="32" Height="32" CornerRadius="9"
Background="{Binding MonogramHue, Converter={StaticResource MonogramBg}}">
<TextBlock Classes="mono" Text="{Binding Monogram}" FontWeight="Bold"
FontSize="12.5" HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{Binding MonogramHue, Converter={StaticResource MonogramFg}}" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="3">
<!--
A Grid rather than a horizontal StackPanel, on both of these rows — a
horizontal StackPanel measures its children with infinite width, so
TextTrimming never fires and the name would run on past the card's own
border instead of trimming. See the longer note this carried in v4.
-->
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<!--
The tick sits beside the dot rather than in place of it, the same
arrangement the phone's row uses: the dot says whether a shell is open
on this machine, and swapping it for the tick would hide that fact the
moment a card is chosen.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
Margin="6,0,0,0" VerticalAlignment="Center">
<TextBlock Text="✓" FontSize="11" IsVisible="{Binding IsChosen}"
Foreground="{StaticResource AccentText}" />
<!--
Two states, deliberately: green means a terminal is open on this host
right now, grey means there is not one. Never amber — nothing here
pings anything, and a third colour would be a claim this application
does not check. See Ellipse.dot in App.axaml and
hosts-v5-design-spec.md's deviations.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
Width="9" Height="9" />
</StackPanel>
<Border Grid.Column="2" 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 Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<!--
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 board spanning several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
Margin="0,8,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Bottom">
<!--
The pin badge: how many folders are pinned on this host, reading
HostRowViewModel.PinCount — which is Host.PinnedPaths.Count, see that
property's own remarks. Only when there is at least one; a badge reading
zero would be a fact nobody asked for.
-->
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</Border>
<!--
Tag chips, bottom-left. Templated over plain strings rather than a typed
row, so no x:DataType is declared on this inner template — TagLabels is
already resolved names, not ids, see HostRowViewModel.TagLabels.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!--
HostRowViewModel.LastConnectedText is filled from the connection log on
the hosts screen's activation and on SessionEnded, and restrung on a
one-minute tick while this screen is visible — see that property's own
remarks and VaultViewModel.RefreshLastConnectedAsync. This reads it as-is
rather than formatting anything here, and draws nothing while it is empty
rather than a dash that would claim "never connected" — a host never seen
in the log and a host whose vault this has not read yet look the same from
here, and neither is a claim this card can make. A connected host prints
nothing too, per decision 4: the dot above already says "right now".
-->
<TextBlock Grid.Column="1" Text="{Binding LastConnectedText}" FontSize="12"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Bottom"
IsVisible="{Binding LastConnectedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</Grid>
</Grid>
<!--
◆ THE PENCIL, AND THE ONLY WAY THE DRAWER OPENS FROM THIS BOARD. See the note
on this in v4's own markup, kept: selecting a card does not open it any more,
and this is where the asking is. Bound through #Board rather than the old
$parent[ListBox] — see the remark at the top of this file for why a ListBox one
level further in than the old grid changes what that path would resolve to.
-->
<Button Classes="flat rowedit" Content="✎"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Opens this host's pane beside the board" />
</Panel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Nothing to show, and the reasons it can happen are different questions — see
VaultViewModel.NoHostBoardMessage for the one answered here per reason.
-->
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap" MaxWidth="480"
HorizontalAlignment="Left" Margin="0,4,0,0"
IsVisible="{Binding !HasHostBoardEntries}"
Text="{Binding NoHostBoardMessage}" />
</StackPanel>
</ScrollViewer>
<!--
◆ THE BAND, drawn over the scroller rather than inside it.
It is a rectangle the pointer is dragging out right now, so it belongs in the viewport's coordinates
and not in the scrolling content's: a band drawn inside the stack would slide away from the pointer
the moment the board scrolled under it. Same row as the ScrollViewer and after it, which is what puts
it on top — a Grid cell stacks its children in declaration order.
IsHitTestVisible="False" is the whole of why the gesture works while it is up. The band is under the
pointer by definition, and a rectangle that took the pointer would end the drag it is drawing.
Positioned by Margin from the top left rather than in a Canvas, because the two alignments below make
the margin mean exactly "where the corner is"; see HostsScreen.axaml.cs, which is the only thing that
writes to it.
-->
<Border Grid.Row="3" x:Name="Band" IsVisible="False" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Top"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="1" CornerRadius="2" />
</Grid>
<!--
The drawer, and it takes its 320 pixels only while it has been opened — which is what gives the board
the full width for most of the time anybody is looking at it, now including all the time somebody is
choosing between forty machines.
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>