Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml
T
jaap-jan cec73010d3
ci / android head (pull_request) Failing after 12s
ci / build and test (pull_request) Failing after 12s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
Colour the window's frame, inset Hosts like its neighbours, drop Pins
Three things one pass over the shell's chrome turned up, none of them related
to the others beyond having been looked at together.

◆ A PALE STRIP ACROSS THE TOP OF THE WINDOW ON WINDOWS, and it is not this
application's titlebar. Avalonia's Win32 backend gives a BorderOnly window
WS_BORDER | WS_THICKFRAME and then calls DwmExtendFrameIntoClientArea with
one-pixel margins on all four sides — read out of WindowImpl.UpdateWindowProperties
in 12.1.1 rather than guessed at. So DWM owns a hairline of every edge and fills
it with the system's caption and border colours, which follow the user's
personalisation settings: with "show accent colour on title bars and window
borders" on, that is blue against a near-black shell. Nothing in the visual tree
painted those pixels, which is why nothing in the visual tree could cover them.

NativeWindowFrame sets DWMWA_BORDER_COLOR and DWMWA_CAPTION_COLOR to the
window's own Background, so the hairline still exists — the resize grip is on
it, the drop shadow hangs off it — and cannot be seen. Deliberately not
DWMWA_COLOR_NONE, which removes the border outright and leaves a near-black
window with no edge at all on a dark desktop. Windows 10 gets the dark-mode
attribute and nothing else, because the two colour attributes are Windows 11
and DwmSetWindowAttribute simply answers E_INVALIDARG there.

Called from OnOpened, not the constructor: there is no platform handle until
the window is shown, and calling early is a silent no-op — which looks exactly
like a fix that does not work.

Verified on screen on Windows 11.

◆ THE HOSTS HEADER SAT A STEP LEFT OF AND ABOVE EVERY OTHER SCREEN'S. Keychain,
Snips, Logs and Pins all frame their content with Margin="26"; Hosts was on 16
a side and 20 on top. It is 26 all round now, stated per row rather than once on
the root, because the board's ScrollViewer is deliberately full-bleed so that
its scrollbar rides the pane's edge, and because a root margin would also inset
the drawer, which draws its own.

That cost the cards ten pixels, and the layout suite is what said so:
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen failed, because
Border.tile's 224 was derived from the board's old 16-pixel margins and the grid
quietly collapses to one column at exactly the size this application guarantees.
224 becomes 214, with the arithmetic in App.axaml rewritten — it had also gone
stale in a way that hid itself, still citing the 1016 minimum and 190 rail from
before v5b, whose two changes happened to cancel.

◆ PINS LEAVES THE RAIL, and only the rail. KnownHostsScreen is still built and
still one click away, from "Host keys" on the Keys screen's own header, which
was always the second way in. The row was kept through v5b on the grounds that
the mock has no screen for approved host keys — a reason for the screen to
exist, and never a reason for a rail entry once the keychain had a door to the
same place. Two rows landing on one screen is a rail that has to be read twice.
MainWindowViewModel.IsKnownHostsShowing stays: it names a real shell state and
ShellFlowTests still asserts on it.

design-import-gaps.md recorded that row as a deliberate deviation and
manual-checks.md Phase 1.1 walked the rail entry by entry; both are corrected,
and the manual check now reaches the screen the way a user would.

The layout suite's rail row count moves from six to five with it.

153 layout tests and 446 shell tests pass. The frame is confirmed by eye; the
Hosts inset and the rail are covered by the layout suite but were not seen
running, because the instance launched to check them came up locked.
2026-08-12 10:43:37 +02:00

822 lines
54 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">
<!--
── 26 DOWN EACH SIDE, the same inset Keychain, Snips, Logs and Pins all take. ──────────────────────
Those four say it once, as Margin="26" on their own root; this screen repeats it on each of the four
rows below, and it has to. The board's ScrollViewer is the last row and is deliberately full-bleed, so
that its scrollbar rides the pane's own edge rather than floating 26 pixels inside it — a root margin
would inset the bar with everything else. It would also inset the drawer in the second column, which
draws its own edge and wants none.
It was 16 and 20 until this pass, which put the Hosts header a visible step left of and above every
other screen's. Four numbers rather than one is the cost of the two exceptions above; changing one of
them means changing all four.
-->
<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="26,26,26,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="26,0,26,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="26,0,26,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="26,0,26,26" 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>