Public Access
Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3'
This commit is contained in:
+42
-31
@@ -269,33 +269,32 @@ single-process test can reach.
|
|||||||
|
|
||||||
Open the hosts screen without creating any group.
|
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
|
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no chip on any
|
||||||
between the cards, no UNGROUPED, nothing saying the hosts are unfiled.
|
card, nothing saying the hosts are unfiled.
|
||||||
|
|
||||||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
|
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
|
||||||
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
|
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,
|
**Pass:** every filed card carries the group's name as an accent chip under its address; the cards stay one
|
||||||
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
|
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.
|
||||||
**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.
|
|
||||||
|
|
||||||
**Then press a group card.** The grid narrows to that group's hosts, the card is marked as chosen, and
|
**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.
|
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
|
### 3.3 Deleting a group with hosts in it
|
||||||
|
|
||||||
Select a group with hosts and press DELETE.
|
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
|
**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
|
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
|
||||||
`HostGroupRepository`.
|
`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
|
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.
|
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
|
**Pass:** A shows the host with no chip on its card, as though it were in no group. Open that host's editor:
|
||||||
no longer here)" and *keeps it selected*. Change the port and save.
|
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
|
**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.
|
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
|
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
|
||||||
back.
|
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
|
Make two groups and file a host into one. Drag a host card up onto the other group's card.
|
||||||
inside another group; and onto UNGROUPED.
|
|
||||||
|
|
||||||
**Pass:** whatever is under the pointer washes accent while the pointer is over it, the cursor shows a move
|
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the
|
||||||
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
|
cursor shows a move rather than a refusal, and the drop files the host — the chip on its card changes to the
|
||||||
headings change. Dropping onto its own group's heading is refused while still in the air.
|
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
|
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the
|
||||||
automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSelected`.
|
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
|
### 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
|
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click the space
|
||||||
heading.
|
around the cards.
|
||||||
|
|
||||||
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking a
|
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking the
|
||||||
heading folds it and unfolds it again and connects to nothing.
|
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
|
**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.
|
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
|
### 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.
|
**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.
|
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.
|
**Pass:** no menu opens at all, and the host selection has not moved.
|
||||||
|
|
||||||
|
|||||||
@@ -276,9 +276,12 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.grouphead">
|
<!--
|
||||||
<Setter Property="Padding" Value="10,7,10,4" />
|
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
|
||||||
</Style>
|
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. -->
|
<!-- A category in the vault's rail: the same accent strip a selected row carries, at full row height. -->
|
||||||
<Style Selector="Button.cat">
|
<Style Selector="Button.cat">
|
||||||
@@ -661,19 +664,31 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The row a host is about to be dropped on. A wash rather than the accent strip a selection carries,
|
The group card a host is about to be dropped on. A wash rather than the accent strip a selection
|
||||||
because it is not a selection: it says "let go here", it lasts as long as the pointer is over the row,
|
carries, because it is not a selection: it says "let go here", it lasts as long as the pointer is over
|
||||||
and it has to be legible on top of whatever that row already looks like — including the selected row,
|
the card, and it has to be legible on top of whatever that card already looks like — including the
|
||||||
which is often the one being dragged onto.
|
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
|
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
|
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.
|
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
|
||||||
-->
|
-->
|
||||||
<Style Selector="ListBoxItem.droptarget /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBoxItem.droptarget Border.tile">
|
||||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
<Setter Property="BorderThickness" Value="0,0,0,2" />
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -151,8 +151,17 @@
|
|||||||
|
|
||||||
</StackPanel>
|
</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">
|
<StackPanel Margin="16,14" Spacing="16">
|
||||||
|
|
||||||
<!-- ============ GROUPS ============ -->
|
<!-- ============ GROUPS ============ -->
|
||||||
@@ -198,10 +207,18 @@
|
|||||||
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
|
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
|
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.
|
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"
|
<ListBox Classes="tiles" x:Name="GroupGrid"
|
||||||
|
DragDrop.AllowDrop="True"
|
||||||
ItemsSource="{Binding Groups}"
|
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>
|
<ListBox.ItemsPanel>
|
||||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||||
</ListBox.ItemsPanel>
|
</ListBox.ItemsPanel>
|
||||||
@@ -257,15 +274,31 @@
|
|||||||
focus off the terminal's child window and then calls Focus() on something that refuses it,
|
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.
|
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
|
◆ CARDS ONLY, AND NO HEADINGS. It binds VisibleHosts rather than SidebarRows, which is the
|
||||||
reasons, and neither is decoration: a grid showing every group at once has to say which cards
|
collection with the group headings folded into it. Those headings were a full-width fold-away
|
||||||
belong to which, and a heading is the drop target that takes a host out of one group and into
|
bar between the cards — a chevron, a name and a count — and in a wrap of cards they read as a
|
||||||
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
|
collapsed dropdown that had somehow been dealt the hosts: the moment a first group existed the
|
||||||
container, and the code-behind for what a drag does with one.
|
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"
|
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
|
||||||
DragDrop.AllowDrop="True"
|
ItemsSource="{Binding VisibleHosts}"
|
||||||
ItemsSource="{Binding SidebarRows}"
|
|
||||||
SelectedItem="{Binding SelectedSidebarRow}">
|
SelectedItem="{Binding SelectedSidebarRow}">
|
||||||
|
|
||||||
<ListBox.ItemsPanel>
|
<ListBox.ItemsPanel>
|
||||||
@@ -279,7 +312,8 @@
|
|||||||
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
|
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
|
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,
|
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>
|
<ListBox.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -290,35 +324,9 @@
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</ListBox.ContextMenu>
|
</ListBox.ContextMenu>
|
||||||
|
|
||||||
<ListBox.DataTemplates>
|
<ListBox.ItemTemplate>
|
||||||
|
|
||||||
<!--
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
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">
|
|
||||||
<Border Classes="tile">
|
<Border Classes="tile">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
@@ -389,6 +397,26 @@
|
|||||||
VerticalAlignment="Top" Margin="6,3,0,0" />
|
VerticalAlignment="Top" Margin="6,3,0,0" />
|
||||||
</Grid>
|
</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
|
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.
|
can wear several and the line above is three fixed-width facts where these are not.
|
||||||
@@ -412,7 +440,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
</ListBox.DataTemplates>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
internal sealed partial class HostsScreen : UserControl
|
internal sealed partial class HostsScreen : UserControl
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
|
/// 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>
|
/// </remarks>
|
||||||
private const double DragThreshold = 5;
|
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>
|
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
|
/// 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;
|
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;
|
private ListBoxItem? marked;
|
||||||
|
|
||||||
public HostsScreen()
|
public HostsScreen()
|
||||||
@@ -78,9 +93,22 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
HostGrid.PointerReleased += OnPointerReleased;
|
HostGrid.PointerReleased += OnPointerReleased;
|
||||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
||||||
|
|
||||||
DragDrop.AddDragOverHandler(HostGrid, OnDragOver);
|
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
|
||||||
DragDrop.AddDragLeaveHandler(HostGrid, OnDragLeave);
|
// same control: the target was a heading among the cards, and with the headings gone the group cards
|
||||||
DragDrop.AddDropHandler(HostGrid, OnDrop);
|
// 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>
|
/// <remarks>
|
||||||
@@ -117,9 +145,9 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnHostActivated(object? sender, TappedEventArgs e)
|
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
|
// Only over a card. A double-tap on the space around them must not connect to whichever host was
|
||||||
// connect to whichever host was selected before — which is what an unguarded handler would do, on a
|
// selected before — which is what an unguarded handler would do, on a machine the user is not even
|
||||||
// machine the user is not even pointing at.
|
// pointing at.
|
||||||
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
||||||
{
|
{
|
||||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
@@ -137,9 +165,9 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// against.
|
/// against.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Cancelled outright over a group heading and over the space around the cards. Neither is a host, and a
|
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
|
||||||
/// menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or act
|
/// Edit and Delete over it would be three buttons that either do nothing or act on something else
|
||||||
/// on something else entirely.
|
/// entirely.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
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.
|
/// Says whether what is under the pointer would take this host, and marks it if it would.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
|
/// A host over the card of the group it is already in is refused, which is not pedantry:
|
||||||
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
|
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
|
||||||
/// does nothing is worse than one that says so while it is still in the air.
|
/// would do something and then does nothing is worse than one that says so while it is still in the air.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnDragOver(object? sender, DragEventArgs e)
|
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();
|
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>
|
/// <remarks>
|
||||||
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A heading is the obvious target and a card is the useful one: dropping onto a machine files the
|
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
|
||||||
/// dragged host beside it, which means the whole band of cards under a heading is a target rather than
|
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
|
||||||
/// one line of text. The ungrouped heading is a target like any other, and it is how a host is taken out
|
/// dropped on is what it says on it. The space around the cards takes nothing.
|
||||||
/// of a group without opening the editor.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
|
||||||
/// rather than here, so the rule has one home.
|
/// rather than here, so the rule has one home.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -293,25 +374,13 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
private static DropTarget? Target(DragEventArgs e)
|
private static DropTarget? Target(DragEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid? group = container.DataContext switch
|
return new DropTarget(dragged, group.EntityId, container);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Mark(ListBoxItem container)
|
private void Mark(ListBoxItem container)
|
||||||
@@ -353,5 +422,10 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
/// <summary>A drag in flight, and where it would land.</summary>
|
/// <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>
|
/// <summary>Whether this row has a vault to name.</summary>
|
||||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
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;
|
internal HostSecret Host => host.Secret;
|
||||||
|
|
||||||
/// <summary>
|
/// <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 "
|
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||||
+ "~/.ssh/config from Preferences.",
|
+ "~/.ssh/config from Preferences.",
|
||||||
(_, not null, 0) =>
|
(_, not null, 0) =>
|
||||||
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
|
"Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's "
|
||||||
+ "group in a host's own editor.",
|
+ "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.",
|
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2396,6 +2421,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
// Only when there is something to tell apart. A badge on every row of a
|
// 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.
|
// single-vault list is noise that says the same thing on all of them.
|
||||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2712,6 +2743,17 @@ internal sealed partial class VaultViewModel(
|
|||||||
.OrderBy(label => label, StringComparer.CurrentCulture),
|
.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>
|
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
||||||
private void RebuildGroups()
|
private void RebuildGroups()
|
||||||
{
|
{
|
||||||
@@ -2895,10 +2937,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
|
/// What dragging a host card onto a group card does, and the only thing in this application that changes
|
||||||
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
|
/// a host without opening the editor. That is the justification for it existing at all: filing thirty
|
||||||
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
|
/// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one
|
||||||
/// host that is about arrangement rather than about the machine.
|
/// field of a host that is about arrangement rather than about the machine.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
|
/// 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.Controls;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
@@ -127,30 +128,85 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A heading is an item in the same list and the control will happily select it, but it is not a host —
|
/// <para>
|
||||||
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
|
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
|
||||||
/// or act on a machine somewhere else in the grid.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
|
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheGridAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
var selected = Row(vault, "prod-db");
|
var selected = Row(vault, "prod-db");
|
||||||
vault.SelectedHost = selected;
|
vault.SelectedHost = selected;
|
||||||
|
|
||||||
var heading = screen.HostGrid
|
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
|
||||||
.GetVisualDescendants()
|
{
|
||||||
.OfType<ListBoxItem>()
|
RoutedEvent = Control.ContextRequestedEvent,
|
||||||
.First(item => item.DataContext is SidebarGroupHeader);
|
Source = screen.HostGrid,
|
||||||
|
});
|
||||||
RightClick(heading, window);
|
|
||||||
|
|
||||||
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
||||||
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
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>
|
/// <remarks>
|
||||||
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
|
/// 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
|
/// 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 ----
|
// ---- 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)
|
private static void RightClick(Visual row, Visual window)
|
||||||
{
|
{
|
||||||
var at = Centre(row, 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>
|
/// <remarks>
|
||||||
/// Headings are items in the same list as the cards, drawn from a different template, and they span a
|
/// <para>
|
||||||
/// whole row of the wrap rather than sitting in the flow as another card. Measured with one group
|
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
|
||||||
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
|
/// — 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheHostsScreenFitsWithGroupHeadingsInTheGrid()
|
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
|
||||||
{
|
{
|
||||||
await SeedGroupsAsync(3);
|
await SeedGroupsAsync(3);
|
||||||
|
|
||||||
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
|
foreach (var host in vault.Hosts.ToArray())
|
||||||
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
|
{
|
||||||
|
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>
|
/// <remarks>
|
||||||
|
|||||||
@@ -3584,9 +3584,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// What dragging a row onto a heading does. It is the same write the editor makes — one field of the
|
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
|
||||||
/// host, pushed straight away — reached without opening a form, because filing thirty imported machines
|
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
|
||||||
/// through the editor is thirty rounds of open, pick, save.
|
/// machines through the editor is thirty rounds of open, pick, save.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
|
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
|
||||||
@@ -3605,15 +3605,21 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
||||||
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
|
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>()
|
vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||||
.Single(header => header.GroupId == group)
|
.Single(header => header.GroupId == group)
|
||||||
.Count.ShouldBe(1);
|
.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));
|
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
|
||||||
|
|
||||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
||||||
|
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -3670,9 +3676,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Groups.ShouldBeEmpty();
|
vault.Groups.ShouldBeEmpty();
|
||||||
vault.HasGroups.ShouldBeFalse();
|
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.Hosts.Single().Host.GroupId.ShouldBe(groupId);
|
||||||
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
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>
|
/// <remarks>
|
||||||
|
|||||||
Reference in New Issue
Block a user