Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml
T
jaap-jan 780f4bf892 Merge branch 'main' into the group's move and its deletion question
Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
2026-08-04 17:11:06 +02:00

665 lines
43 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 hosts screen: every group, then every host, as cards — and a drawer for whichever one is selected.
── IT WAS A 268-PIXEL LIST BESIDE A MOSTLY EMPTY COLUMN. ────────────────────────────────────────────
That list was doing two jobs at two-thirds size. It had to be narrow so the editor beneath it could be
a column, and being narrow is what made forty machines a scroll rather than a glance: a host row was a
name, an address and an auth word stacked in 268 pixels, and the column beside it repeated all three
for the one that happened to be selected.
Cards split the two jobs. The grid is about all of them — how many groups there are, which machines are
in one, which have a shell open — and it gets the full width to say so. The drawer is about one, and it
is only there when 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 grid 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 tag filter, a calendar and a share control in
the toolbar, and offers Serial beside Terminal. None of the five exists: there is one view, no saved
filters, nothing dated, no sharing outside a team vault, and no serial transport in the SSH layer. Five
disabled controls would teach nobody anything. See docs/design-import-gaps.md.
── ITS DATA CONTEXT IS THE VAULT, and it used to be the shell. ──────────────────────────────────────
The shell was needed only so that the old sidebar could be handed the vault on its own element while
everything around it bound Vault.*. The drawer needs no such thing — it is a child of this control and
inherits what this control has — so the indirection went with the sidebar, and every binding in this
file is now a property of the vault.
That is worth more than the tidiness: a control whose data context is the shell cannot be laid out by
the layout harness without building an entire MainWindowViewModel, and this is the screen with the most
worth measuring. The window wraps it in a Panel that carries IsHostsScreen, which is the same pattern
every other screen there already uses — see MainWindow.axaml's note on why the two cannot go on one
element.
-->
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
============ FIND ============
One wide box, which is what the width bought. It narrows the grid below and nothing else — the
connect path, the selection and the pinned host key list all read the unfiltered collection — so a
filter left in the box can hide a host but can never break one.
◆ IT IS ALSO THE WAY OUT OF THE GROUPS. The grid below holds one level of the tree, and typing here
searches that level and everything under it — every machine in the keychain while nothing is open.
That is the one thing on this screen that crosses a group boundary, and it has to be: a search that
looked only where you happen to be standing 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.
The connect bar that used to be this row — password box, REMEMBER, the note, CONNECT — is in the
drawer now, beside the host it is about. See HostDrawer.
-->
<Border Grid.Row="0" Padding="16,12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
<!--
============ THE TOOLBAR ============
Only what makes something new. EDIT and DELETE are not here: they act on one host, and one host is
what the drawer is about — a pair of buttons over a grid of forty is a pair whose subject the user
has to work out. The group's own EDIT and DELETE sit beside the group cards for the same reason.
-->
<Border Grid.Row="1" Padding="16,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="+ NEW GROUP" Command="{Binding NewGroupCommand}"
ToolTip.Tip="A heading for this grid, and the port, username and key the hosts under it inherit." />
</StackPanel>
</Border>
<StackPanel Grid.Row="2">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="16,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="16,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
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. It sits on an Auto row above a star row, and an ItemsControl with no
ceiling grows without limit — so a pass that merged twenty items would push the grid off the
bottom of a screen nobody could scroll.
-->
<Border Padding="16,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,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="4">
<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>
</StackPanel>
<!--
============ THE GRID ============
A drop target that takes nothing itself. Everything the pointer can be over during a drag is inside
this control, and it has to accept one so that the group cards at the top of it stay reachable from
the bottom of a long grid: a host dragged off the fortieth card cannot be dropped on a group card
that has scrolled out of sight, so a drag held at either edge scrolls this while it is in flight.
See HostsScreen.axaml.cs.
-->
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled"
DragDrop.AllowDrop="True">
<StackPanel Margin="16,14" Spacing="16">
<!-- ============ GROUPS ============ -->
<!--
Absent entirely from a vault nobody has filed anything in, which is what makes groups cost
nothing to ignore — the same rule the host list has always followed for its headings.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasGroups}">
<!--
============ WHERE YOU ARE ============
The trail, drawn exactly as the transfers screen draws a directory path, because it is the same
control answering the same question — and a window with two breadcrumbs that look different has
two ideas of what a path is. See TransfersScreen.axaml.
It exists because the cards below are one level of a tree rather than every group at once, and
a level nobody can name is a grid that has quietly hidden things. The first crumb is always
there and always goes back to every host, which is what the SHOW ALL button used to be: a
button that only says "stop" beside a trail that says where you are is the second control for
the same job, and this one also gets you halfway back rather than all the way.
◆ ABOVE THE GROUPS LABEL, and deliberately not under it as the transfers screen's is. That one
sits below its heading because it describes the one list beneath it; this one describes two.
Opening a group narrows the hosts as well as the cards, so a trail drawn under GROUPS would
read as one more thing about the groups while it is also the reason the grid at the bottom of
the screen is showing eleven machines instead of forty. First in the section, and both headings
below belong to it.
-->
<ItemsControl ItemsSource="{Binding GroupTrail}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupCrumbViewModel">
<StackPanel Orientation="Horizontal">
<!--
The command is the vault's and the item is a crumb, so it is reached through the
ItemsControl's data context — the same way every other templated command on the
desktop is bound.
-->
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding Group}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
<TextBlock Classes="mono" Text="" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
The heading and nothing else. This used to be a row with EDIT and DELETE at the far end of it,
and the card's own menu is where those live now — see the note on it below. They were a second
control for the job that menu already does, and the harder of the two to read: a button beside
a heading has to say which card it means, and with none selected it meant the group the trail
ends with rather than anything on screen. Moving a group is on that menu and never had a button
here, for the same reason and without the intervening mistake.
-->
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}" />
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
◆ MOVING THE GROUP TO ANOTHER VAULT. The host drawer's move panel, one level up, and under
the heading rather than in the drawer because a group has no drawer of its own. 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 it uses. It sits beside that question and never with it: MoveGroup disarms a pending
deletion and DeleteGroup folds this away, so the section draws at most one of the two.
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. Saying that here
rather than only in the status line afterwards is the difference between a warning and a
surprise.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
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>
<!--
A ListBox rather than an ItemsControl of buttons, and that is what marks the chosen group for
free: selection is a state the control already has, and the tile style draws it the same way
every other list in this application draws a selected row. Buttons would have needed a
Classes.active binding per card and a second copy of "which one".
◆ ONE CLICK SELECTS, TWO OPEN. SelectedItem is the selection and nothing else now — the
gesture that opens a group is a double-click, wired in the code-behind beside the one that
opens a shell on a host. One click used to mean both, and a card was then the only place a
group could be named while also being the control that threw the rest of the grid away.
◆ ONE SELECTION, TWO LISTS. Selecting a card here takes the mark off the host grid below, and
selecting a host takes it off this one. Two controls each keep their own SelectedItem, so the
vault is what joins them — see VaultViewModel.OnSelectedHostChanged. Without it both grids
could be lit at once, which is two chosen things under two pairs of buttons.
◆ THIS IS ONE LEVEL, NOT EVERY GROUP. It binds VisibleGroups: what is inside the group the
trail above ends with, or the outermost groups when it ends at ALL HOSTS. Folded away entirely
at a group with nothing inside it, which is an ordinary thing to open — the trail stays, and
it is also the way back to the level where that group has a card of its own to be edited from.
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
that is the whole of what a drag does on this screen. It used to be a heading inside the host
grid, and the headings are gone — see the note on the host grid below. A group is a thing that
is drawn once, at the top, where the eye already goes; a target that is also the label of what
you are aiming at needs no explaining. See the code-behind for the gesture.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
DragDrop.AllowDrop="True"
IsVisible="{Binding HasVisibleGroups}"
ItemsSource="{Binding VisibleGroups}"
SelectedItem="{Binding SelectedGroup}"
ToolTip.Tip="Press a group to select it, double-press to open it. Drag a host card onto one to file it there.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
The three things you can do to a group, on the group itself — the same menu the host grid
below has, for the same reasons. On the list rather than in the item template, because the
commands are the vault's and a menu inside a DataTemplate would have the row for its data
context; the code-behind selects whatever was right-clicked before the menu opens, and
cancels it outright over the space around the cards.
Open is here as well as on the double-click, which is not a duplicate for its own sake: a
gesture is unreachable without a pointer. Edit and Delete are here and nowhere else — they
were also a pair of buttons beside the heading above, which acted on GroupTarget and so with
no card selected meant the group the trail ends with rather than any card on screen. All
three act on the card that was right-clicked, which is the whole of what this menu is for.
Only Open takes a parameter, because OpenGroupCommand's null means ALL HOSTS rather than
nothing; the other three read GroupTarget, which the selection the code-behind has just made
is the first half of.
Moving is above the separator that Delete sits below, exactly as it is in the host pane's
menu, and for the same reason: it is not a field of the group and it is not a deletion
either. The two vaults are encrypted under different keys, so it is a re-seal of the whole
shelf into one and a tombstone per item in the other.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Command="{Binding OpenGroupCommand}"
CommandParameter="{Binding SelectedGroup}" />
<MenuItem Header="Edit…" Command="{Binding EditGroupCommand}" />
<MenuItem Header="Move to another vault…" Command="{Binding MoveGroupCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteGroupCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<Border Classes="tile">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Accent}">
<TextBlock Text="▤" FontSize="14" Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!-- A Grid rather than a horizontal StackPanel; see the note on the host card. -->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
<!--
Which vault this group is in, on the same rule and in the same place as the host
card's: only where there is more than one vault to be in. It matters more on a
folder than on a machine — two vaults may each hold a "production", and without
this the cards are two identical folders side by side, one of which a colleague
can read. It is also what says a group is shared at all.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<!-- ============ HOSTS ============ -->
<StackPanel Spacing="8">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!-- The collection's own count, so it follows both filters with no second number to keep in step. -->
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding VisibleHosts.Count}"
FontSize="11" Foreground="{StaticResource TextFaint}"
VerticalAlignment="Center" />
</Grid>
<!--
Named because it is where keyboard focus lands when the terminal gives it back, and because
every gesture on it is wired in the code-behind.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty
list has none. Without it the release-the-keyboard path is a measured no-op: it takes Win32
focus off the terminal's child window and then calls Focus() on something that refuses it,
leaving the window with nothing focused and the keystrokes going nowhere.
◆ CARDS ONLY, AND NO HEADINGS. It binds VisibleHosts rather than SidebarRows, which is the
collection with the group headings folded into it. Those headings were a full-width fold-away
bar between the cards — a chevron, a name and a count — and in a wrap of cards they read as a
collapsed dropdown that had somehow been dealt the hosts: the moment a first group existed the
grid stopped being a grid. Two controls saying "group" on one screen was one too many, and the
cards above are the better of the two. SidebarRows and the fold are still the phone's, whose
list has no room for a row of group cards; see Android's HostsScreen.
What a heading also was is the drop target, and that moved to the group cards rather than
going away with it — see the note on them above, and the code-behind for the gesture. A host is
taken back out of a group through its own editor, which is the one place "no group" can be said
plainly.
◆ ONE LEVEL, AS A DIRECTORY PANE IS. A host filed under a group is inside that group and is
not also here — the cards above are the way into it, and this grid holds what is loose at
whatever level the trail names. It used to hold every host at once with the groups drawn as
headings over them, which made opening a group an operation that could only ever subtract.
See VaultViewModel.Matches, and the note on the find box above for the one thing that crosses
a group boundary.
Which group a card is in is on the card, as a chip. That is what the heading above it used to
say, and one chip per card says it without an accordion — and it is what tells a search result
from a host that lives at this level, since searching is the one way a card from inside a
group reaches this grid.
SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item
here is now a host. That property swallows the null the ListBox writes back whenever the
collection is rebuilt — which is every filter keystroke and every background sync — where
binding the host selection directly would let that null through, and a null host selection
closes the drawer and disarms a delete somebody is in the middle of confirming. See the
property, and RebuildVisibleHosts on where the real clearing is done.
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
and the commands here are the vault's. The code-behind selects whatever was right-clicked
before the menu opens, so the selection-based commands act on the card under the pointer,
and cancels the menu outright over the space around the cards, where there is no host to act
on.
-->
<!--
Details is here as well as on the pencil, and that is not a duplicate for its own sake: the
pencil appears on hover, so it is unreachable without a pointer, and this menu is what a
keyboard and the context key can get to. It passes no parameter — the code-behind has
already selected whatever was right-clicked, which is exactly what OpenHostPane falls back
to.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Details…" Command="{Binding OpenHostPaneCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<!--
The address is on the tooltip and nowhere else on the card. It used to be the subtitle;
see the note on the summary line below for what took its place and why it is not lost.
-->
<Border Classes="tile" ToolTip.Tip="{Binding Address}">
<!--
A Panel so the pencil below can be drawn *over* the card rather than in a column of
its own. A column would have cost the name 30 pixels of a 232-pixel tile — permanently,
for a control that is only there while the pointer is — and the name is the thing the
grid exists to be scanned by.
-->
<Panel>
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2">
<!--
◆ A GRID AND NOT A HORIZONTAL StackPanel, on both of these rows, and the two
look interchangeable until the text is too long for the card.
A horizontal StackPanel measures every child with infinite width, so a
TextBlock inside one never learns it is short of room and TextTrimming never
fires — the text simply runs on past the card's border and over the card beside
it. The card is a fixed 248 and a host name is not, so that is the ordinary
case rather than the edge one.
The star column is the part that gives way and the Auto column is the part that
must not: a badge saying "not synced" and the word that says how a host
authenticates are both short and both meaningless trimmed, where a hostname
with its tail cut is still the machine you were looking for.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<!--
◆ ONE COMMA-SEPARATED LINE: the transport, the account, and every tag this host
wears. It replaces two things — the address-and-authentication row that was here,
and the row of tag chips that used to sit under the whole card.
What went is the address, and it went to the tooltip above rather than nowhere.
A card is read while scanning forty machines, and at that moment the name and
what kind of machine it is are what is being looked for; an address is what you
read once you have found it, which is what the drawer is for. The word saying
how it authenticates went with it, for a smaller reason: it was there to warn
that a password box would be needed, and the box is in the pane beside that
warning now.
See HostRowViewModel.Summary, and the remark there on why "ssh" is printed at
all when every host in this product is one.
-->
<TextBlock Text="{Binding Summary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
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 grid
spanning several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!--
Connected, and nothing more. Green means a terminal is open on this host right
now; grey means there is not one. It is deliberately not reachability — nothing
here pings anything, and a dot that meant "up" would be a claim this application
never checks.
-->
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Top" Margin="6,3,0,0" />
</Grid>
<!--
The group this host is filed under, and only when it is filed under one — which is
the same rule the group cards above follow, and the reason a keychain nobody has
filed anything in looks exactly as it did before groups existed.
This is what the fold-away heading over a run of cards used to say. A chip on the
card says it per host instead, which is the shape the rest of this card already is
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
needs no row of its own, and it is what changes when a card is dropped onto a group.
Accent rather than plain, so the one chip that is a *place* is not read as another
tag. A group whose name the vault cannot resolve leaves this empty rather than
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel.
-->
<Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
IsVisible="{Binding HasGroup}">
<TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
TextTrimming="CharacterEllipsis" />
</Border>
<!--
A wrapped row of tag chips was here. The tags are in the summary line above now,
after the account, which is what took the card from three rows and a wrap down to
two: a chip and a comma-separated name say the same thing, and only one of them
needs a row of its own. Chips are still what a tag looks like where it is *chosen* —
the editor's picker in the drawer, and the phone's own list. See
HostRowViewModel.Summary.
-->
</StackPanel>
<!--
◆ THE PENCIL, AND THE ONLY WAY THE DRAWER OPENS FROM THIS GRID.
Selecting a card no longer opens it. That used to be automatic, which meant the grid
gave up 304 pixels the moment anybody touched a card — including every card arrowed
past on the way to the one they wanted. Opening the pane is a thing you ask for now,
and this is where the asking is. See VaultViewModel.IsHostPaneOpen.
Bottom right, under the status dot rather than beside it, and over the card rather
than in a column: the two marks a card can carry on that edge are "a shell is open
here" and "open this one's pane", and stacking them is what keeps both out of the
name's width. It appears on hover and on the selected card; see Button.rowedit in
App.axaml for why that is IsVisible rather than an opacity.
The row is the command's parameter rather than being left to the selection. A button
inside a ListBoxItem takes the press itself, and whether the item is also selected by
it is the theme's business — so a command reading SelectedHost would open the pane on
whichever card happened to be lit, which on the first click of a session is none of
them.
-->
<Button Classes="flat rowedit" Content="✎"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).OpenHostPaneCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Opens this host's pane beside the grid" />
</Panel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Nothing to show, and the two reasons it can happen are different questions. An empty keychain
is an invitation; a filter that matches nothing is a filter that has gone too far, and saying
"add a host" to somebody who has thirty would be answering the wrong one.
-->
<TextBlock Classes="hint" FontSize="12" TextWrapping="Wrap" MaxWidth="480"
HorizontalAlignment="Left" Margin="0,4,0,0"
IsVisible="{Binding !HasVisibleHosts}"
Text="{Binding NoVisibleHostsMessage}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!--
The drawer, and it takes its 304 pixels only while it has been opened — which is what gives the grid
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>