Public Access
Compare commits
2
Commits
7b616e0bb0
...
d8cf16fb46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf16fb46 | ||
|
|
507cd9ff88 |
@@ -882,6 +882,23 @@ the drop are covered by nothing. What *is* automated is the decision each drag e
|
||||
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
|
||||
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom`.
|
||||
|
||||
### 7.6a Dragging a whole set onto a group card · **also uncovered, and the same reason**
|
||||
|
||||
Tick three hosts (7.7a is how), then pick one of the three up and drag it onto a group card.
|
||||
|
||||
**Pass:** the card marks itself exactly as it does for one host, and the drop files **all three** — the
|
||||
status line says how many, and all three leave the level. Ticking nothing and dragging a single card still
|
||||
files that one card, which is what this gesture has always done.
|
||||
|
||||
**And a card that is not in the set** drags alone: press one of the unticked cards and the ticks come off
|
||||
before the drag starts, so what lands is the one machine that was under the pointer.
|
||||
|
||||
**Failure means:** a drag that filed one of three is the payload having been built from the card rather than
|
||||
from the set — the thing this gesture must never do quietly, since the other two stay behind looking filed.
|
||||
The write is `ShellFlowTests.DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem` and the drag event's
|
||||
answer is `HostGridTests.AGroupCardTakesAWholeSetOfDraggedHosts`; the platform's half of it is covered by
|
||||
nothing, as 7.6 explains.
|
||||
|
||||
### 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 the space
|
||||
@@ -897,6 +914,33 @@ double-tap handler has lost its check that the pointer was over a card.
|
||||
**And the same two gestures on the group cards above**, where they mean select and open rather than select
|
||||
and connect — the space around *those* cards must open nothing, for the same reason. See 3.2.
|
||||
|
||||
### 7.7a Choosing several cards with Ctrl, Shift and a band · **the desktop's half of the phone's set**
|
||||
|
||||
With four or more hosts in the grid: Ctrl-click two cards, Shift-click a third, then drag a rectangle out
|
||||
over the cards starting from the empty space below them. Finally press Esc, and try Ctrl+A.
|
||||
|
||||
**Pass:** each ticked card takes a ✓ beside its status dot and a filled surface, and the strip beside the
|
||||
HOSTS heading counts them. A Ctrl-click does **not** move the selection — whichever card was lit before
|
||||
stays lit, and the drawer, if it is open, goes on showing the same machine. Shift ticks the whole run
|
||||
between the anchor and the card, in the order the cards are laid out. The band draws an accent rectangle
|
||||
that follows the pointer and ticks every card it *touches*, unticking them again as it is pulled back off.
|
||||
Ctrl+A ticks every card being drawn — with something in the find box, that is the ones on screen and not the
|
||||
ones it is hiding. Esc, CLEAR, a plain click on a card and a click on the empty space each drop the set.
|
||||
|
||||
**And the menu is about the set while one is up:** right-click a ticked card and the entries are Connect,
|
||||
Browse files and Edit… only while exactly one is ticked, then Change group…, Move to another vault…, Copy to
|
||||
another vault…, Duplicate and Delete… — the last naming the count. Right-clicking a card **outside** the set
|
||||
drops the set and gives the ordinary four back, aimed at that card.
|
||||
|
||||
**Also check the band does not start** on the scrollbar, on a group card, or inside a panel above the grid,
|
||||
and that dragging the scrollbar still scrolls.
|
||||
|
||||
**Failure means:** a Ctrl-click that also selects is the press not being handled on the way down, and it
|
||||
puts the drawer on a machine somebody is removing from a set. A menu offering both halves at once is the
|
||||
`IsVisible` bindings in `HostsScreen.axaml` having come apart, which is how the wrong machine gets deleted.
|
||||
`HostGridTests` drives all of these headlessly; what it cannot see is the rectangle actually being painted
|
||||
and the two marks being legible together, which is what this check is for.
|
||||
|
||||
### 7.8 The highlight clears after a drag that goes nowhere
|
||||
|
||||
Drag a host over a group card and release outside the grid, or press Escape mid-drag.
|
||||
|
||||
@@ -647,6 +647,23 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A CARD IN THE CHOSEN SET, which is not the same thing as the selected one.
|
||||
|
||||
The list holds one selection — the card the drawer and CONNECT are about — and the set is what Ctrl,
|
||||
Shift and a band dragged across the grid build on top of it. Six ticked cards with one of them also
|
||||
selected is the ordinary shape, so the two marks have to be legible together and legible apart.
|
||||
|
||||
The fill and the tick on the card are the mark; the border is deliberately left to the rule above. A
|
||||
chosen card that also drew an accent outline would be indistinguishable from the selected one, and the
|
||||
question "which of these is the drawer showing" would have no answer. Declared after that rule anyway,
|
||||
because it sets the same Background and Avalonia settles two matching rules by declaration order —
|
||||
the trap this file records for Button.tab, Border.rowmark and the drop target below.
|
||||
-->
|
||||
<Style Selector="Border.tile.chosen">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
</Style>
|
||||
|
||||
<!-- The square a tile carries on its left: a group's mark, or a host's prompt. -->
|
||||
<Style Selector="Border.tileicon">
|
||||
<Setter Property="Width" Value="32" />
|
||||
|
||||
@@ -377,12 +377,164 @@
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOSTS"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
============ ◆ WHAT SAYS A SET IS UP ============
|
||||
Ctrl, Shift and a band dragged across the grid tick cards — see HostsScreen.axaml.cs — and
|
||||
this is the whole of the chrome that arrives with them: how many, the way out, and where the
|
||||
actions are. The phone raises a contextual action bar in the vault header's place for the
|
||||
same state; a window with room for a context menu does not need one, and a strip of eight
|
||||
buttons over the grid would be a second home for entries the menu already has.
|
||||
|
||||
◆ THE MENU IS WHERE THE ACTIONS ARE, and the sentence says so because there is no other way
|
||||
to find that out. Everything else on this screen can be reached by looking at it.
|
||||
|
||||
A Grid rather than a horizontal StackPanel, for the reason the host card carries at length: a
|
||||
horizontal StackPanel measures its children with infinite width, so the sentence would never
|
||||
learn it is short of room and would run out over the count at the far end instead of
|
||||
trimming. At the window's minimum with the drawer open there is not room for all of it.
|
||||
-->
|
||||
<Grid Grid.Column="1" Margin="12,0" ColumnDefinitions="Auto,Auto,*"
|
||||
IsVisible="{Binding IsChoosingHosts}">
|
||||
<Border Grid.Column="0" Classes="chip accent" Padding="6,1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding ChosenHostsSummary}" FontSize="9.5" />
|
||||
</Border>
|
||||
<Button Grid.Column="1" Classes="flat" Margin="6,0,0,0" Padding="5,1" FontSize="9.5"
|
||||
Content="CLEAR" Command="{Binding ClearHostChoiceCommand}"
|
||||
ToolTip.Tip="Takes the ticks off every card · Esc, or a click on the space around them" />
|
||||
<TextBlock Grid.Column="2" Classes="hint" FontSize="10" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
Text="right-click one of them for what can be done to all of them" />
|
||||
</Grid>
|
||||
|
||||
<!-- The collection's own count, so it follows both filters with no second number to keep in step. -->
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding VisibleHosts.Count}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ ◆ WHAT THE SET'S MENU ASKED ============
|
||||
Three panels, at most one of them up, all three above the grid rather than over it — the same
|
||||
arrangement the GROUPS section uses and the phone's list uses, for the reason written there: a
|
||||
card laid over the cards hides the ticked ones, and which hosts are ticked is the information
|
||||
the question exists to give. The grid shortens instead.
|
||||
|
||||
Which of the three is showing is decided in the view model, because each disarms the other two
|
||||
on the way up. See MoveChosenHostsToVault, RegroupChosenHosts and DeleteChosenHosts.
|
||||
-->
|
||||
|
||||
<!--
|
||||
◆ MOVING OR COPYING THEM TO ANOTHER VAULT. One panel for both verbs, because they differ in one
|
||||
word and in the sentence under the picker; what varies is bound rather than duplicated. See
|
||||
VaultViewModel.ChosenHostVaultPanelTitle and its two siblings.
|
||||
|
||||
The sentence is not decoration: groups and tags are items of the vault being left, so neither
|
||||
can come along, and a host that arrived carrying either would point at something the
|
||||
destination does not contain.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
IsVisible="{Binding IsSendingChosenHostsToAVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="label"
|
||||
Text="{Binding ChosenHostVaultPanelTitle}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding ChosenHostsSummary}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostVaultChoices}"
|
||||
SelectedItem="{Binding SelectedChosenHostVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="{Binding ChosenHostVaultPanelNote}" />
|
||||
|
||||
<!--
|
||||
◆ THE KEY, and only for a move of exactly one host — which key to carry is a fact about one
|
||||
machine, and a copy that took it away would leave the original unable to connect. Unticked,
|
||||
because moving a key into a team's vault hands it to everybody holding that vault's key.
|
||||
See VaultViewModel.BringsTheChosenBindingAlong.
|
||||
-->
|
||||
<CheckBox IsVisible="{Binding HasAChosenBindingToBring}"
|
||||
IsChecked="{Binding BringsTheChosenBindingAlong}">
|
||||
<TextBlock FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding ChosenBindingToBringQuestion}" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasAChosenBindingToBring}"
|
||||
Text="{Binding ChosenBindingToBringNote}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="{Binding ChosenHostVaultPanelVerb}"
|
||||
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ FILING THEM UNDER A GROUP, which is what dragging the set onto a group card does without the
|
||||
picker. Both exist for the reason the single-host gesture and the host editor both do: a
|
||||
gesture is unreachable without a pointer, and a card that has scrolled out of sight cannot be
|
||||
dragged onto.
|
||||
|
||||
One keychain's groups. A selection spanning two is refused before this panel is drawn at all —
|
||||
see VaultViewModel.RegroupChosenHosts, which is the same refusal the drop makes.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
IsVisible="{Binding IsRegroupingChosenHosts}">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="CHANGE GROUP" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Margin="8,0,0,0" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding ChosenHostsSummary}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding ChosenHostGroupChoices}"
|
||||
SelectedItem="{Binding SelectedChosenHostGroup}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="FILE"
|
||||
Command="{Binding ConfirmRegroupChosenHostsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelRegroupChosenHostsCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE DELETION QUESTION, naming a count rather than a machine — which is the reason
|
||||
DeletionTarget.ChosenHosts exists: six copies of "delete prod-db?" is not a confirmation
|
||||
anybody reads. The shared card draws it, as it does for a group above and for one host in the
|
||||
drawer; see ConfirmDeleteCard.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
|
||||
IsVisible="{Binding IsConfirmingChosenHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
every gesture on it is wired in the code-behind.
|
||||
@@ -426,7 +578,8 @@
|
||||
-->
|
||||
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
SelectedItem="{Binding SelectedSidebarRow}"
|
||||
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
@@ -449,13 +602,53 @@
|
||||
already selected whatever was right-clicked, which is exactly what OpenHostPane falls back
|
||||
to.
|
||||
-->
|
||||
<!--
|
||||
◆ TWO MENUS IN ONE, AND WHICH OF THEM IS DRAWN IS WHETHER ANYTHING IS TICKED.
|
||||
|
||||
Once Ctrl, Shift or a band has ticked cards, this menu is about the set and nothing else —
|
||||
and it has to be, because the entries above act on the vault's *selection*, which is one
|
||||
host. A Delete… that asked about the card under the pointer while six sat ticked behind the
|
||||
menu is the exact mistake the whole of OnContextRequested exists to prevent, arrived at from
|
||||
the other direction. Right-clicking a card that is not in the set drops the set first, so
|
||||
these entries and the ones above are never both about something; see the code-behind.
|
||||
|
||||
This is the only home for the seven, which is why it says what each of them takes: a set from
|
||||
two keychains, a set with a host in it that a newer version wrote, and a set of one are three
|
||||
different answers. The three single-host entries are collapsed rather than greyed for the
|
||||
reason the phone's sheet collapses them — a terminal, an SFTP session and a form are each
|
||||
about one machine, and there is no reading of "edit these six".
|
||||
|
||||
Delete is under a separator, as it is above and as the phone's sheet has it.
|
||||
-->
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
|
||||
<MenuItem Header="Details…" Command="{Binding OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
|
||||
<MenuItem Header="Connect" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding ConnectCommand}" />
|
||||
<MenuItem Header="Details…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding EditSelectedHostCommand}" />
|
||||
<Separator IsVisible="{Binding !IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !IsChoosingHosts}"
|
||||
Command="{Binding DeleteHostCommand}" />
|
||||
|
||||
<MenuItem Header="Connect" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding ConnectToChosenHostCommand}" />
|
||||
<MenuItem Header="Browse files" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding BrowseChosenHostCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding EditChosenHostCommand}" />
|
||||
<MenuItem Header="Change group…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding RegroupChosenHostsCommand}" />
|
||||
<MenuItem Header="Move to another vault…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding MoveChosenHostsToVaultCommand}" />
|
||||
<MenuItem Header="Copy to another vault…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding CopyChosenHostsToVaultCommand}" />
|
||||
<MenuItem Header="Duplicate" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding DuplicateChosenHostsCommand}" />
|
||||
<Separator IsVisible="{Binding IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding IsChoosingHosts}"
|
||||
Command="{Binding DeleteChosenHostsCommand}" />
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
|
||||
@@ -466,7 +659,15 @@
|
||||
The address is on the tooltip and nowhere else on the card. It used to be the subtitle;
|
||||
see the note on the summary line below for what took its place and why it is not lost.
|
||||
-->
|
||||
<Border Classes="tile" ToolTip.Tip="{Binding Address}">
|
||||
<!--
|
||||
◆ Classes.chosen is the tick on the card, and it is a class rather than the list's own
|
||||
selection because they are two different things: the list holds one card, which is what
|
||||
the drawer and CONNECT are about, and the set is what Ctrl, Shift and the band build. A
|
||||
card can be in the set without being the selected one, which is what a run of six looks
|
||||
like. See VaultViewModel.ChooseHosts and Border.tile.chosen in App.axaml.
|
||||
-->
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<!--
|
||||
A Panel so the pencil below can be drawn *over* the card rather than in a column of
|
||||
its own. A column would have cost the name 30 pixels of a 232-pixel tile — permanently,
|
||||
@@ -543,9 +744,21 @@
|
||||
now; grey means there is not one. It is deliberately not reachability — nothing
|
||||
here pings anything, and a dot that meant "up" would be a claim this application
|
||||
never checks.
|
||||
|
||||
◆ The tick sits beside it rather than in place of it, which is the arrangement
|
||||
the phone's row uses and for the same reason: the dot says whether a shell is
|
||||
open on this machine, and swapping it for the tick would make choosing a host
|
||||
hide a fact about it. It takes no width while it is collapsed, so a grid with
|
||||
nothing ticked is laid out exactly as it was.
|
||||
-->
|
||||
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
VerticalAlignment="Top" Margin="6,3,0,0" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Top" Margin="6,2,0,0">
|
||||
<TextBlock Text="✓" FontSize="11" IsVisible="{Binding IsChosen}"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
VerticalAlignment="Center" Margin="0,1,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -625,6 +838,26 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
◆ THE BAND, drawn over the scroller rather than inside it.
|
||||
|
||||
It is a rectangle the pointer is dragging out right now, so it belongs in the viewport's coordinates
|
||||
and not in the scrolling content's: a band drawn inside the stack would slide away from the pointer
|
||||
the moment the grid scrolled under it. Same row as the ScrollViewer and after it, which is what puts
|
||||
it on top — a Grid cell stacks its children in declaration order.
|
||||
|
||||
IsHitTestVisible="False" is the whole of why the gesture works while it is up. The band is under the
|
||||
pointer by definition, and a rectangle that took the pointer would end the drag it is drawing.
|
||||
|
||||
Positioned by Margin from the top left rather than in a Canvas, because the two alignments below make
|
||||
the margin mean exactly "where the corner is"; see HostsScreen.axaml.cs, which is the only thing that
|
||||
writes to it.
|
||||
-->
|
||||
<Border Grid.Row="3" x:Name="Band" IsVisible="False" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="1" CornerRadius="2" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
@@ -17,6 +18,15 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
|
||||
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
|
||||
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
|
||||
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
|
||||
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
|
||||
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
|
||||
/// different.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
|
||||
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
|
||||
/// beside the grid inherits the same one.
|
||||
@@ -25,21 +35,30 @@ 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 group card it is dropped on.
|
||||
/// How hosts travel from the cards they were picked up on to the group card they are dropped on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
|
||||
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
|
||||
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
|
||||
/// <para>
|
||||
/// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
|
||||
/// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
|
||||
/// drop needs: they know which vault each edit has to return to, which an id on its own does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
|
||||
/// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
|
||||
/// a gesture that quietly does a fraction of what it looks like it does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
|
||||
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
|
||||
/// those into a drag gesture the user never asked for.
|
||||
/// those into a drag gesture the user never asked for. The band on the empty space between the cards
|
||||
/// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
|
||||
/// flashed up on every one of those would be a gesture reported where none happened.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 5;
|
||||
|
||||
@@ -66,13 +85,49 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private PointerPressedEventArgs? press;
|
||||
|
||||
private HostRowViewModel? pickedUp;
|
||||
/// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
|
||||
private IReadOnlyList<HostRowViewModel> pickedUp = [];
|
||||
|
||||
private Point origin;
|
||||
|
||||
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
||||
private ListBoxItem? marked;
|
||||
|
||||
/// <summary>
|
||||
/// Where a Shift-click measures its run from: the last card pressed without one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
|
||||
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
|
||||
/// to the selected card for.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? anchor;
|
||||
|
||||
/// <summary>
|
||||
/// The ticked card a plain press landed on, which the release collapses the set onto.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
|
||||
/// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
|
||||
/// was an ordinary click and means what a click always means here — this one card, and nothing else. If
|
||||
/// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? collapse;
|
||||
|
||||
/// <summary>Whether a band is being dragged out over the grid right now.</summary>
|
||||
private bool banding;
|
||||
|
||||
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
|
||||
private Point bandFrom;
|
||||
|
||||
/// <summary>Whether that band adds to the set rather than being the whole of it.</summary>
|
||||
/// <remarks>
|
||||
/// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag
|
||||
/// would change what the rectangle already crossed means, which is a selection nobody could predict.
|
||||
/// </remarks>
|
||||
private bool bandAdds;
|
||||
|
||||
public HostsScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -92,7 +147,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
||||
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
||||
// what is selected, and the menu is about to open against it.
|
||||
// what is selected, and the menu is about to open against it. It is also what lets a modifier click
|
||||
// stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
|
||||
// moving its own selection onto it.
|
||||
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
@@ -105,6 +162,8 @@ internal sealed partial class HostsScreen : UserControl
|
||||
HostGrid.PointerReleased += OnPointerReleased;
|
||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
||||
|
||||
WireTheBand();
|
||||
|
||||
// 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
|
||||
@@ -123,6 +182,31 @@ internal sealed partial class HostsScreen : UserControl
|
||||
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
||||
}
|
||||
|
||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||
/// <remarks>
|
||||
/// Split out of the constructor rather than sitting in it, and only because the constructor is at the
|
||||
/// length this repository's analyser allows for one. Everything here arrived together: it is the whole
|
||||
/// of choosing more than one card with a pointer.
|
||||
/// </remarks>
|
||||
private void WireTheBand()
|
||||
{
|
||||
// Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
|
||||
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
|
||||
// in it. The grid takes focus on a press, so the keys work from the moment anything has been
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
HostGrid.KeyDown += OnGridKey;
|
||||
|
||||
// ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
|
||||
// mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
|
||||
// the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
|
||||
// reason the list's own press is, and it runs first, so it has to recognise a press on a card and
|
||||
// leave it alone.
|
||||
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
|
||||
Scroll.PointerMoved += OnBandMoved;
|
||||
Scroll.PointerReleased += OnBandReleased;
|
||||
Scroll.PointerCaptureLost += OnBandCaptureLost;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Null before the window has handed one over, and while the previewer is showing this control with no
|
||||
/// data context at all. Every handler below checks rather than assuming.
|
||||
@@ -187,15 +271,20 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
|
||||
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
|
||||
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
|
||||
/// against.
|
||||
/// The menu's commands read the vault's host selection, and a right click does not move it — which would
|
||||
/// mean a menu that quietly acted on whichever host happened to be selected instead of the one under the
|
||||
/// pointer. Deleting the wrong machine is the version of that mistake worth designing against.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>A right click outside the ticked set drops the set, and one inside it keeps it.</b> That is the
|
||||
/// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from
|
||||
/// one markup: the entries about a set and the entries about a selection are never both meaningful, so
|
||||
/// Delete… can never be a question about the card under the pointer asked while six others sit ticked
|
||||
/// behind the menu.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
@@ -206,7 +295,15 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedSidebarRow = row;
|
||||
if (vault.IsChoosingHosts && !row.IsChosen)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
if (!vault.IsChoosingHosts)
|
||||
{
|
||||
vault.SelectedSidebarRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -236,23 +333,84 @@ internal sealed partial class HostsScreen : UserControl
|
||||
vault.SelectedGroup = row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks cards, or remembers a press that may become a drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
|
||||
/// until the pointer moves, so this is the point at which both are still possible.
|
||||
/// <para>
|
||||
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
||||
/// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
|
||||
/// that also selected would light the card it had just unticked, and the drawer would open on a machine
|
||||
/// the user was removing from a set. Handled on the way down is the only place that can be said —
|
||||
/// by the time the press bubbles the control has already answered it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
|
||||
/// file manager has and the reason the two selections on this screen can never disagree: after an
|
||||
/// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
|
||||
/// to the release, because the press may be the start of a drag of the whole set; see
|
||||
/// <see cref="collapse"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
|
||||
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
|
||||
/// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// things that can then be done to a set each refuse for themselves, and say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
press = null;
|
||||
pickedUp = null;
|
||||
Forget();
|
||||
|
||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
if (Vault is not { } vault
|
||||
|| !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var control = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
|
||||
|
||||
HostGrid.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (control)
|
||||
{
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
HostGrid.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
anchor = row;
|
||||
|
||||
// Read before anything is cleared, since clearing is one of the two things it decides.
|
||||
if (row.IsChosen)
|
||||
{
|
||||
collapse = row;
|
||||
pickedUp = vault.ChosenHosts;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
pickedUp = [row];
|
||||
}
|
||||
|
||||
press = e;
|
||||
pickedUp = row;
|
||||
origin = e.GetPosition(HostGrid);
|
||||
}
|
||||
|
||||
@@ -263,7 +421,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (press is not { } pressed || pickedUp is not { } row)
|
||||
if (press is not { } pressed || pickedUp.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -281,20 +439,232 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var carried = pickedUp;
|
||||
|
||||
Forget();
|
||||
|
||||
_ = DragAsync(pressed, row);
|
||||
_ = DragAsync(pressed, carried);
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
|
||||
/// <remarks>
|
||||
/// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
|
||||
/// what every other click on a card means. See <see cref="collapse"/>.
|
||||
/// </remarks>
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (collapse is not null && Vault is { } vault)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
Forget();
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
|
||||
|
||||
/// <summary>Carries one host for as long as the user holds it.</summary>
|
||||
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
|
||||
/// <summary>
|
||||
/// Esc drops the set, and Ctrl+A is every card on the screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
|
||||
/// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
|
||||
/// including the thirty-two not being shown would be the worst possible input to Delete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
|
||||
/// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it
|
||||
/// away from whichever of those the user meant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnGridKey(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.Escape && vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
|
||||
{
|
||||
vault.ChooseHosts(vault.VisibleHosts, replacing: true);
|
||||
anchor = vault.VisibleHosts[0];
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a band on the empty space between and below the cards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
|
||||
/// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
|
||||
/// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
|
||||
/// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
|
||||
/// above it moves the ceiling with no code here changing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
|
||||
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
|
||||
/// nothing left to take it down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click
|
||||
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
EndBand();
|
||||
|
||||
if (Vault is null
|
||||
|| !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
|
||||
|| TakesThePress(e.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
banding = true;
|
||||
bandFrom = at;
|
||||
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
e.Pointer.Capture(Scroll);
|
||||
HostGrid.Focus();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is
|
||||
/// over at that moment — including the cards it has just been pulled back off. A band that only ever
|
||||
/// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is
|
||||
/// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how
|
||||
/// a second run is picked up without losing the first.
|
||||
/// </remarks>
|
||||
private void OnBandMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!banding || Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
EndBand();
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
var box = new Rect(
|
||||
Math.Min(bandFrom.X, at.X),
|
||||
Math.Min(bandFrom.Y, at.Y),
|
||||
Math.Abs(at.X - bandFrom.X),
|
||||
Math.Abs(at.Y - bandFrom.Y));
|
||||
|
||||
if (box.Width < DragThreshold && box.Height < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Band.Margin = new Thickness(box.X, box.Y, 0, 0);
|
||||
Band.Width = box.Width;
|
||||
Band.Height = box.Height;
|
||||
Band.IsVisible = true;
|
||||
|
||||
vault.ChooseHosts(CardsIn(box), replacing: !bandAdds);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A press and a release with no band between them is a click on the space around the cards, and that
|
||||
/// drops the set — the same thing a plain click on a card does, and the way out of selection mode for
|
||||
/// anybody who never finds Esc. Where a band <em>was</em> drawn the moves have already said what is
|
||||
/// ticked, and re-applying it here would only repeat the last one.
|
||||
/// </remarks>
|
||||
private void OnBandReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!banding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var drawn = Band.IsVisible;
|
||||
|
||||
EndBand();
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
if (!drawn && Vault is { IsChoosingHosts: true } vault)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when
|
||||
/// the window is deactivated, and when the platform simply takes it — none of which is the user saying
|
||||
/// what they wanted; leaving the rectangle up would be the only visible consequence.
|
||||
/// </remarks>
|
||||
private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand();
|
||||
|
||||
private void EndBand()
|
||||
{
|
||||
banding = false;
|
||||
Band.IsVisible = false;
|
||||
}
|
||||
|
||||
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
|
||||
/// <remarks>
|
||||
/// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
|
||||
/// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
|
||||
/// the run, and would tick nothing at all for a band drawn down the middle of a column.
|
||||
/// </remarks>
|
||||
private List<HostRowViewModel> CardsIn(Rect box)
|
||||
{
|
||||
var hit = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var container in HostGrid.GetRealizedContainers())
|
||||
{
|
||||
if (container.DataContext is HostRowViewModel row
|
||||
&& container.TranslatePoint(default, Scroll) is { } corner
|
||||
&& box.Intersects(new Rect(corner, container.Bounds.Size)))
|
||||
{
|
||||
hit.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
|
||||
/// <remarks>
|
||||
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
|
||||
/// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
|
||||
/// tick everything it passed.
|
||||
/// </remarks>
|
||||
private static bool TakesThePress(object? source) => source is Visual visual
|
||||
&& visual.GetSelfAndVisualAncestors().Any(element =>
|
||||
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
|
||||
|
||||
/// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
|
||||
private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, row));
|
||||
carried.Add(DataTransferItem.Create(HostFormat, rows));
|
||||
|
||||
try
|
||||
{
|
||||
@@ -313,12 +683,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 these hosts, and marks it if it would.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A host over the card of the group it is already in is refused, which is not pedantry:
|
||||
/// A drag whose hosts are all already in the group under the pointer 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 set with even one host from somewhere else is accepted, because filing that one is a real move.
|
||||
/// </remarks>
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
@@ -392,9 +763,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
|
||||
/// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
|
||||
/// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
|
||||
/// a group belongs to one vault — and reports a count rather than forty status lines in a row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// task nothing observes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
@@ -408,7 +787,14 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
|
||||
|
||||
if (target.Hosts.Count > 1)
|
||||
{
|
||||
vault.FileChosenHostsUnderCommand.Execute(target.Group);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -428,14 +814,14 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private static DropTarget? Target(DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
|
||||
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
||||
|| dragged.Host.GroupId == group.EntityId)
|
||||
|| dragged.All(row => row.Host.GroupId == group.EntityId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropTarget(dragged, group.EntityId, container);
|
||||
return new DropTarget(dragged, group, container);
|
||||
}
|
||||
|
||||
private void Mark(ListBoxItem container)
|
||||
@@ -461,7 +847,8 @@ internal sealed partial class HostsScreen : UserControl
|
||||
private void Forget()
|
||||
{
|
||||
press = null;
|
||||
pickedUp = null;
|
||||
pickedUp = [];
|
||||
collapse = null;
|
||||
}
|
||||
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
||||
@@ -478,9 +865,11 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
/// <summary>A drag in flight, and where it would land.</summary>
|
||||
/// <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.
|
||||
/// The group is the card rather than its id, which it was while only one host could be dragged: the
|
||||
/// command that files a set takes the card, because the refusal it makes is about which vault the group
|
||||
/// is in. The way out of a group is still 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);
|
||||
private sealed record DropTarget(
|
||||
IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
|
||||
}
|
||||
|
||||
@@ -2384,7 +2384,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal bool ShowsAddButton => !AnEditorIsOpen && !IsChoosingHosts && !AChosenHostPanelIsOpen;
|
||||
|
||||
// ---- ◆ The phone's chosen hosts ----
|
||||
// ---- ◆ The chosen hosts ----
|
||||
//
|
||||
// ◆ BOTH HEADS, AND IT WAS THE PHONE'S ALONE. The set below is unchanged by that: what a head brings is
|
||||
// the gesture that fills it and the furniture that acts on it. The phone has a long press, a contextual
|
||||
// action bar and a sheet of seven entries; the desktop has Ctrl, Shift, a band dragged over the grid and
|
||||
// one context menu — see HostsScreen.axaml.cs there, and ChooseHosts below, which is the entry those
|
||||
// gestures use. Everything about *which hosts* and *what happens to them* is here, once.
|
||||
//
|
||||
// THE CONNECT BAR WAS HERE, AND WHAT REPLACED IT IS A SELECTION RATHER THAN A PANEL.
|
||||
//
|
||||
@@ -2418,13 +2424,33 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private readonly HashSet<Guid> chosenHostIds = [];
|
||||
|
||||
/// <summary>Which hosts the deletion question on screen is about.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The question names a count and the run that answers it reads the set again, so the two have to be
|
||||
/// the same set.</b> Nothing kept them together: the panel is drawn <em>above</em> the list on both heads
|
||||
/// rather than over it — deliberately, so the ticked rows can be seen while the question is answered —
|
||||
/// which leaves every one of them still able to be ticked and unticked. One more tick between "Delete
|
||||
/// these 6 hosts?" and pressing DELETE deleted seven, and the desktop's Ctrl-click and band made that a
|
||||
/// second's work rather than a deliberate act.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Compared rather than frozen, and the question is dropped rather than the set: what somebody has just
|
||||
/// chosen is what they meant, and a question they have already read is not. It also covers the case
|
||||
/// nobody performs — a colleague's deletion arriving mid-question and shrinking the set under it. Ids,
|
||||
/// for the reason <see cref="chosenHostIds"/> holds ids.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly HashSet<Guid> deletionAskedAbout = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone is in selection mode: a long press has chosen at least one host.
|
||||
/// Whether at least one host is ticked: a long press on the phone, a Ctrl-click or a band on the desktop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the set being non-empty rather than being a flag beside it, so the mode and its contents
|
||||
/// cannot disagree. Unticking the last host leaves selection mode, which is what every Android list does
|
||||
/// and is the second way out of it — the other being the cross at the left of the bar.
|
||||
/// cannot disagree. Unticking the last host leaves selection mode, which is what every list of either
|
||||
/// kind does and is the second way out of it — the others being the cross at the left of the phone's bar,
|
||||
/// and CLEAR, Esc or a plain click on the desktop.
|
||||
/// </remarks>
|
||||
internal bool IsChoosingHosts => chosenHostIds.Count > 0;
|
||||
|
||||
@@ -2439,6 +2465,16 @@ internal sealed partial class VaultViewModel(
|
||||
internal string ChosenHostsLabel =>
|
||||
ChosenHostCount.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>The same count with the word on it, which is what the desktop prints.</summary>
|
||||
/// <remarks>
|
||||
/// The bare number above works on the phone because it sits in a bar that is the whole top of the
|
||||
/// screen and can mean nothing else. The desktop has no such bar — the ticks arrive beside a HOSTS
|
||||
/// heading that already has a count of its own at the far end of the same row — so a second bare number
|
||||
/// there would be two numbers about two different things, side by side, neither of them labelled.
|
||||
/// </remarks>
|
||||
internal string ChosenHostsSummary =>
|
||||
$"{ChosenHostCount.ToString(CultureInfo.CurrentCulture)} chosen";
|
||||
|
||||
/// <summary>The chosen hosts, as the rows currently in the list.</summary>
|
||||
/// <remarks>
|
||||
/// Rebuilt per read rather than kept, because the rows it names are replaced on every reload and this is
|
||||
@@ -5356,11 +5392,11 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes one host, which is what a tap means once the bar is up.
|
||||
/// Adds or removes one host: a tap once the phone's bar is up, and a Ctrl-click on the desktop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taking the last tick off leaves selection mode, which is what every Android list does and is the
|
||||
/// second way out of it — the cross at the left of the bar being the first. It goes through
|
||||
/// Taking the last tick off leaves selection mode, which is what every list of either kind does and is
|
||||
/// the second way out of it — the cross at the left of the bar being the first. It goes through
|
||||
/// <see cref="ClearHostChoice"/> rather than merely emptying the set, so the menu and any panel it raised
|
||||
/// go with it: a picker asking which vault to move nothing to is not a state worth having.
|
||||
/// </remarks>
|
||||
@@ -5386,6 +5422,89 @@ internal sealed partial class VaultViewModel(
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||
/// </summary>
|
||||
/// <param name="rows">The hosts to tick.</param>
|
||||
/// <param name="replacing">
|
||||
/// Whether this run is the selection now, or is being added to whatever is already ticked — which is the
|
||||
/// difference between a band dragged across the grid and the same band dragged with Ctrl held.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A method rather than a command, and it is the one entry into this set that is not a control.</b>
|
||||
/// Every other one is pressed: a long press, a tap, the cross on the bar. This one is handed a run the
|
||||
/// head has just worked out from the pointer — the cards a rubber band crossed, or the cards between two
|
||||
/// clicks — and a command takes a single argument, so binding it would mean inventing a parameter object
|
||||
/// for a caller that is not a binding. See <c>HostsScreen.axaml.cs</c> on the desktop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Replacing with nothing goes through <see cref="ClearHostChoice"/> rather than merely emptying the set,
|
||||
/// for the reason <see cref="ToggleHostChoice"/> does: the panels and the menu are about the set, and a
|
||||
/// picker asking which vault to move nothing to is not a state worth having. That is the ordinary end of
|
||||
/// a band dragged across empty space, and of a plain click on the grid's background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void ChooseHosts(IEnumerable<HostRowViewModel> rows, bool replacing)
|
||||
{
|
||||
if (replacing)
|
||||
{
|
||||
chosenHostIds.Clear();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
chosenHostIds.Add(row.EntityId);
|
||||
}
|
||||
|
||||
if (chosenHostIds.Count == 0)
|
||||
{
|
||||
ClearHostChoice();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks every host between two cards, which is what a shift-click on the desktop means.
|
||||
/// </summary>
|
||||
/// <param name="anchor">Where the run starts: the last card clicked without shift.</param>
|
||||
/// <param name="to">The card that was shift-clicked.</param>
|
||||
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order is the grid's own — <see cref="VisibleHosts"/>, which is the collection the cards are drawn
|
||||
/// from — so "between" means what the eye says it means, with whatever filter is in the box and whatever
|
||||
/// group is open already applied. Taking it from <see cref="Hosts"/> instead would tick machines that are
|
||||
/// not on the screen, which is the version of this mistake that ends in a deletion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Either end missing from that collection ticks nothing rather than guessing. That is a shift-click
|
||||
/// arriving after the run's other end has been filtered away, and the honest answer to it is no run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
|
||||
{
|
||||
if (anchor is null || to is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var from = VisibleHosts.IndexOf(anchor);
|
||||
var until = VisibleHosts.IndexOf(to);
|
||||
|
||||
if (from < 0 || until < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var first = Math.Min(from, until);
|
||||
var last = Math.Max(from, until);
|
||||
|
||||
ChooseHosts(VisibleHosts.Skip(first).Take(last - first + 1).ToList(), replacing);
|
||||
}
|
||||
|
||||
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
|
||||
/// <remarks>
|
||||
/// It takes the menu and whichever panel was raised from it, because all three are about the set: a
|
||||
@@ -5822,13 +5941,75 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = ChosenHosts;
|
||||
var name = choice.EntityId is null ? "no group" : choice.Label;
|
||||
|
||||
IsRegroupingChosenHosts = false;
|
||||
ChosenHostGroupChoices.Clear();
|
||||
SelectedChosenHostGroup = null;
|
||||
|
||||
await FileTheChosenHostsUnderAsync(
|
||||
choice.EntityId,
|
||||
choice.EntityId is null ? "no group" : choice.Label,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files the ticked hosts under the group card a drag of them was let go of.
|
||||
/// </summary>
|
||||
/// <param name="card">The group card the drop landed on.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The desktop's drag, once more than one card is ticked.</b> Dragging one host onto a group is
|
||||
/// <see cref="MoveHostToGroupAsync"/> and always has been; a selection dragged onto one has to file all of
|
||||
/// it, because the alternative — moving whichever card the pointer happened to be holding and leaving the
|
||||
/// other five where they are — is a gesture that quietly does a fraction of what it looks like it does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two refusals are the ones <see cref="RefusesTheDrop"/> makes for a single card, made once for the
|
||||
/// set: a write under an open editor is a save nobody asked for, and a group belongs to one vault, so a
|
||||
/// selection spanning two cannot be filed under it — the hosts from the other keychain would carry an id
|
||||
/// only its holders can resolve. A read-only host inside the set is skipped rather than refusing the
|
||||
/// whole drop; that is counted and said, in the run below.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No cancellation token, for the reason <see cref="MoveHostToGroupAsync"/> has none: two drops in quick
|
||||
/// succession are two writes rather than one superseding the other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task FileChosenHostsUnderAsync(HostGroupRowViewModel? card)
|
||||
{
|
||||
if (card is null || !IsChoosingHosts || AHostEditorIsInTheWay())
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (ChosenHosts.Any(row => row.VaultId != card.VaultId))
|
||||
{
|
||||
Status = $"'{card.Label}' is in another keychain to some of these hosts, and a group belongs to "
|
||||
+ "one. Move them to that keychain first, or file the ones already in it.";
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return FileTheChosenHostsUnderAsync(card.EntityId, card.Label, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The write behind both ways a set of ticked hosts is filed: the group picker, and a drag of them onto a
|
||||
/// group card.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The group they end up in, or null for none.</param>
|
||||
/// <param name="name">What to call it on the status line.</param>
|
||||
/// <param name="cancellationToken">The caller's lifetime.</param>
|
||||
/// <remarks>
|
||||
/// Shared rather than written twice, because what "filing a set" means — the hosts a newer version wrote
|
||||
/// are skipped, the ones already there are counted as done, and the selection is dropped once the reload
|
||||
/// has replaced every row — is the part that would drift between two copies of it.
|
||||
/// </remarks>
|
||||
private async Task FileTheChosenHostsUnderAsync(
|
||||
Guid? groupId, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = ChosenHosts;
|
||||
|
||||
var done = 0;
|
||||
var skipped = 0;
|
||||
|
||||
@@ -5844,7 +6025,7 @@ internal sealed partial class VaultViewModel(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.Host.GroupId == choice.EntityId)
|
||||
if (row.Host.GroupId == groupId)
|
||||
{
|
||||
// Already there. Counted as done rather than skipped: the user asked for these hosts
|
||||
// to be under this heading, and they are.
|
||||
@@ -5856,7 +6037,7 @@ internal sealed partial class VaultViewModel(
|
||||
.UpdateAsync(
|
||||
row.VaultId,
|
||||
row.EntityId,
|
||||
row.Host with { GroupId = choice.EntityId },
|
||||
row.Host with { GroupId = groupId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
@@ -5961,6 +6142,10 @@ internal sealed partial class VaultViewModel(
|
||||
IsSendingChosenHostsToAVault = false;
|
||||
IsRegroupingChosenHosts = false;
|
||||
|
||||
// What the count below is about, so that the question goes if the set stops being it.
|
||||
deletionAskedAbout.Clear();
|
||||
deletionAskedAbout.UnionWith(chosenHostIds);
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.ChosenHosts,
|
||||
Guid.Empty,
|
||||
@@ -6133,6 +6318,12 @@ internal sealed partial class VaultViewModel(
|
||||
row.IsChosen = chosenHostIds.Contains(row.EntityId);
|
||||
}
|
||||
|
||||
// A question asked about six hosts is not a question about these seven. See deletionAskedAbout.
|
||||
if (IsConfirmingChosenHostDeletion && !chosenHostIds.SetEquals(deletionAskedAbout))
|
||||
{
|
||||
PendingDeletion = null;
|
||||
}
|
||||
|
||||
// The panels go with the last host, wherever the set emptied from. A sync that removed the only
|
||||
// chosen machine would otherwise leave a vault picker up with nothing behind it.
|
||||
if (chosenHostIds.Count == 0)
|
||||
@@ -6145,6 +6336,7 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(ShowsAddButton));
|
||||
OnPropertyChanged(nameof(ChosenHostCount));
|
||||
OnPropertyChanged(nameof(ChosenHostsLabel));
|
||||
OnPropertyChanged(nameof(ChosenHostsSummary));
|
||||
OnPropertyChanged(nameof(ChosenHosts));
|
||||
OnPropertyChanged(nameof(TheChosenHost));
|
||||
OnPropertyChanged(nameof(HasOneChosenHost));
|
||||
|
||||
@@ -119,7 +119,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
||||
// than in the item template: a menu inside the template would have the row for its data context,
|
||||
// and every one of these would silently bind to nothing.
|
||||
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
|
||||
//
|
||||
// Drawn rather than merely present, because this menu carries a second Edit… that is about the
|
||||
// ticked set rather than about the selection; with nothing ticked that one is collapsed. See
|
||||
// TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
|
||||
var edit = menu.Items.OfType<MenuItem>()
|
||||
.Single(item => item.IsVisible && item.Header is "Edit…");
|
||||
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
||||
|
||||
edit.Command!.Execute(null);
|
||||
@@ -260,7 +265,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
|
||||
carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
@@ -574,6 +579,245 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ◆ Choosing more than one card ----
|
||||
//
|
||||
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
|
||||
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
|
||||
// gesture means what, and the rule that keeps the list's own selection and the set from ever both being
|
||||
// about something at the same moment. See HostsScreen.axaml.cs.
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
|
||||
/// also moved the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
|
||||
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
|
||||
/// down rather than acted on as it bubbles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||
/// card is in play and every command on this screen is about the same host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = Row(vault, "prod-db");
|
||||
var second = Row(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = first;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Click(CardFor(screen, second), window, RawInputModifiers.Control);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1);
|
||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||
|
||||
Click(CardFor(screen, first), window);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a plain click drops the set");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "and selects the card it landed on, as it always has");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
|
||||
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
|
||||
/// the grid's own, so "between" means between as the cards are laid out.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
||||
{
|
||||
await AddHostAsync("dev-box");
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var order = vault.VisibleHosts.ToList();
|
||||
|
||||
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
|
||||
|
||||
Click(CardFor(screen, order[0]), window);
|
||||
Click(CardFor(screen, order[2]), window, RawInputModifiers.Shift);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(3);
|
||||
order.ShouldAllBe(row => row.IsChosen);
|
||||
|
||||
Click(CardFor(screen, order[1]), window, RawInputModifiers.Shift);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
order[2].IsChosen.ShouldBeFalse("the run is re-measured from the anchor, not extended");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The band, dragged out over the space around the cards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
|
||||
/// selection or the start of a drag of that host, and the band is what the space between and below them
|
||||
/// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
|
||||
/// and a band that had to contain one would tick nothing at all when drawn down a column.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The second half is the same press without the drag: a click on the empty space is how somebody who
|
||||
/// never finds Esc gets out of a selection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = CardFor(screen, Row(vault, "prod-db"));
|
||||
var last = CardFor(screen, Row(vault, "stage-web"));
|
||||
|
||||
var topLeft = Corner(first, window);
|
||||
var bottomRight = Corner(last, window)
|
||||
+ new Point(last.Bounds.Width, last.Bounds.Height);
|
||||
|
||||
// Below every card, so the press lands on the scroller rather than on a list item.
|
||||
var from = bottomRight.WithY(bottomRight.Y + 24);
|
||||
var to = topLeft + new Point(2, 2);
|
||||
|
||||
// The button has to be named on the moves as well as on the press: a headless move carries the
|
||||
// button state in its modifiers, and one sent without it is the pointer being let go of.
|
||||
window.MouseDown(from, MouseButton.Left);
|
||||
window.MouseMove(new Point(to.X, from.Y), RawInputModifiers.LeftMouseButton);
|
||||
window.MouseMove(to, RawInputModifiers.LeftMouseButton);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2, "the band was over both cards");
|
||||
|
||||
window.MouseUp(to, MouseButton.Left);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2, "and letting go keeps what it was over");
|
||||
|
||||
window.MouseDown(from, MouseButton.Left);
|
||||
window.MouseUp(from, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a press and a release with no band between them is a click");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One menu with two halves, and which half is drawn is whether anything is ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
|
||||
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
|
||||
/// must never happen is both being offered at once: a Delete… asking about the card under the pointer
|
||||
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
|
||||
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var ticked = Row(vault, "prod-db");
|
||||
var other = Row(vault, "stage-web");
|
||||
|
||||
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
|
||||
|
||||
RightClick(CardFor(screen, ticked), window);
|
||||
|
||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
||||
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
|
||||
|
||||
var offered = menu.Items.OfType<MenuItem>().Where(item => item.IsVisible).ToList();
|
||||
|
||||
offered.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteChosenHostsCommand));
|
||||
offered.ShouldNotContain(
|
||||
item => ReferenceEquals(item.Command, vault.DeleteHostCommand),
|
||||
"the entries about the selection are not offered beside the entries about the set");
|
||||
|
||||
menu.Close();
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
|
||||
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
|
||||
|
||||
menu.Items.OfType<MenuItem>()
|
||||
.Where(item => item.IsVisible)
|
||||
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
|
||||
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
|
||||
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
|
||||
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
|
||||
{
|
||||
await AddHostAsync("dev-box");
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
||||
|
||||
screen.HostGrid.Focus();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1, "the one card being drawn, not the three hosts there are");
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
|
||||
/// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
|
||||
/// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
|
||||
/// would be a drop the user can see the point of and cannot make.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];
|
||||
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, set));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
var over = Over(onto, carried);
|
||||
|
||||
over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
||||
@@ -582,8 +826,8 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// 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");
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
||||
/// <remarks>
|
||||
@@ -610,6 +854,26 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
((Window)window).MouseUp(at, MouseButton.Right);
|
||||
}
|
||||
|
||||
/// <summary>A press and a release on one card, with whatever was being held down at the time.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because the two say different things here: the press is where a modifier is read and
|
||||
/// where the set is dropped, and the release is where a press on a ticked card that turned out not to be
|
||||
/// a drag collapses onto it.
|
||||
/// </remarks>
|
||||
private static void Click(Visual card, Window window, RawInputModifiers held = RawInputModifiers.None)
|
||||
{
|
||||
var at = Centre(card, window);
|
||||
|
||||
window.MouseDown(at, MouseButton.Left, held);
|
||||
window.MouseUp(at, MouseButton.Left, held);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
private static Point Corner(Visual control, Visual window) =>
|
||||
control.TranslatePoint(default, window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -677,6 +941,18 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
|
||||
private async Task AddHostAsync(string label)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
|
||||
@@ -630,6 +630,115 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
|
||||
}
|
||||
|
||||
// ---- ◆ The hosts screen with a set of cards ticked ----
|
||||
//
|
||||
// Ctrl, Shift and a band put the phone's chosen-hosts set on this screen — see HostsScreen.axaml.cs — and
|
||||
// with it come one strip and three panels that had never been drawn in a window. All four sit between the
|
||||
// HOSTS heading and the grid, so every one of them shortens the grid rather than overflowing it; that is
|
||||
// the property these measure. The gestures themselves are HostGridTests'.
|
||||
|
||||
/// <remarks>
|
||||
/// The strip: a count, CLEAR, and the sentence saying the actions are on the menu — squeezed between the
|
||||
/// heading and the grid's own count on the same row. It is the one thing on this screen whose width is
|
||||
/// set by nothing but its text, so what is really being measured is that the sentence trims instead of
|
||||
/// running out over the number at the far end. Measured with the drawer open, which is the width at which
|
||||
/// it does not fit and has to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileCardsAreTicked()
|
||||
{
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeTrue("the strip is only drawn while something is ticked");
|
||||
|
||||
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
||||
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with two cards ticked"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tallest of the three panels: the vault picker with the key question under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A heading, a picker, a wrapping paragraph naming everything that travels and everything that does not,
|
||||
/// a tick with a second wrapping sentence beside it, and two buttons — all above the group cards and the
|
||||
/// grid, which still have to fit under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host is given a key first, because the tick is drawn only for a move of exactly one host that has
|
||||
/// something to bring; without that this would measure the short shape and say the long one fits. The
|
||||
/// panel is opened by hand rather than through <c>MoveChosenHostsToVault</c> for the reason the group
|
||||
/// move test gives: this fixture's account holds one vault, and the command declines rather than open a
|
||||
/// picker with nothing in it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheChosenHostsVaultPanelOpen()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.First(choice => choice.Kind is AuthenticationKind.SshKey);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
|
||||
vault.ChosenHostVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
vault.SelectedChosenHostVault = vault.ChosenHostVaultChoices[0];
|
||||
vault.IsSendingChosenHostsToAVault = true;
|
||||
|
||||
vault.HasAChosenBindingToBring
|
||||
.ShouldBeTrue("the key question is the part of this panel worth measuring");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's vault panel up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The group picker over the set, which is the same panel the phone draws and the same write a set
|
||||
/// dragged onto a group card makes. Shorter than the vault panel above and drawn in the same place, so
|
||||
/// what this adds is the picker being filled from one keychain's groups rather than from nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
||||
vault.ChosenHostGroupChoices.Count.ShouldBeGreaterThan(1, "no group, and the three seeded ones");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's group panel up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One question for the whole set, drawn by the same card the group deletion above uses. The count is
|
||||
/// what makes it a confirmation somebody reads rather than one they press past, and the consequence line
|
||||
/// wraps — which is the part a narrower column would push out of the window.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileTheChosenHostsAreBeingDeleted()
|
||||
{
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.DeleteChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's deletion question up"));
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -5312,6 +5312,100 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A question about six hosts does not survive the set becoming seven.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The question names a count and the run that answers it reads the set again, and the panel is drawn
|
||||
/// above the list rather than over it — deliberately, so the ticked rows stay in view — which leaves
|
||||
/// every one of them still tickable while it is up. One more tick between the question and the answer
|
||||
/// used to delete a machine nobody had been asked about. The desktop is where this became easy: a
|
||||
/// Ctrl-click or a band is a second's work. See <c>VaultViewModel.deletionAskedAbout</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TickingAnotherHost_DropsTheDeletionQuestionAlreadyOnScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "prod-web");
|
||||
await AddHostAsync(vault, "staging");
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||
|
||||
vault.DeleteChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 2 hosts?");
|
||||
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||
|
||||
vault.IsConfirmingChosenHostDeletion
|
||||
.ShouldBeFalse("the question was about two of them and there are three now");
|
||||
vault.IsChoosingHosts.ShouldBeTrue("the set is what was just chosen, so it stays");
|
||||
|
||||
vault.DeleteChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 3 hosts?");
|
||||
|
||||
// And unticking back to the set it was asked about does not bring a stale question back up.
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||
|
||||
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The desktop's drag, once more than one card is ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dragging one host onto a group card has always been <c>MoveHostToGroup</c>; a set dragged onto one has
|
||||
/// to file all of it, because moving whichever card the pointer happened to be holding and leaving the
|
||||
/// other five where they are is a gesture that quietly does a fraction of what it looks like it does. It
|
||||
/// is the picker's write with the picker skipped — see <c>ConfirmRegroupChosenHosts</c>, which shares it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The refusal is the one <c>RefusesTheDrop</c> makes for a single card, made once for the set: a drop is
|
||||
/// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
|
||||
/// and could not then cancel.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "prod-web");
|
||||
await AddHostAsync(vault, "staging");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||
|
||||
var card = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
|
||||
vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
|
||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
|
||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
|
||||
/// another vault: the copy stays in the same keychain, so everything it points at is still there.
|
||||
|
||||
Reference in New Issue
Block a user