Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml
T
jaap-jan 3f419cb19b Let one card be selected at a time
The hosts screen draws two grids, one above the other, and each is a ListBox
with a selection of its own. Nothing joined them, so a group card and a host card
could be lit at the same moment — two chosen things, under two pairs of buttons
of which only one would act on whichever the eye had settled on. Both grids mark
a selection the same way, so there was nothing on screen to say which of the two
the next press belonged to.

They share one mark now. Selecting a host clears the group and selecting a group
clears the host, and that second one takes the detail pane down with it: a pane
about one machine cannot go on standing beside a marked group, because nothing
on it would be about what is selected.

Losing a selection deliberately clears nothing. A null arrives whenever either
list is rebuilt — every keystroke in the filter box and every background sync —
and treating that as somebody deselecting would take the mark off a group card
because a search emptied the grid beneath it.

The reload needed the same guard for the same reason. It falls back to the first
host when nothing is selected, which is what puts a target under CONNECT on a
fresh unlock; with one mark between the two grids that fallback would have
unselected a group nobody had touched, once a minute. It is skipped while a group
holds the selection, and it moved into a method of its own because the comment
saying why pushed ReloadHostsAsync past sixty lines.

One consequence needed handling rather than accepting. The phone's only route
into the group editor is a button on a heading in the host list, and it worked by
selecting the group first — which under this rule takes the highlight off the
machine somebody was about to connect to, on a screen that draws no group cards
to say where it has gone. EditGroup takes the group as an argument now: the
heading passes its own row, and the desktop's button beside the cards passes
nothing and still means "the card that is selected".

What the tests hold is the half that lives in the controls. Clearing the property
has to reach the list that is drawing the card, and a selection nulled in the
view model while the card stays highlighted is the exact failure this is about —
so the rule is driven on the real screen, in both directions, against the
ListBoxes' own SelectedItem. The desktop's EDIT is pressed through its binding
for the same kind of reason: a command refusing the button's empty parameter
would be a button that never fires, and nothing about the markup would say so.
2026-08-04 16:27:20 +02:00

592 lines
37 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>
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="GROUPS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The group's own two actions, beside the group cards rather than in the toolbar, because they
act on the card that is selected — and this is where the selection is made. Hidden rather
than disabled while DELETE's question is up, as every other pair in this application is, so
it cannot be pressed twice, and hidden again when there is nothing for them to act on. What
that is, with no card selected, is the group the trail above ends with; see
VaultViewModel.GroupTarget.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
</StackPanel>
</Grid>
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
A ListBox rather than an ItemsControl of buttons, and that is what marks the chosen group for
free: selection is a state the control already has, and the tile style draws it the same way
every other list in this application draws a selected row. Buttons would have needed a
Classes.active binding per card and a second copy of "which one".
◆ 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 and the two
buttons stay, because leaving it is a gesture and editing it is a button.
◆ 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>
<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}" />
</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>