Choose more than one host card on the desktop, the way the phone already can

The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action
bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could
fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the
context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing
out a vault was eleven rounds of the deletion question.

So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run
between the anchor and the card, and drags a band out over the space between and below the cards to tick
everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and
Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on
screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked
before Delete is pressed.

TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are
answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a
Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine
somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case
is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary
click exactly one card is in play, which is what makes every command on the screen unambiguous again.

The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts.
Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them
and for the same reason; the other four read better for a count. A right click on a card outside the set
drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked
behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the
other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries,
and a strip repeating a menu that already exists would be a second home for the wording that matters most.
What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the
actions are.

A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now,
and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open
editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving
whichever card the pointer happened to be holding and leaving the other five where they are is a gesture
that quietly does a fraction of what it looks like it does, and the five left behind look filed.

The three panels the set's actions raise had never been drawn in a window: the vault picker with its key
question, the group picker, and the deletion question. All three sit above the grid rather than over it,
which is the arrangement the GROUPS section and the phone's list already use and for the reason written
there — the ticked cards are the information the question exists to give, so the grid shortens instead.

A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the
run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately
above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with
the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work
with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked
about and drops the question when the set stops being them — the question rather than the set, because what
somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion
arriving mid-question and shrinking the set under it.

VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests
drive real pointer and key input through the headless window — the modifier click and what it must not do to
the selection, the run and its re-measurement from the anchor, the band and the click that drops the set,
Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests
measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host
first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's
write and its refusal, and the deletion question dropping itself.

manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the
gestures — the rectangle actually being painted and the tick and the fill being legible together are the two
things the harness cannot look at.
This commit is contained in:
2026-08-06 14:11:48 +02:00
parent 1e8a1f2e83
commit 507cd9ff88
8 changed files with 1418 additions and 64 deletions
+242 -9
View File
@@ -377,12 +377,164 @@
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
============ ◆ WHAT SAYS A SET IS UP ============
Ctrl, Shift and a band dragged across the grid 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 grid would be a second home for entries the menu already has.
◆ THE MENU IS WHERE THE ACTIONS ARE, and the sentence says so because there is no other way
to find that out. Everything else on this screen can be reached by looking at it.
A Grid rather than a horizontal StackPanel, for the reason the host card carries at length: a
horizontal StackPanel measures its children with infinite width, so the sentence would never
learn it is short of room and would run out over the count at the far end instead of
trimming. At the window's minimum with the drawer open there is not room for all of it.
-->
<Grid Grid.Column="1" Margin="12,0" 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>
<!-- 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>
<!--
============ ◆ WHAT THE SET'S MENU ASKED ============
Three panels, at most one of them up, all three above the grid rather than over it — the same
arrangement the GROUPS section uses and the phone's list uses, for the reason written there: a
card laid over the cards hides the ticked ones, and which hosts are ticked is the information
the question exists to give. The grid shortens instead.
Which of the three is showing is decided in the view model, because each disarms the other two
on the way up. See MoveChosenHostsToVault, RegroupChosenHosts and DeleteChosenHosts.
-->
<!--
◆ 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.
The sentence is not decoration: groups and tags are items of the vault being left, so neither
can come along, and a host that arrived carrying either would point at something the
destination does not contain.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
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.
See VaultViewModel.BringsTheChosenBindingAlong.
-->
<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 dragging the set onto a group card does without the
picker. Both exist for the reason the single-host gesture and the host editor both do: a
gesture is unreachable without a pointer, and a card that has scrolled out of sight cannot be
dragged onto.
One keychain's groups. A selection spanning two is refused before this panel is drawn at all —
see VaultViewModel.RegroupChosenHosts, which is the same refusal the drop makes.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
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; see ConfirmDeleteCard.
-->
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingChosenHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
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.
@@ -426,7 +578,8 @@
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedSidebarRow}">
SelectedItem="{Binding SelectedSidebarRow}"
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>
@@ -449,13 +602,53 @@
already selected whatever was right-clicked, which is exactly what OpenHostPane falls back
to.
-->
<!--
◆ TWO MENUS IN ONE, AND WHICH OF THEM IS DRAWN IS WHETHER ANYTHING IS TICKED.
Once Ctrl, Shift or a band has ticked cards, this menu is about the set and nothing else —
and it has to be, because the entries above act on the vault's *selection*, which is one
host. A Delete… that asked about the card under the pointer while six sat ticked behind the
menu is the exact mistake the whole of OnContextRequested exists to prevent, arrived at from
the other direction. Right-clicking a card that is not in the set drops the set first, so
these entries and the ones above are never both about something; see the code-behind.
This is the only home for the seven, which is why it says what each of them takes: a set from
two keychains, a set with a host in it that a newer version wrote, and a set of one are three
different answers. The three single-host entries are collapsed rather than greyed for the
reason the phone's sheet collapses them — a terminal, an SFTP session and a form are each
about one machine, and there is no reading of "edit these six".
Delete is under a separator, as it is above and as the phone's sheet has it.
-->
<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}" />
<MenuItem Header="Connect" IsVisible="{Binding !IsChoosingHosts}"
Command="{Binding ConnectCommand}" />
<MenuItem Header="Details…" IsVisible="{Binding !IsChoosingHosts}"
Command="{Binding OpenHostPaneCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding !IsChoosingHosts}"
Command="{Binding EditSelectedHostCommand}" />
<Separator IsVisible="{Binding !IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding !IsChoosingHosts}"
Command="{Binding DeleteHostCommand}" />
<MenuItem Header="Connect" IsVisible="{Binding HasOneChosenHost}"
Command="{Binding ConnectToChosenHostCommand}" />
<MenuItem Header="Browse files" IsVisible="{Binding HasOneChosenHost}"
Command="{Binding BrowseChosenHostCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding HasOneChosenHost}"
Command="{Binding EditChosenHostCommand}" />
<MenuItem Header="Change group…" IsVisible="{Binding IsChoosingHosts}"
Command="{Binding RegroupChosenHostsCommand}" />
<MenuItem Header="Move to another vault…" IsVisible="{Binding IsChoosingHosts}"
Command="{Binding MoveChosenHostsToVaultCommand}" />
<MenuItem Header="Copy to another vault…" IsVisible="{Binding IsChoosingHosts}"
Command="{Binding CopyChosenHostsToVaultCommand}" />
<MenuItem Header="Duplicate" IsVisible="{Binding IsChoosingHosts}"
Command="{Binding DuplicateChosenHostsCommand}" />
<Separator IsVisible="{Binding IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding IsChoosingHosts}"
Command="{Binding DeleteChosenHostsCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
@@ -466,7 +659,15 @@
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}">
<!--
◆ Classes.chosen is the tick on the card, and it is a class rather than the list's own
selection because they are two different things: the list holds one card, which is what
the drawer and CONNECT are about, and the set is what Ctrl, Shift and the band build. A
card can be in the set without being the selected one, which is what a run of six looks
like. See VaultViewModel.ChooseHosts and Border.tile.chosen in App.axaml.
-->
<Border Classes="tile" Classes.chosen="{Binding IsChosen}"
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,
@@ -543,9 +744,21 @@
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.
◆ The tick sits beside it rather than in place of it, which is the arrangement
the phone's row uses and for the same reason: the dot says whether a shell is
open on this machine, and swapping it for the tick would make choosing a host
hide a fact about it. It takes no width while it is collapsed, so a grid with
nothing ticked is laid out exactly as it was.
-->
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Top" Margin="6,3,0,0" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Top" Margin="6,2,0,0">
<TextBlock Text="✓" FontSize="11" IsVisible="{Binding IsChosen}"
Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Center" Margin="0,1,0,0" />
</StackPanel>
</Grid>
<!--
@@ -625,6 +838,26 @@
</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 grid 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>
<!--