Take the group headings out of the host grid, and drop onto a group card

A first group turned the wrap of host cards into an accordion: SidebarRows
interleaves a full-width fold-away heading — chevron, name, count — between the
cards, and in a grid that reads as a dropdown somebody left open. The desktop
grid binds VisibleHosts now. The headings and the fold stay for the phone, whose
list has no room for the row of group cards the desktop draws above the grid.

What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once
per reload like the tag names, drawn as an accent chip and absent from a host in
no group — or in one that has been deleted, which is the same thing to look at.

What a heading also was is the drop target, and that moves to the group cards.
Two things go with it. A host dropped onto another host card used to be filed
beside it, which was legible while a heading named the band of cards it landed
in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged
back out of a group; the way out is the picker in its own editor, which is the
one place "no group" can be said in words.

A drag held at either edge of the grid scrolls it. Without that the gesture only
works for whoever can see both ends of it: the group cards are the first thing in
the scroller, the host may be the fortieth card down, and a drag cannot use the
wheel. A step per drag event rather than a timer, so it follows the pointer and
stops when it stops.

The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings
asserts the grid's contents rather than only measuring them, because a heading
that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost
raises a real DragOver over both kinds of card and checks the effects and the
mark — the nearest a headless test gets to a gesture no headless test can
synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
This commit is contained in:
2026-08-03 15:49:58 +02:00
parent 1b7df47537
commit f9d08b738c
8 changed files with 432 additions and 147 deletions
+42 -31
View File
@@ -269,33 +269,32 @@ single-process test can reach.
Open the hosts screen without creating any group.
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no headings
between the cards, no UNGROUPED, nothing saying the hosts are unfiled.
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no chip on any
card, nothing saying the hosts are unfiled.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
### 3.2 Filing hosts, and folding a heading
### 3.2 Filing hosts, and reading which group a card is in
Make two groups, file some hosts into each through the host editor, then click a heading.
Make two groups and file some hosts into each through the host editor.
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
**Also check:** clicking a heading does not change which host is selected — the drawer stays open on the
same machine and its EDIT and DELETE go on acting on it. This is asserted in a test, but the test drives the
view model directly; what it cannot see is whether the `ListBox` writes something else back through the
binding first.
**Pass:** every filed card carries the group's name as an accent chip under its address; the cards stay one
flat wrap, in one order, with nothing between them. **There is no heading and no fold on the desktop** — the
headings, their chevrons and UNGROUPED are the phone's, whose list has no room for a row of group cards.
**Then press a group card.** The grid narrows to that group's hosts, the card is marked as chosen, and
SHOW ALL appears beside GROUPS. Pressing it brings the rest back and unmarks the card.
**Failure means:** a full-width bar with a chevron between the cards is the old grouping coming back through
`SidebarRows` — the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
### 3.3 Deleting a group with hosts in it
Select a group with hosts and press DELETE.
**Pass:** the question names how many hosts are filed under it and says they stay. Agreeing removes the
group; the hosts reappear under UNGROUPED with everything else about them unchanged.
group; the hosts lose their chip and are otherwise unchanged.
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
`HostGroupRepository`.
@@ -305,8 +304,8 @@ group; the hosts reappear under UNGROUPED with everything else about them unchan
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
again. Back on A, sync.
**Pass:** A shows the host under UNGROUPED. Open that host's editor: the group picker shows "(a group that is
no longer here)" and *keeps it selected*. Change the port and save.
**Pass:** A shows the host with no chip on its card, as though it were in no group. Open that host's editor:
the group picker shows "(a group that is no longer here)" and *keeps it selected*. Change the port and save.
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
@@ -642,33 +641,45 @@ Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is s
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
back.
### 7.6 Dragging a host into a group · **least covered, like all drag and drop**
### 7.6 Dragging a host onto a group card · **least covered, like all drag and drop**
Make two groups and file a host into one. Drag a host card onto another group's heading; onto a host card
inside another group; and onto UNGROUPED.
Make two groups and file a host into one. Drag a host card up onto the other group's card.
**Pass:** whatever is under the pointer washes accent while the pointer is over it, the cursor shows a move
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
headings change. Dropping onto its own group's heading is refused while still in the air.
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the
cursor shows a move rather than a refusal, and the drop files the host — the chip on its card changes to the
new group's name, the host counts under both group cards change, and the status line says where it went.
**Failure means:** headless Avalonia cannot synthesise a platform drag, so nothing about this gesture is
automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSelected`.
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the
group the host is *already* in; over another **host** card, which is deliberately not a target now that there
are no headings to say which group it would mean; and over the empty space around the cards.
**And getting a host back out** is the host's own editor — pick "No group" in its picker. There is no
UNGROUPED target on the desktop any more, because there is no UNGROUPED heading for it to be.
**And a drag held near the top or bottom edge of the grid scrolls it**, which is what makes this usable at
all with forty machines: the group cards are the first thing in the scroller, and a drag cannot use the
wheel. The pointer has to keep moving inside the band — a stationary pointer gets no drag events.
**Failure means:** headless Avalonia cannot synthesise a platform drag, so the picking up, the cursor and
the drop are covered by nothing. What *is* automated is the decision each drag event takes —
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected`.
### 7.7 A click still selects, and a double click still connects
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click a group
heading.
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click the space
around the cards.
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking a
heading folds it and unfolds it again and connects to nothing.
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking the
space around the cards connects to nothing.
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the grid unusable. A heading that connects means the
as 2.16 on the other screen, and here it would make the grid unusable. Empty space that connects means the
double-tap handler has lost its check that the pointer was over a card.
### 7.8 The highlight clears after a drag that goes nowhere
Drag a host over a heading and release outside the grid, or press Escape mid-drag.
Drag a host over a group card and release outside the grid, or press Escape mid-drag.
**Pass:** the wash goes away.
@@ -676,7 +687,7 @@ Drag a host over a heading and release outside the grid, or press Escape mid-dra
With host A selected, right-click host B and choose Delete.
**Pass:** the question names **B**. Then right-click a group heading.
**Pass:** the question names **B**. Then right-click the space around the cards.
**Pass:** no menu opens at all, and the host selection has not moved.
+25 -10
View File
@@ -276,9 +276,12 @@
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<Style Selector="Button.grouphead">
<Setter Property="Padding" Value="10,7,10,4" />
</Style>
<!--
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
cards. The headings went when the grid became cards and the group cards above it became the thing that
names a group — see HostsScreen.axaml — and a style with nothing wearing it is a shape somebody will
later reintroduce by reaching for it.
-->
<!-- A category in the vault's rail: the same accent strip a selected row carries, at full row height. -->
<Style Selector="Button.cat">
@@ -661,19 +664,31 @@
</Style>
<!--
The row a host is about to be dropped on. A wash rather than the accent strip a selection carries,
because it is not a selection: it says "let go here", it lasts as long as the pointer is over the row,
and it has to be legible on top of whatever that row already looks like — including the selected row,
which is often the one being dragged onto.
The group card a host is about to be dropped on. A wash rather than the accent strip a selection
carries, because it is not a selection: it says "let go here", it lasts as long as the pointer is over
the card, and it has to be legible on top of whatever that card already looks like — including the
selected one, which is the group the grid is currently narrowed to and so often the one being dragged
onto.
Painted on the tile rather than on the item's own presenter, which is where this used to go when the
target was a full-width heading. A wash behind a 10-pixel-rounded tile is a square of colour with the
card floating on it — the same four grey corners the tiles list clears the theme's own hover and
selection brushes to avoid. Declared after that rule and after the selected-tile rule, because
Avalonia has no specificity and settles two matching rules by declaration order.
◆ THE DOUBLED BORDER IS THE MARK, not the fill, and that is what the move to the tile forced. The fill
a selected tile already carries is the strongest one this palette has, so a target that only filled
would be invisible on the card the grid is narrowed to — which is the card a host most often has to be
dragged off, into another group. Two pixels of accent is legible on both.
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
-->
<Style Selector="ListBoxItem.droptarget /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
<Style Selector="ListBoxItem.droptarget Border.tile">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<!--
+68 -40
View File
@@ -151,8 +151,17 @@
</StackPanel>
<!-- ============ THE GRID ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
<!--
============ 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 ============ -->
@@ -198,10 +207,18 @@
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
for why the phone's own use of SelectedGroup must not.
◆ 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 to narrow the grid; 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"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}">
SelectedItem="{Binding GroupFilter}"
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
@@ -257,15 +274,31 @@
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.
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
reasons, and neither is decoration: a grid showing every group at once has to say which cards
belong to which, and a heading is the drop target that takes a host out of one group and into
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
container, and the code-behind for what a drag does with one.
◆ 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.
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.
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"
DragDrop.AllowDrop="True"
ItemsSource="{Binding SidebarRows}"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
@@ -279,7 +312,8 @@
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 a group heading, which has no host to act on.
and cancels the menu outright over the space around the cards, where there is no host to act
on.
-->
<ListBox.ContextMenu>
<ContextMenu>
@@ -290,35 +324,9 @@
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.DataTemplates>
<ListBox.ItemTemplate>
<!--
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
card: it names the run of cards under it, and a heading the width of one card would read
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
width it asks for and starts a new line when the line cannot hold it, so a child asking
for more than one line holds is alone on its own.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" MinWidth="640"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
Foreground="{StaticResource TextFaint}" FontSize="9"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" HorizontalAlignment="Left" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="tile">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -389,6 +397,26 @@
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>
<!--
The tags this host wears. Under the summary rather than beside it, because a host
can wear several and the line above is three fixed-width facts where these are not.
@@ -412,7 +440,7 @@
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox.ItemTemplate>
</ListBox>
<!--
+109 -35
View File
@@ -25,7 +25,7 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostsScreen : UserControl
{
/// <summary>
/// How a host travels from the card it was picked up on to the heading it is dropped on.
/// How a host travels from the card it was picked up on to the group card it is dropped on.
/// </summary>
/// <remarks>
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
@@ -43,6 +43,21 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private const double DragThreshold = 5;
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
/// <remarks>
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
/// carrying something the user is looking at — a band the width of a hairline would only be found by
/// accident, and only by somebody who did not need it.
/// </remarks>
private const double EdgeBand = 48;
/// <summary>How far one drag event inside that band moves the grid.</summary>
/// <remarks>
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
/// </remarks>
private const double EdgeStep = 24;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
@@ -55,7 +70,7 @@ internal sealed partial class HostsScreen : UserControl
private Point origin;
/// <summary>The card or heading the pointer is currently over, while a drag is in flight.</summary>
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostsScreen()
@@ -78,9 +93,22 @@ internal sealed partial class HostsScreen : UserControl
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(HostGrid, OnDragLeave);
DragDrop.AddDropHandler(HostGrid, OnDrop);
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
// same control: the target was a heading among the cards, and with the headings gone the group cards
// are the only thing on this screen that names a group. A card dropped onto another card is refused
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
// would be, and a gesture whose result you cannot see before you let go is one that files machines
// somewhere the user did not intend.
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
DragDrop.AddDropHandler(GroupGrid, OnDrop);
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
// platform is concerned. This is where all of them but a group card are turned down — and where a
// drag held at the top or bottom edge pulls the grid towards the target.
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
}
/// <remarks>
@@ -117,9 +145,9 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// Only over a card. A double-tap on a group heading folds it and unfolds it again, and must not also
// connect to whichever host was selected before — which is what an unguarded handler would do, on a
// machine the user is not even pointing at.
// Only over a card. A double-tap on the space around them must not connect to whichever host was
// selected before — which is what an unguarded handler would do, on a machine the user is not even
// pointing at.
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
@@ -137,9 +165,9 @@ internal sealed partial class HostsScreen : UserControl
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the space around the cards. Neither is a host, and a
/// menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or act
/// on something else entirely.
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
/// Edit and Delete over it would be three buttons that either do nothing or act on something else
/// entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
@@ -233,9 +261,9 @@ internal sealed partial class HostsScreen : UserControl
/// Says whether what is under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
/// does nothing is worse than one that says so while it is still in the air.
/// A host over the card of the group it is already in is refused, which is not pedantry:
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
/// would do something and then does nothing is worse than one that says so while it is still in the air.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
@@ -254,6 +282,60 @@ internal sealed partial class HostsScreen : UserControl
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <summary>
/// Carries the grid under a drag that is over the cards rather than over a group.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
/// list.
/// </para>
/// <para>
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
/// </para>
/// <para>
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
/// everything that is not a group.
/// </para>
/// </remarks>
private void OnDragOverScroll(object? sender, DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is null)
{
return;
}
e.Handled = true;
e.DragEffects = DragDropEffects.None;
Unmark();
var at = e.GetPosition(Scroll).Y;
var height = Scroll.Bounds.Height;
var step = at switch
{
_ when at < EdgeBand => -EdgeStep,
_ when at > height - EdgeBand => EdgeStep,
_ => 0,
};
if (step == 0)
{
return;
}
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
}
/// <remarks>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
@@ -279,13 +361,12 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a card is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of cards under a heading is a target rather than
/// one line of text. The ungrouped heading is a target like any other, and it is how a host is taken out
/// of a group without opening the editor.
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
/// dropped on is what it says on it. The space around the cards takes nothing.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the grid already does with
/// A group the vault no longer has is read as no group at all, which is what the list already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
@@ -293,25 +374,13 @@ internal sealed partial class HostsScreen : UserControl
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|| dragged.Host.GroupId == group.EntityId)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
return new DropTarget(dragged, group.EntityId, container);
}
private void Mark(ListBoxItem container)
@@ -353,5 +422,10 @@ internal sealed partial class HostsScreen : UserControl
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
/// <remarks>
/// The group is a <see cref="Guid"/> rather than a nullable one, which it was while the ungrouped
/// heading was also a target. Every target is now a group card and every group card has an id; the way
/// out of a group is the host's own editor, which is the one place "no group" can be said in words.
/// </remarks>
private sealed record DropTarget(HostRowViewModel Host, Guid GroupId, ListBoxItem Container);
}
@@ -222,6 +222,31 @@ internal sealed partial class HostRowViewModel(
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// The name of the group this host is filed under, or empty for a host that is in none.
/// </summary>
/// <remarks>
/// <para>
/// What the desktop's grid draws as a chip on the card. It is the one thing the fold-away group headings
/// between the cards used to say, and the reason removing them cost nothing: a card that names its own
/// group answers the question per host, where a heading answered it per run of cards and needed the grid
/// to be sorted into runs to do it. The phone still draws headings — its list has no room for the row of
/// group cards the desktop puts above the grid — so <see cref="SidebarGroupHeader"/> stays.
/// </para>
/// <para>
/// Resolved once when the list is built, like <see cref="TagLabels"/> and for the same two reasons: the
/// row stores an id and a chip shows a name, and the answer cannot change without the list being rebuilt.
/// <b>A group id that does not resolve leaves this empty</b> rather than printing the id — the reference
/// is allowed to dangle, deleting a group deliberately does not rewrite the hosts in it, and "the group
/// this names is not here" and "this names no group" are the same thing to look at. See
/// <see cref="HostSecret.GroupId"/>.
/// </para>
/// </remarks>
internal string GroupLabel { get; init; } = string.Empty;
/// <summary>Whether this host is filed under a group the vault can name.</summary>
internal bool HasGroup => GroupLabel.Length > 0;
internal HostSecret Host => host.Secret;
/// <summary>
@@ -1051,8 +1076,8 @@ internal sealed partial class VaultViewModel(
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
+ "~/.ssh/config from Preferences.",
(_, not null, 0) =>
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
+ "group in a host's own editor.",
"Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's "
+ "card — or choose the group in a host's own editor.",
_ => "No host matches that. The name, the address and the notes are all searched.",
};
@@ -2358,6 +2383,12 @@ internal sealed partial class VaultViewModel(
// Only when there is something to tell apart. A badge on every row of a
// single-vault list is noise that says the same thing on all of them.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
// Every readable vault's groups, not the active one's, because a host in a team's
// vault is filed under that team's group — and looked up here rather than on the row
// for the reason the tag names are: the map is the list's, and a row that reached for
// it would be a lookup per chip per redraw.
GroupLabel = GroupLabelFor(item.Secret.GroupId),
}));
}
@@ -2674,6 +2705,17 @@ internal sealed partial class VaultViewModel(
.OrderBy(label => label, StringComparer.CurrentCulture),
];
/// <summary>
/// The name behind a host's group id, or empty where there is none to show.
/// </summary>
/// <remarks>
/// Empty covers both "this host is in no group" and "the group it names is not in this vault any more",
/// which is the same answer the list gives a dangling reference everywhere else. See
/// <see cref="HostRowViewModel.GroupLabel"/>.
/// </remarks>
private string GroupLabelFor(Guid? groupId) =>
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
private void RebuildGroups()
{
@@ -2857,10 +2899,10 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <para>
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
/// host that is about arrangement rather than about the machine.
/// What dragging a host card onto a group card does, and the only thing in this application that changes
/// a host without opening the editor. That is the justification for it existing at all: filing thirty
/// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one
/// field of a host that is about arrangement rather than about the machine.
/// </para>
/// <para>
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
@@ -127,30 +128,85 @@ public sealed class HostGridTests : IAsyncLifetime
}
/// <remarks>
/// A heading is an item in the same list and the control will happily select it, but it is not a host —
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
/// or act on a machine somewhere else in the grid.
/// <para>
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
/// </para>
/// <para>
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, window) =>
await OnTheGridAsync((screen, _) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
RightClick(heading, window);
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.HostGrid,
});
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// A host held over a group card would be filed there, and one held over another host card would not.
/// </summary>
/// <remarks>
/// <para>
/// The group headings that used to sit between the cards are gone — see
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
/// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
/// dropped onto another card used to file it beside that one, which was legible while a heading said
/// which group the band of cards belonged to and is guesswork now that none does.
/// </para>
/// <para>
/// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
/// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
/// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
/// covers is in docs/manual-checks.md 7.6.
/// </para>
/// </remarks>
[Fact]
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
{
await OnTheGridAsync((screen, _) =>
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
var onto = screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
var over = Over(onto, carried);
onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
over.DragEffects.ShouldBe(DragDropEffects.Move);
var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);
refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
refused.DragEffects.ShouldBe(
DragDropEffects.None,
"a card dropped onto another card would be filed somewhere nothing on screen names");
// And the group card it was over a moment ago stops offering to take it, which is the half of
// this that is wrong far more often than the mark appearing at all.
onto.Classes.ShouldNotContain("droptarget");
});
}
/// <remarks>
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
@@ -182,6 +238,32 @@ public sealed class HostGridTests : IAsyncLifetime
// ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
/// <remarks>
/// Declared again here rather than made visible, because what the two have in common is the contract —
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
/// started carrying something else under it.
/// </remarks>
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
/// <remarks>
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
/// </remarks>
private static DragEventArgs Over(Interactive target, DataTransfer carried)
{
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
target.RaiseEvent(over);
return over;
}
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, window);
@@ -325,22 +325,45 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
/// <summary>
/// The grid is cards and nothing else, whatever the vault has been filed into.
/// </summary>
/// <remarks>
/// Headings are items in the same list as the cards, drawn from a different template, and they span a
/// whole row of the wrap rather than sitting in the flow as another card. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// <para>
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
/// that spans the width is as inside it as a card is.
/// </para>
/// <para>
/// Measured with every host filed, which is the shape that used to produce the most headings.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithGroupHeadingsInTheGrid()
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
foreach (var host in vault.Hosts.ToArray())
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(host, vault.Groups[0].EntityId));
}
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
await OnTheHostsScreenAsync((screen, _) =>
{
var rows = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Select(item => item.DataContext)
.ToList();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
rows.ShouldNotBeEmpty("the seed has to put hosts in the grid");
rows.ShouldAllBe(row => row is HostRowViewModel);
});
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three groups and every host filed"));
}
/// <remarks>
@@ -3368,9 +3368,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// What dragging a row onto a heading does. It is the same write the editor makes — one field of the
/// host, pushed straight away — reached without opening a form, because filing thirty imported machines
/// through the editor is thirty rounds of open, pick, save.
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
/// machines through the editor is thirty rounds of open, pick, save.
/// </remarks>
[Fact]
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
@@ -3389,15 +3389,21 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
// Under the group's own heading now, which is the thing the drop was aiming at.
// Under the group's own heading now, which is what the phone's list draws.
vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(header => header.GroupId == group)
.Count.ShouldBe(1);
// And back out again, which is what the ungrouped heading is a target for.
// And the name on the card, which is what the desktop's grid draws instead of that heading — the one
// thing on screen that changes where the host was dropped rather than where it came from.
vault.Hosts.Single().GroupLabel.ShouldBe("production");
vault.Hosts.Single().HasGroup.ShouldBeTrue();
// And back out again, which is what the host's own editor is for now that the drop has one target.
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
}
/// <remarks>
@@ -3454,9 +3460,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Groups.ShouldBeEmpty();
vault.HasGroups.ShouldBeFalse();
// The host keeps the id, which is what makes this cheap; the sidebar is what resolves it to nothing.
// The host keeps the id, which is what makes this cheap; the list is what resolves it to nothing.
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
// The card says the same thing the phone's list does: nothing. An id nobody can name is drawn as no
// group rather than as a GUID on a chip.
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
}
/// <remarks>