diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 590641b..cec0a58 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -952,13 +952,15 @@ closed — which is how a foldable would open to a phone layout until somebody t
Look at the bottom right of the host list with no host selected.
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then press
-and hold a host so the connect bar rises.
+and hold a host so the action bar rises, and open the ⋯ menu → **Change group**.
-**Pass:** the + has moved up with the bar and is not over the CONNECT button.
+**Pass:** the + is gone the moment anything is ticked, and stays gone under the picker that panel raises.
+It comes back when the cross at the left of the bar is pressed.
-**Failure means:** the + was anchored to the screen rather than to the list's row. Nothing in the layout
-harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
-between two controls, so a button sitting squarely on top of CONNECT reports clean.
+**Failure means:** a + still drawn over a selection is `ShowsAddButton` no longer reading `IsChoosingHosts`
+— a control that does something, where the something is not what the screen is about. Nothing in the layout
+harness would catch it if it could run: its only fit rule is containment inside the window, never overlap
+between two controls.
### 8.3 The sheet says what each choice does
@@ -1004,7 +1006,7 @@ Add a group with a default SSH key. Add a host with no group and leave AUTHENTIC
each time)**. Save. Now edit it and choose the group.
**Pass:** the picker gains an **Inherit from group** entry and moves to it. Save, and the host row reads
-`key`, and the connect bar shows a note naming the group rather than a password box.
+`key`. Tap it: it connects without asking for anything, rather than raising the password sheet.
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
absence of one, and this host will never use the fleet's key.
@@ -1013,7 +1015,7 @@ absence of one, and this host will never use the fleet's key.
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
-**Pass:** the connect bar shows a password box again, and it survives a lock and unlock.
+**Pass:** tapping the row raises the password sheet again, and that survives a lock and unlock.
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
@@ -1090,60 +1092,101 @@ selection, which this head never sets. That is the exact failure the header-aime
With only one writable vault, the entry answers on the status line instead and no panel opens. That is the
intended answer, not a failure.
-### 8.14 DELETE under a host is not beside EDIT
+### 8.14 Remove is at the bottom of the menu, below a rule
-Press and hold a host so the connect bar rises.
+Press and hold a host so the action bar rises, then open the ⋯.
-**Pass:** CONNECT, then EDIT and MOVE side by side, then DELETE full width in the danger colour on a row of
-its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone.
+**Pass:** Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate — then a rule,
+then **Remove** in the danger colour. The sheet scrolls if it has to, and Remove is reachable without the
+list underneath being tapped by accident.
-Press DELETE.
+Tick a second host and open the ⋯ again.
-**Pass:** the question takes the whole bar — no CONNECT, no EDIT, no second DELETE underneath it — and KEEP
-puts them back with the host still there.
+**Pass:** Connect and Connect via SFTP are gone. A terminal and a file session are each about one machine,
+and there is no reading of "connect to these six". The other five are still there.
-**Failure means:** a bar that kept its buttons under the question is one where DELETE can be pressed twice,
-the second press landing on whatever the list rebuilt underneath.
+Press Remove.
+**Pass:** a question above the list naming the count — "Delete these 2 hosts?" — and KEEP puts the list back
+with both hosts and both ticks still there.
-### 8.15 A tap connects, and a long press is what raises the bar · **the one worth the most care**
+**Failure means:** one question per host is `DeletionTarget.ChosenHosts` not being used, and six copies of
+"delete prod-db?" is not a confirmation anybody reads. A question that cleared the ticks on KEEP is a
+selection thrown away for declining to destroy it.
+
+### 8.15 A tap connects, and a long press chooses · **the one worth the most care**
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
**Pass:** it connects, and the terminal takes the screen. **Nothing rises over the list on the way past** —
-no password box, no CONNECT, no EDIT/MOVE/DELETE. Come back to HOSTS: still nothing.
+no password box, no action bar. Come back to HOSTS: still nothing, and no row is marked.
Now press and hold the same row.
-**Pass:** the bar comes up, naming that host, with all of it. Press and hold a different row: the bar
-follows to that one rather than staying on the first.
+**Pass:** the header is replaced by a bar carrying ✕, the count `1`, a pencil and a ⋯, and the row itself is
+filled with a tick at its right-hand end. Press and hold a second row: the count reads `2` and the pencil is
+gone.
+
+Tap one of the two.
+
+**Pass:** it unticks rather than connecting. Tap the other: the count reaches zero, the bar goes and the
+vault header comes back.
Now tap a host that authenticates with a **typed password**.
-**Pass:** it does not dial. The bar rises with the password box in it and the status line says the host asks
-for a password. Type it and press CONNECT — or tap the row a second time, which is the same thing — and it
-connects.
+**Pass:** it does not dial. A sheet rises from the bottom naming that host, with a password box, the
+remember tick, CONNECT and CANCEL. Type it and press CONNECT — or dismiss the sheet, type nothing, and tap
+the row a second time — and it connects. Dismissing it empties the box.
-**Failure means:** a tap that raises the bar is the old behaviour, where choosing a machine put five
-controls over the bottom of the list in the way of the one thing a tap on its name means. A tap that dials
-a password host with an empty box is worse: the connection fails somewhere the user cannot see why. A long
-press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a
-hold, so one touch fires both gestures.
+**Failure means:** a tap that connects while rows are ticked is `IsChoosingHosts` not being read in
+`HostsScreen.axaml.cs` — a terminal opened on top of a selection somebody was halfway through building. A
+tap on a group *heading* that ticks a host is worse: it means the handler is reading the list's selection
+rather than the element under the finger, so a machine nobody pointed at has gone into a set about to be
+deleted. A tap that dials a password host with an empty box fails somewhere the user cannot see why. And a
+long press that also connects means `held` is not suppressing the `Tapped` that follows a hold.
-### 8.16 The pencil in the header edits the chosen host
+### 8.16 The pencil in the action bar opens a page, not a card
-With a host chosen — by a tap that connected, or by a long press — look at the header beside LOCK.
+Tick exactly one host and press the pencil.
-**Pass:** a pencil. It opens that host's editor. With nothing chosen there is no pencil at all rather than a
-greyed one, and while an editor is open it is gone.
+**Pass:** the editor takes the **whole screen** — no search box, no sync line, no shells strip, no bottom
+bar — with its own header carrying ←, the title, and a ✓. The ticks are gone: the screen is about one host
+now.
-Navigate to Settings and back.
+Fill in a field and press ← .
-**Pass:** the pencil is absent on every screen but HOSTS, including Settings and the keychain.
+**Pass:** back on the list with nothing saved, and the boxes cleared for the next time. The system back
+gesture does the same thing.
-**Failure means:** a pencil visible over another screen is the `IsHostsShowing` wrapper gone — see
-`PhoneShell.axaml`, which nests the two conditions because Avalonia's bindings have no "and". A pencil that
-stays while the host editor is up is `CanEditSelectedHost` no longer reading `AnEditorIsOpen`.
+Tick two hosts.
+
+**Pass:** no pencil. Editing two machines has no meaning, and the bar collapses the control rather than
+greying it.
+
+**Failure means:** chrome left around the editor is `RefreshChrome` no longer reading the vault's
+`IsEditing` — see `PhoneShell.axaml.cs`, which subscribes to the vault as well as the shell precisely so
+that it can. A pencil beside a count of two is `HasOneChosenHost` not being read.
+
+### 8.17 Connect via SFTP crosses to the files screen with the host already chosen
+
+Tick one host that needs nothing typed and choose **Connect via SFTP** from the ⋯.
+
+**Pass:** the files screen, with that machine already chosen and connecting — not a picker asking which host
+again. The ticks are gone.
+
+Now do the same with a host that authenticates with a typed password.
+
+**Pass:** the files screen with the picker open, that host chosen in it, its own password box beside it, and
+a line saying the host asks for one. It does not dial with an empty box.
+
+Open a bucket first, then try the entry again.
+
+**Pass:** it refuses and says so, and the screen stays on the bucket. There is one file session behind both
+destinations, so switching under a live one would show a screen titled S3 listing an SFTP host's files.
+
+**Failure means:** a picker asking which host is `MainWindowViewModel.OnVaultFilesRequested` failing to find
+the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
+rather than handed the vault's object.
---
@@ -1360,14 +1403,14 @@ seriousness here, since this password is typed rather than stored and so is type
With at least one keychain host and one machine connected to by hand in the log, open Connections with
nothing running.
-**Pass:** tapping the keychain one lands on HOSTS with that host selected **and its connect bar up**.
-Tapping the hand-typed one puts its address back in the box on this screen and leaves the password box
-**empty**. Neither one dials anything on its own.
+**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
+for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
+leaves the password box **empty**. Neither one dials anything on its own.
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
-with the host selected but **no bar**, `ConnectToRecent` is setting the selection rather than opening the
-pane: since 8.15 a selection alone raises nothing, so that is a screen with nothing to press.
+at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
+`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
### 11.9 Settings holds the keychain, and back walks out of it
diff --git a/src/DodoSSH.Client.Android/Views/HostActionBar.axaml b/src/DodoSSH.Client.Android/Views/HostActionBar.axaml
new file mode 100644
index 0000000..f3ae998
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostActionBar.axaml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/HostActionBar.axaml.cs b/src/DodoSSH.Client.Android/Views/HostActionBar.axaml.cs
new file mode 100644
index 0000000..86d405a
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostActionBar.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// The bar that takes the header's place while hosts are chosen.
+internal sealed partial class HostActionBar : UserControl
+{
+ public HostActionBar() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index 9113c64..9ae395b 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -9,10 +9,8 @@
-
+
@@ -107,6 +110,132 @@
Text="{Binding UnreadableSummary}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
@@ -281,216 +422,92 @@
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -574,177 +591,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -805,6 +669,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
index c069162..6392239 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
@@ -1,23 +1,26 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
+using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
-/// Design 02 — the host list, and the connect bar that replaces the desktop's right column.
+/// Design 02 — the host list, and the two gestures that are the whole of this file.
///
///
-/// ◆ Two gestures on one list, and this file is the whole of the difference between them. A tap on a
-/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
-/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
+/// ◆ A tap connects, a long press chooses, and once anything is chosen a tap ticks instead. The
+/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
+/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
+/// ask.
///
///
-/// The handlers are on the ListBox rather than on the row, and the row stays a plain Grid.
-/// A button as the item template swallows the press before the list sees it — FilesScreen writes that
-/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
-/// events fire after the list has moved its selection, which is what lets these read it.
+/// The handlers are on the ListBox rather than on the row, and the row stays a plain
+/// Border. A button as the item template swallows the press before the list sees it —
+/// FilesScreen writes that out at length — so neither handler would ever run.
///
///
internal sealed partial class HostsScreen : UserControl
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
///
///
/// Avalonia raises Tapped on release whatever the press lasted, so without this a long press
- /// would open the bar and then connect — the two gestures firing one after the other on one touch, which
- /// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
- /// by the tap it suppresses, so it never survives the gesture that set it.
+ /// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
+ /// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
+ /// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
///
private bool held;
///
///
- /// The long press is attached here rather than in the markup so that it sits beside the property that
- /// makes it fire at all. is set rather than assumed:
- /// it is the whole of the gesture, and a default that changed would take it away silently — every tap
- /// would go on working and nothing would ever open the bar again.
+ /// Both gestures are attached here rather than in the markup so that they sit beside the property that
+ /// makes one of them fire at all. is set rather than
+ /// assumed: it is the whole of the long press, and a default that changed would take it away silently —
+ /// every tap would go on working and nothing would ever choose a host again.
///
///
/// ◆ FindControl rather than the field the name generator declares for x:Name,
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
}
///
- /// Connects to the row that was tapped.
+ /// Connects to the row that was tapped, or ticks it where hosts are already ticked.
///
///
///
- /// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
- /// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
- /// before — see VaultViewModel.SelectedSidebarRow — so reading the selection here answers "a host,
- /// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
- /// a machine the user was not pointing at.
+ /// ◆ The branch is the selection, not the row. Once anything is chosen the screen is in selection
+ /// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
+ /// connecting. That is what every Android list does, and the alternative is worse than a rule to
+ /// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
+ /// selection somebody was halfway through building.
///
///
- /// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
- /// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
- /// returning a task nothing observes.
+ /// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
+ /// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
+ /// be an event handler returning a task nothing observes.
///
///
private void OnRowTapped(object? sender, TappedEventArgs e)
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
return;
}
- if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
+ if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
{
- _ = vault.ConnectToRowCommand.ExecuteAsync(row);
+ return;
}
+
+ if (vault.IsChoosingHosts)
+ {
+ vault.ToggleHostChoiceCommand.Execute(row);
+ return;
+ }
+
+ _ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
- ///
- /// Raises the bar about the row that was held.
- ///
+ /// Puts a tick against the row that was held, entering selection mode with it.
///
- ///
/// On rather than on completion, so the bar is up while the finger is
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
/// had been recognised, and the only feedback available on this list is the thing it does.
- ///
- ///
- /// It goes through OpenHostPaneCommand, which is the desktop's own "ask about this host" — the two
- /// heads raise different furniture from one flag rather than keeping a selection rule each.
- ///
///
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
{
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
held = true;
- if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
+ if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
{
- vault.OpenHostPaneCommand.Execute(row);
+ vault.ChooseHostCommand.Execute(row);
}
}
+
+ ///
+ /// The host the gesture landed on, or null where it landed on something that is not one.
+ ///
+ ///
+ ///
+ /// ◆ Read off the element under the finger rather than off the list's selection, which is what both
+ /// handlers used to do. The selection was defensible while a tap only ever connected: a tap on a
+ /// group heading moves the selection and the view model bounces it straight back — see
+ /// VaultViewModel.SelectedSidebarRow — so reading it answered "a host, or nothing" for free.
+ /// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
+ /// whichever host was last chosen and tick that, which is a machine the user was not pointing
+ /// at going into a set they are about to delete.
+ ///
+ ///
+ /// The ancestor rather than e.Source itself, because the source is whatever leaf the finger
+ /// landed on — a tag chip's TextBlock has a string for a data context, and the row's own
+ /// Ellipse has the row. The is the one element in that chain whose data
+ /// context is always the list's item, whatever kind it is.
+ ///
+ ///
+ private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
+ (e.Source as Visual)?.FindAncestorOfType(includeSelf: true)?.DataContext
+ as HostRowViewModel;
}
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index 8c8e399..b437645 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -98,7 +98,7 @@
-
+
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
index bf7f4d3..9cb00d1 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
+ ///
+ /// The open vault, while there is one, so that this control hears about the hosts screen's own state.
+ ///
+ ///
+ /// ◆ A second subscription, and it is the price of the header being swappable. Two of the flags
+ /// below are questions about the vault rather than about the shell — whether hosts are ticked, and
+ /// whether the host editor is filling the screen — and the shell does not forward the vault's
+ /// notifications. Kept in step from , because Vault is replaced on
+ /// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
+ ///
+ private VaultViewModel? vault;
+
///
/// Everything the phone draws, which is the element the software keyboard is kept off.
///
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
TryOfferDeviceUnlock();
}
+ FollowTheVault();
RefreshChrome();
};
}
+ /// Moves this control's second subscription onto whichever vault is open now.
+ ///
+ /// Compared before being swapped, so that the ordinary case — a shell notification about something else
+ /// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property
+ /// change on the shell.
+ ///
+ private void FollowTheVault()
+ {
+ if (ReferenceEquals(vault, shell?.Vault))
+ {
+ return;
+ }
+
+ if (vault is not null)
+ {
+ vault.PropertyChanged -= OnVaultChanged;
+ }
+
+ vault = shell?.Vault;
+
+ if (vault is not null)
+ {
+ vault.PropertyChanged += OnVaultChanged;
+ }
+ }
+
+ private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ => RefreshChrome();
+
/// Whether this surface is wide enough to be laid out like the desktop.
///
/// A property of the control rather than of the view model, because it is a fact about the surface and
@@ -115,6 +157,14 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty ShowsVaultHeaderProperty =
AvaloniaProperty.Register(nameof(ShowsVaultHeader));
+ /// Whether the bar about the chosen hosts is drawn in the header's place.
+ public static readonly StyledProperty ShowsHostSelectionBarProperty =
+ AvaloniaProperty.Register(nameof(ShowsHostSelectionBar));
+
+ /// Whether the strip of open shells above the bottom bar is drawn.
+ public static readonly StyledProperty ShowsShellStripProperty =
+ AvaloniaProperty.Register(nameof(ShowsShellStrip));
+
///
public bool IsWide
{
@@ -143,6 +193,20 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsVaultHeaderProperty, value);
}
+ ///
+ public bool ShowsHostSelectionBar
+ {
+ get => GetValue(ShowsHostSelectionBarProperty);
+ private set => SetValue(ShowsHostSelectionBarProperty, value);
+ }
+
+ ///
+ public bool ShowsShellStrip
+ {
+ get => GetValue(ShowsShellStripProperty);
+ private set => SetValue(ShowsShellStripProperty, value);
+ }
+
///
/// Works out which chrome this surface should be wearing.
///
@@ -161,7 +225,16 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface.
///
///
- /// Recomputed on every shell notification rather than on a named list of them. Three boolean
+ /// ◆ The header is now a swap rather than a switch, and the editor takes the whole screen. Two
+ /// more flags and two more inputs, both of them the vault's rather than the shell's — see
+ /// . While hosts are ticked the header stands down and
+ /// puts the action bar in its place, which is what makes that bar
+ /// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it
+ /// is a page rather than a card, so all four rows of chrome stand down and the form has the display —
+ /// which is what "opens in a separate page" means on a 360dp screen.
+ ///
+ ///
+ /// Recomputed on every notification from either object rather than on a named list of them. Five boolean
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
/// would notice going stale.
@@ -172,6 +245,16 @@ internal sealed partial class PhoneShell : UserControl
var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true;
+ // The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
+ // not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
+ // is the one control it needs and the one the system gesture already maps to.
+ var editing = vault?.IsEditing == true;
+
+ // Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
+ // navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
+ // is not on the display.
+ var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true;
+
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
@@ -183,9 +266,11 @@ internal sealed partial class PhoneShell : UserControl
}
IsWide = wide;
- ShowsRail = wide && pages;
- ShowsBottomBar = !wide && pages;
- ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
+ ShowsRail = wide && pages && !editing;
+ ShowsBottomBar = !wide && pages && !editing;
+ ShowsShellStrip = pages && !editing;
+ ShowsHostSelectionBar = pages && choosing && !editing;
+ ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -195,6 +280,7 @@ internal sealed partial class PhoneShell : UserControl
return;
}
+ FollowTheVault();
RefreshChrome();
if (e.PropertyName is nameof(MainWindowViewModel.State))
@@ -500,16 +586,23 @@ internal sealed partial class PhoneShell : UserControl
/// Whether anything was closed, and so whether back has been spent.
///
///
- /// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
- /// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
- /// over a list nobody asked to see, and the second back would then close the sheet rather than the
- /// editor the user was looking at.
+ /// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
+ /// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
+ /// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
+ /// than the editor the user was looking at.
///
///
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
/// next NEW HOST open on somebody else's abandoned draft.
///
+ ///
+ /// ◆ Selection mode is last and is still a thing back has to spend itself on. It is a mode rather
+ /// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top
+ /// — and a gesture that left the application from it would take somebody out of the app because they had
+ /// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is
+ /// what the user is looking at, and the ticks underneath are what it is about.
+ ///
///
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
{
@@ -518,22 +611,71 @@ internal sealed partial class PhoneShell : UserControl
return false;
}
+ return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
+ }
+
+ /// Lowers the nearest of the four sheets, which are what sits over everything else.
+ ///
+ /// The four cannot be open at once — each is raised from a control the others hide — so their order
+ /// between themselves decides nothing. What matters is that all of them come before the panels and the
+ /// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a
+ /// list nobody asked to see.
+ ///
+ private static bool TryLowerASheet(VaultViewModel vault)
+ {
+ // ◆ The action bar's own menu, first of the four because it is raised from chrome that is already
+ // over everything else.
+ if (vault.IsHostActionSheetOpen)
+ {
+ vault.CloseHostActionSheetCommand.Execute(null);
+ return true;
+ }
+
+ // The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than
+ // hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword.
+ if (vault.IsAskingForConnectPassword)
+ {
+ vault.CancelConnectPasswordCommand.Execute(null);
+ return true;
+ }
+
if (vault.IsAddSheetOpen)
{
vault.CloseAddSheetCommand.Execute(null);
return true;
}
- // The other sheet, and it is checked beside the first rather than after the editors for the same
- // reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
- // at once — one is raised by the +, the other by a heading, and each hides the list the other's
- // control is on — so their order between themselves decides nothing.
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
return true;
}
+ return false;
+ }
+
+ /// Closes the nearest of the panels, the editors and selection mode itself.
+ ///
+ private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault)
+ {
+ if (vault.IsSendingChosenHostsToAVault)
+ {
+ vault.CancelSendChosenHostsToAVaultCommand.Execute(null);
+ return true;
+ }
+
+ if (vault.IsRegroupingChosenHosts)
+ {
+ vault.CancelRegroupChosenHostsCommand.Execute(null);
+ return true;
+ }
+
+ if (vault.IsConfirmingChosenHostDeletion)
+ {
+ vault.CancelDeleteCommand.Execute(null);
+ return true;
+ }
+
if (vault.IsEditing)
{
vault.CancelEditCommand.Execute(null);
@@ -546,6 +688,12 @@ internal sealed partial class PhoneShell : UserControl
return true;
}
+ if (vault.IsChoosingHosts)
+ {
+ vault.ClearHostChoiceCommand.Execute(null);
+ return true;
+ }
+
return false;
}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 069ab32..e3efff7 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -1133,17 +1133,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
///
/// Two destinations, because a recent row is one of two different things. One that names a
- /// keychain host goes to that host on the hosts screen, with the panel about it opened — the desktop's
- /// drawer, the phone's connect bar — carrying whatever authentication the keychain resolves for it and a
- /// password box only if it needs one. Connecting from here instead would be a third connect path that
- /// had to answer all of that again.
+ /// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
+ /// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
+ /// authentication the keychain resolves for it. Connecting from here instead would be a third connect
+ /// path that had to answer all of that again.
///
///
- /// ◆ It opens that panel rather than merely selecting the row, and on the phone it has to.
- /// Choosing a host there no longer raises the bar — a tap on the list connects instead, see
- /// VaultViewModel.ShowsConnectBar — so arriving with the host selected and nothing else would be
- /// arriving at a screen with nothing to press. Asking to go back to a machine is exactly the deliberate
- /// act that flag exists to distinguish from browsing.
+ /// ◆ It raises that rather than merely selecting the row, and on the phone it has to. Nothing on
+ /// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
+ /// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
+ /// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
+ /// VaultViewModel.AskAboutHost, which is where the two heads' answers are raised together.
///
///
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
@@ -1171,7 +1171,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
- vault.OpenHostPaneCommand.Execute(known);
+ vault.AskAboutHostCommand.Execute(known);
ShowScreen(ShellScreen.Hosts);
return;
}
@@ -2757,6 +2757,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
// resolves: the vault holds the handler, not the other way round.
oldValue.VaultsChanged -= OnVaultsAdmitted;
+ oldValue.FilesRequested -= OnVaultFilesRequested;
if (attempts.Count == 0)
{
@@ -2779,6 +2780,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// end of.
newValue.VaultsChanged += OnVaultsAdmitted;
+ // ◆ The phone's action bar asking for a host's files rather than a shell on it. An event because
+ // the screen it leads to is this object's and the transfers view model behind it is a sibling of
+ // the vault rather than a part of it; which machine is the vault's business, because a host is a
+ // decrypted item. See OnVaultFilesRequested.
+ newValue.FilesRequested += OnVaultFilesRequested;
+
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
// disconnected — so without this the status dots go out once a minute underneath terminals that
// are still open. The rows belong to the vault and the connection state belongs to the shell,
@@ -3213,6 +3220,62 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Surface = ShellSurface.Page;
}
+ ///
+ /// Takes the hosts screen to the files screen, on the host it asked about.
+ ///
+ ///
+ ///
+ /// ◆ "Connect via SFTP", from the phone's action bar. It was reachable before only by going to the
+ /// files screen and choosing the machine again out of a picker — which meant naming a host twice, the
+ /// second time on a screen that had no idea one had already been chosen.
+ ///
+ ///
+ /// It goes through rather than setting the screen itself, so the one refusal
+ /// there — a bucket already open — is made once and made here too. The host is chosen after that call,
+ /// because arriving is what clears the picker.
+ ///
+ ///
+ /// It navigates and then connects, and the two are separate on purpose. A host wanting a typed
+ /// password cannot be dialled from a list, so that case opens the picker with the machine already chosen
+ /// and the box beside it — the same branch the tap on the hosts screen makes, and for the same reason.
+ /// Everything else connects, and its failures land on the files screen's own status line, which is where
+ /// somebody who has just arrived there is looking.
+ ///
+ ///
+ /// The row is re-found in the transfers screen's own list rather than used directly. That list is a copy
+ /// rebuilt from the vault's — see TransfersViewModel.Hosts — and its picker binds to rows in it,
+ /// so handing it the vault's object would select nothing.
+ ///
+ ///
+ private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
+ {
+ ShowFiles(RemoteKind.Host);
+
+ if (Screen is not ShellScreen.Transfers)
+ {
+ // The refusal above stood: a bucket is open, and its message is on screen. Choosing a host under
+ // it would leave the picker pointing at a machine nothing is going to dial.
+ return;
+ }
+
+ Transfers.SelectedHost =
+ Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
+
+ if (Transfers.SelectedHost is null)
+ {
+ return;
+ }
+
+ if (Transfers.SelectedHostAsksForAPassword)
+ {
+ Transfers.BeginChoosingRemoteCommand.Execute(null);
+ Transfers.Status = $"{e.Host.Label} asks for a password. Type it here, then CONNECT.";
+ return;
+ }
+
+ _ = Transfers.ConnectCommand.ExecuteAsync(null);
+ }
+
///
///
/// The one place the connect sheet is lowered by something other than a tap. Every way out of a
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index c3e9405..af9963c 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -447,6 +447,25 @@ internal sealed partial class HostRowViewModel(
///
[ObservableProperty]
private bool isConnected;
+
+ ///
+ /// Whether this host is one of the ones the phone's action bar is about.
+ ///
+ ///
+ ///
+ /// The second thing on this row that is not a property of the host, and it is here for the same reason
+ /// is: the list is rebuilt from scratch on every filter keystroke and every
+ /// background sync, so the row cannot be where the answer lives — it is written back onto the
+ /// new rows from the set of ids the vault holds. See VaultViewModel.ChosenHostIds.
+ ///
+ ///
+ /// Not the same as being selected, and the phone no longer draws the latter at all. A selection is what
+ /// a control lights; this is what a long press put a tick against, and every entry in the action bar's
+ /// menu acts on it rather than on whatever the list happens to have marked.
+ ///
+ ///
+ [ObservableProperty]
+ private bool isChosen;
}
/// What a host can authenticate with.
@@ -776,6 +795,25 @@ internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, b
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
}
+/// A host somebody has asked to browse rather than to open a shell on.
+/// The machine, as the row the list is holding.
+///
+///
+/// An event rather than a command, because the file screen is not the vault's. Which host to browse is a
+/// decrypted item and so is this object's business; going to a screen is the shell's, and the
+/// transfers view model it has to be handed to is a sibling of this one rather than a part of it. The same
+/// division already draws for a shell.
+///
+///
+/// It carries the row rather than an id, because the far side has to find the same host in its own copy of
+/// the list — see TransfersViewModel.Hosts — and the entity id is what identifies it there.
+///
+///
+internal sealed class HostFilesEventArgs(HostRowViewModel host) : EventArgs
+{
+ internal HostRowViewModel Host { get; } = host;
+}
+
/// A conflict, as a row.
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
@@ -933,6 +971,18 @@ internal enum DeletionTarget
/// A host, from the sidebar beside the terminal.
Host,
+ ///
+ /// Every host the phone's action bar has a tick against.
+ ///
+ ///
+ /// A member of its own rather than asked several times, because the question is asked
+ /// once and has to name a count — "delete 6 hosts?" is a different sentence from six copies of "delete
+ /// prod-db?", and the second of those is not a confirmation anybody reads. It is also the one deletion
+ /// here whose scope is not one item, which is why DeletionRequest.EntityId is
+ /// for it and the set itself is what gets walked.
+ ///
+ ChosenHosts,
+
/// An SSH key, from the vault screen.
Key,
@@ -1871,8 +1921,7 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
- [NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
@@ -1904,11 +1953,11 @@ internal sealed partial class VaultViewModel(
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
- [NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
+ [NotifyPropertyChangedFor(nameof(ShowsHostList))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
private bool isEditingGroup;
@@ -1967,7 +2016,6 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
private bool isHostPaneOpen;
///
@@ -1997,7 +2045,6 @@ internal sealed partial class VaultViewModel(
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectControls))]
private bool isMovingHost;
/// Which host the open move panel is about. Null when it is closed.
@@ -2130,8 +2177,7 @@ internal sealed partial class VaultViewModel(
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
- [NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAddSheetOpen;
///
@@ -2157,8 +2203,7 @@ internal sealed partial class VaultViewModel(
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
- [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
- [NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(GroupSheetLabel))]
private SidebarGroupHeader? groupSheet;
@@ -2169,70 +2214,220 @@ internal sealed partial class VaultViewModel(
/// Whether anything the host screen can put over its list is showing.
///
///
- /// One property rather than four tests at each call site, and it exists because two controls need
+ /// One property rather than five tests at each call site, and it exists because two controls need
/// exactly this question and would otherwise each answer it their own way: the floating + hides
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
/// does nothing — and the back gesture closes them before it considers leaving the screen.
///
internal bool AnEditorIsOpen =>
- IsAddSheetOpen || GroupSheet is not null || IsEditing || IsEditingGroup;
+ IsAddSheetOpen || GroupSheet is not null || IsEditing || IsEditingGroup
+ || IsHostActionSheetOpen || IsAskingForConnectPassword;
///
- /// Whether the phone's connect bar has anything to be about.
+ /// Whether the phone's host list is drawn.
+ ///
+ ///
+ /// ◆ Only the group editor takes the list's place now. This used to be
+ /// , which meant the list went blank behind every sheet — a scrim over an
+ /// empty canvas, on a screen whose sheets are all about rows the user had just been looking at. The
+ /// sheets float; the group editor is a card in the list's own row and genuinely replaces it; and the
+ /// host editor is a page over the whole screen, which needs nothing hidden underneath it.
+ ///
+ internal bool ShowsHostList => !IsEditingGroup;
+
+ ///
+ /// Whether the floating + is drawn.
+ ///
+ ///
+ /// Hidden rather than disabled, in three cases and for one reason each. Over an editor or a sheet it
+ /// would open a form on top of a form. While hosts are ticked it would offer to add a twelfth machine
+ /// beside eleven chosen for deletion — a control that does something, where the something is not what
+ /// the screen is about. And under one of the action bar's panels it would be a large accented circle
+ /// over a question waiting to be answered.
+ ///
+ internal bool ShowsAddButton => !AnEditorIsOpen && !IsChoosingHosts && !AChosenHostPanelIsOpen;
+
+ // ---- ◆ The phone's chosen hosts ----
+ //
+ // THE CONNECT BAR WAS HERE, AND WHAT REPLACED IT IS A SELECTION RATHER THAN A PANEL.
+ //
+ // ShowsConnectBar, ShowsConnectControls and CanEditSelectedHost went with it. The bar was a card over the
+ // bottom of the list carrying a password box, CONNECT, EDIT, MOVE and DELETE — raised by a long press
+ // since a tap started connecting, which made it a menu drawn as a form, in the place a menu is hardest to
+ // reach. A long press now *chooses* the host it landed on and the actions move into a bar across the top,
+ // which is where Android has put them since contextual action bars existed and is the one strip of the
+ // screen a list can never grow into.
+ //
+ // The set is what the actions act on, and the count in that bar is why it is a set rather than one row:
+ // filing eleven imported machines under a group, or clearing out a vault, is the case the old bar could
+ // not express at all. Everything below is about keeping "which hosts" honest across a list that is
+ // rebuilt on every filter keystroke and every background sync.
+
+ ///
+ /// The hosts the action bar is about, by entity id.
///
///
///
- /// ◆ Selecting a host no longer raises this bar, and is the difference.
- /// It used to read SelectedHost is not null, so a tap on any row put a card over the bottom of the
- /// list carrying a password box, CONNECT, EDIT, MOVE and DELETE — five controls in the way of the one
- /// thing a tap on a machine's name means, which is connect to it. A tap connects now; a long press asks
- /// for this. See and .
+ /// Ids rather than rows, for the reason the deletion question carries one: every row object in
+ /// is replaced on every reload, so a set of rows would empty itself once a minute
+ /// under somebody who was still choosing what to do with them. The rows carry
+ /// for the tick, and it is written back onto the new rows from
+ /// this — see .
///
///
- /// It is the same flag and the same reasoning the desktop's drawer already used, which is why it is that
- /// flag rather than a second one: the question both heads are asking is "has somebody asked about this
- /// host", and answering it twice is how two heads come to disagree about what a selection means.
- ///
- ///
- /// The second half stays and is still worth stating: the editor cards replace the list rather than
- /// floating over it, so a bar left showing underneath would carry CONNECT and EDIT for a host that is no
- /// longer on screen — and under the host editor, for the very record being typed into.
+ /// An id that no longer resolves is dropped rather than kept, which is what makes a colleague's deletion
+ /// arriving mid-selection leave a count that matches what is on screen.
///
///
- internal bool ShowsConnectBar => SelectedHost is not null && IsHostPaneOpen && !AnEditorIsOpen;
+ private readonly HashSet chosenHostIds = [];
///
- /// Whether the phone's header should be offering the pencil that edits the selected host.
+ /// Whether the phone is in selection mode: a long press has chosen at least one host.
///
///
- /// The other half of the gesture change above. With the bar no longer raised by choosing a machine, EDIT
- /// went behind a long press — so the header carries a pencil for the row that is chosen, which
- /// is the one control a thumb can reach without opening anything. It is hidden rather than disabled while
- /// an editor is up, because a pencil that opens the form already on screen is a control with nothing to
- /// do.
- ///
- /// It deliberately does not ask which screen is showing. That is the shell's question and this is the
- /// vault's; the header wraps this in the shell's own IsHostsShowing, which is the arrangement
- /// PhoneShell already uses everywhere Avalonia's bindings need an "and".
- ///
+ /// 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.
///
- internal bool CanEditSelectedHost => SelectedHost is not null && !AnEditorIsOpen;
+ internal bool IsChoosingHosts => chosenHostIds.Count > 0;
+
+ /// How many hosts are ticked.
+ internal int ChosenHostCount => chosenHostIds.Count;
+
+ /// What the action bar prints between the cross and the pencil.
+ ///
+ /// The count alone, because the bar it sits in is already the thing saying what the number is about, and
+ /// "6 hosts selected" beside a pencil and a menu at 360dp spends the width the two icons need.
+ ///
+ internal string ChosenHostsLabel =>
+ ChosenHostCount.ToString(CultureInfo.CurrentCulture);
+
+ /// The chosen hosts, as the rows currently in the list.
+ ///
+ /// Rebuilt per read rather than kept, because the rows it names are replaced on every reload and this is
+ /// only ever asked at the moment an action runs. Ordered as the list is, so a status line naming the
+ /// first of them names the one nearest the top of the screen.
+ ///
+ internal IReadOnlyList ChosenHosts =>
+ [.. Hosts.Where(row => chosenHostIds.Contains(row.EntityId))];
///
- /// Whether the phone's connect bar is showing its own controls rather than one of the two panels that
- /// take their place.
+ /// The one chosen host, where exactly one is chosen; otherwise null.
+ ///
+ ///
+ /// Three of the bar's entries are about one machine and cannot be made to mean anything about six —
+ /// editing it, opening a shell on it, and browsing its files, the last two because a terminal and an SFTP
+ /// session are each a connection somebody is about to look at. They are left out of the bar rather than
+ /// refused from it; see .
+ ///
+ internal HostRowViewModel? TheChosenHost => chosenHostIds.Count == 1
+ ? Hosts.FirstOrDefault(row => chosenHostIds.Contains(row.EntityId))
+ : null;
+
+ /// Whether the bar should be offering the entries that are about a single machine.
+ internal bool HasOneChosenHost => TheChosenHost is not null;
+
+ ///
+ /// Whether the menu behind the action bar's ⋯ is showing.
+ ///
+ ///
+ /// The add sheet's shape doing the same job the group sheet already does — a scrim and a bottom panel —
+ /// rather than a flyout under the button. A flyout hangs from the top-right corner of a 360dp screen and
+ /// puts seven entries under a thumb that is holding the phone at the bottom; a sheet puts them where the
+ /// hand is. It is also the one arrangement that has room for the sentence under each entry, and three of
+ /// these seven need one.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
+ private bool isHostActionSheetOpen;
+
+ ///
+ /// Whether the panel asking which vault the chosen hosts should go to is up, and which of the two
+ /// questions it is asking.
///
///
- /// The bar carries three things and draws one of them: connecting to the host, the picker asking which
- /// vault to move it to, and the question asking whether to delete it. This is the first, and the reason
- /// it is a property rather than two conditions in the markup is that Avalonia's bindings have no "and".
///
- /// It is the same rule carries for the desktop's drawer, minus that
- /// one's IsShowingHostDetail: the phone has no pane to open, so its bar's own
- /// already answers whether there is a host at all.
+ /// One panel and a mode rather than two panels, because a move and a copy differ in one verb and in the
+ /// sentence under the picker — everything else, from which vaults are offered to what is left behind, is
+ /// the same question. Two copies of it would be two places to fix the day a third vault rule arrives.
+ ///
+ ///
+ /// Separate from , which is the desktop drawer's panel about the one selected
+ /// host. Sharing that one would have made the phone's bar and the desktop's pane disagree about what
+ /// "the host" means the moment a selection and a set were both non-empty.
///
///
- internal bool ShowsConnectControls => !IsMovingHost && !IsConfirmingHostDeletion;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(AChosenHostPanelIsOpen))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelTitle))]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelNote))]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelVerb))]
+ private bool isSendingChosenHostsToAVault;
+
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelTitle))]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelNote))]
+ [NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelVerb))]
+ private bool chosenHostsAreBeingCopied;
+
+ /// What that panel's heading says.
+ internal string ChosenHostVaultPanelTitle =>
+ ChosenHostsAreBeingCopied ? "COPY TO VAULT" : "MOVE TO VAULT";
+
+ /// What its button says.
+ internal string ChosenHostVaultPanelVerb => ChosenHostsAreBeingCopied ? "COPY" : "MOVE";
+
+ ///
+ /// The sentence under the picker, which is different for the two and not decoration in either case.
+ ///
+ ///
+ /// A move leaves the group and the tags behind because both are items of the vault being left — the same
+ /// sentence has always had to say. A copy leaves the original where it
+ /// is, and that is worth saying rather than assuming: a second readable copy of a machine's details is
+ /// exactly what somebody sharing one host with a team wants and exactly what somebody who meant to move
+ /// it does not.
+ ///
+ internal string ChosenHostVaultPanelNote => ChosenHostsAreBeingCopied
+ ? "A second copy is written, encrypted with the other vault's key, and the original stays where it "
+ + "is. Groups and tags do not come across — both belong to the vault being copied from — so the "
+ + "copies arrive filed under nothing."
+ : "Each host is re-encrypted with the other vault's key, so everybody who holds that key can read it "
+ + "and nobody else can. Groups and tags stay behind — both belong to the vault being left.";
+
+ /// Where the chosen hosts could go: every vault this session can write to.
+ ///
+ /// Every one of them rather than "all but their own", which is what the single-host picker offers. A set
+ /// may span vaults, so there is no single vault to leave out — a host already in the destination is
+ /// skipped when the panel is answered, and said so, rather than shrinking the list it was chosen from.
+ ///
+ internal ObservableCollection ChosenHostVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedChosenHostVault;
+
+ ///
+ /// Whether the panel asking which group the chosen hosts should be filed under is up.
+ ///
+ ///
+ /// The one action in the bar that has always existed and had no phone control: filing is
+ /// , which is what dragging a card onto a group does on the desktop, and
+ /// there is no dragging here. It is the reason the set is worth having at all — thirty imported machines
+ /// under one heading is one gesture rather than thirty rounds of open, pick, save.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(AChosenHostPanelIsOpen))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
+ private bool isRegroupingChosenHosts;
+
+ /// What the chosen hosts could be filed under: one vault's groups, and "no group".
+ ///
+ internal ObservableCollection ChosenHostGroupChoices { get; } = [];
+
+ [ObservableProperty]
+ private GroupChoice? selectedChosenHostGroup;
[ObservableProperty]
private string editorLabel = string.Empty;
@@ -2777,6 +2972,22 @@ internal sealed partial class VaultViewModel(
///
internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
+ ///
+ internal bool IsConfirmingChosenHostDeletion =>
+ PendingDeletion?.Target is DeletionTarget.ChosenHosts;
+
+ ///
+ /// Whether the phone's list has one of the action bar's panels drawn above it.
+ ///
+ ///
+ /// The bar's menu raises three things that are not immediate — which vault, which group, and the deletion
+ /// question — and at most one of them is ever up. One property because two controls need exactly this
+ /// question: the floating + stands down while a panel about six other hosts is on screen, and the
+ /// back gesture closes the panel before it considers anything else.
+ ///
+ internal bool AChosenHostPanelIsOpen =>
+ IsSendingChosenHostsToAVault || IsRegroupingChosenHosts || IsConfirmingChosenHostDeletion;
+
///
/// What a group command with no argument acts on: the card that is selected, or the group that is open.
///
@@ -2822,6 +3033,32 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string connectPassword = string.Empty;
+ ///
+ /// Whether the phone is asking for a password before it can finish a tap.
+ ///
+ ///
+ ///
+ /// ◆ What is left of the connect bar, and the only part of it worth keeping. The bar carried a
+ /// password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list; four of those are in the
+ /// action bar now — see — and this is the fifth. A host that authenticates with
+ /// a typed password has nowhere on a phone to be given one, so the tap that cannot finish raises a sheet
+ /// with the box in it rather than doing nothing or, worse, dialling with no password.
+ ///
+ ///
+ /// A sheet rather than the bar it replaces, and that is not cosmetic: the bar was raised by a long press
+ /// and stayed up, so it was a set of controls sitting over the list whether or not anything was being
+ /// asked. This is up only while a question is on screen, and answering or dismissing it takes it away.
+ ///
+ ///
+ /// Not set on this head alone but only ever read on it: the desktop's drawer has a password box that is
+ /// always on screen for a host that wants one, so it has no moment where the question has to be raised.
+ ///
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
+ [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
+ private bool isAskingForConnectPassword;
+
/// What was typed into the manual connect box, as user@host or user@host:port.
///
/// One box rather than four, because this is the form of an address people already have: it is what a
@@ -3034,6 +3271,10 @@ internal sealed partial class VaultViewModel(
///
internal event EventHandler? ConnectionFailed;
+ /// Raised when somebody asks to browse a host's files rather than open a shell on it.
+ ///
+ internal event EventHandler? FilesRequested;
+
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
@@ -3229,6 +3470,7 @@ internal sealed partial class VaultViewModel(
}
SelectedHost = SelectionAfterReload(selectedId);
+ ApplyTheChosenHosts();
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
// the group rows.
@@ -4713,6 +4955,720 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void CloseAddSheet() => IsAddSheetOpen = false;
+ // ---- ◆ Choosing hosts, and the seven things the bar can do to them ----
+
+ ///
+ /// Puts a tick against the host a long press landed on, entering selection mode with it.
+ ///
+ /// The host that was held.
+ ///
+ ///
+ /// Adds rather than toggles, and the difference is what makes the gesture safe to repeat: a long press on
+ /// a host that is already ticked leaves it ticked, where a toggle would take the selection off a machine
+ /// somebody was holding down on to make sure of. Untick is what a tap is for once the mode is up — see
+ /// .
+ ///
+ ///
+ /// Refused while anything is over the list. A sheet or an editor is what the gesture would be aimed
+ /// through, and the row underneath is not what the finger was on.
+ ///
+ ///
+ [RelayCommand]
+ private void ChooseHost(HostRowViewModel? row)
+ {
+ if (row is null || AnEditorIsOpen)
+ {
+ return;
+ }
+
+ chosenHostIds.Add(row.EntityId);
+ ApplyTheChosenHosts();
+ }
+
+ ///
+ /// Adds or removes one host, which is what a tap means once the bar is up.
+ ///
+ ///
+ /// 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
+ /// 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.
+ ///
+ [RelayCommand]
+ private void ToggleHostChoice(HostRowViewModel? row)
+ {
+ if (row is null)
+ {
+ return;
+ }
+
+ if (!chosenHostIds.Remove(row.EntityId))
+ {
+ chosenHostIds.Add(row.EntityId);
+ }
+
+ if (chosenHostIds.Count == 0)
+ {
+ ClearHostChoice();
+ return;
+ }
+
+ ApplyTheChosenHosts();
+ }
+
+ /// Leaves selection mode, which is the cross at the left of the bar.
+ ///
+ /// It takes the menu and whichever panel was raised from it, because all three are about the set: a
+ /// deletion question left armed over an empty selection would be a question with no answer, and the
+ /// vault picker would be offering to move nothing.
+ ///
+ [RelayCommand]
+ private void ClearHostChoice()
+ {
+ chosenHostIds.Clear();
+ IsHostActionSheetOpen = false;
+ CloseTheChosenHostPanels();
+ ApplyTheChosenHosts();
+ }
+
+ /// Raises the menu behind the bar's ⋯.
+ [RelayCommand]
+ private void OpenHostActionSheet()
+ {
+ if (!IsChoosingHosts)
+ {
+ return;
+ }
+
+ IsHostActionSheetOpen = true;
+ }
+
+ /// Lowers it without choosing anything.
+ [RelayCommand]
+ private void CloseHostActionSheet() => IsHostActionSheetOpen = false;
+
+ ///
+ /// Opens a terminal on the one chosen host.
+ ///
+ ///
+ /// The bar's entry for what a tap already does, and it is in the menu because the bar is what a long
+ /// press leaves you in: without it, connecting to the machine you had just chosen would mean leaving
+ /// selection mode first. It goes through so that a host wanting a typed
+ /// password raises the same sheet a tap does rather than failing quietly.
+ ///
+ [RelayCommand(AllowConcurrentExecutions = true)]
+ private Task ConnectToChosenHostAsync()
+ {
+ IsHostActionSheetOpen = false;
+
+ if (TheChosenHost is not { } row)
+ {
+ return Task.CompletedTask;
+ }
+
+ ClearHostChoice();
+
+ return ConnectToRowAsync(row);
+ }
+
+ ///
+ /// Opens the files screen on the one chosen host.
+ ///
+ ///
+ ///
+ /// It raises rather than doing anything itself, because the screen and the
+ /// view model behind it are the shell's — see the event. What this side owns is which machine, and that
+ /// is a decrypted item.
+ ///
+ ///
+ /// Single-host only, and not because a loop would be hard: there is one file-transfer session behind that
+ /// screen, so six hosts would be five connections nobody could look at.
+ ///
+ ///
+ [RelayCommand]
+ private void BrowseChosenHost()
+ {
+ IsHostActionSheetOpen = false;
+
+ if (TheChosenHost is not { } row)
+ {
+ return;
+ }
+
+ ClearHostChoice();
+ FilesRequested?.Invoke(this, new HostFilesEventArgs(row));
+ }
+
+ /// Opens the editor on the one chosen host.
+ ///
+ /// The pencil at the right of the bar, and the one entry that is not in the menu: it is the action people
+ /// reach for most and it is worth a control that does not need a menu opened first. It leaves selection
+ /// mode, because the editor is a page over the list and a bar counting hosts above a form about one of
+ /// them would be two answers to "what is this screen about".
+ ///
+ [RelayCommand]
+ private void EditChosenHost()
+ {
+ IsHostActionSheetOpen = false;
+
+ if (TheChosenHost is not { } row)
+ {
+ return;
+ }
+
+ SelectedHost = row;
+ ClearHostChoice();
+ EditSelectedHostCommand.Execute(null);
+ }
+
+ /// Asks which vault the chosen hosts should move to.
+ [RelayCommand]
+ private void MoveChosenHostsToVault() => SendChosenHostsToAVault(copying: false);
+
+ /// Asks which vault the chosen hosts should be copied into.
+ [RelayCommand]
+ private void CopyChosenHostsToVault() => SendChosenHostsToAVault(copying: true);
+
+ ///
+ /// Raises the one picker both of those use.
+ ///
+ ///
+ /// Refused with the host editor open, as every other write from this list is: the editor holds a
+ /// half-typed version of a record this would rewrite underneath it.
+ ///
+ private void SendChosenHostsToAVault(bool copying)
+ {
+ IsHostActionSheetOpen = false;
+
+ if (!IsChoosingHosts || AHostEditorIsInTheWay())
+ {
+ return;
+ }
+
+ BuildChosenHostVaultChoices();
+
+ if (ChosenHostVaultChoices.Count == 0)
+ {
+ // The one-vault case, and the honest sentence rather than an empty picker. It is also what
+ // somebody in a team whose only other vault is read-only sees.
+ Status = copying
+ ? "There is nowhere to copy these to: this is the only keychain you can write to."
+ : "There is nowhere to move these to: this is the only keychain you can write to.";
+ return;
+ }
+
+ // At most one panel over the list, which is the rule the group panels already keep between
+ // themselves: two questions about the same six machines, one of which destroys them, is not
+ // something anybody should have to read carefully.
+ PendingDeletion = null;
+ IsRegroupingChosenHosts = false;
+ ChosenHostsAreBeingCopied = copying;
+ IsSendingChosenHostsToAVault = true;
+ Status = string.Empty;
+ }
+
+ /// Abandons the vault picker.
+ [RelayCommand]
+ private void CancelSendChosenHostsToAVault()
+ {
+ IsSendingChosenHostsToAVault = false;
+ ChosenHostVaultChoices.Clear();
+ SelectedChosenHostVault = null;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Moves or copies every chosen host into the vault that was picked.
+ ///
+ ///
+ ///
+ /// The group and the tags are left behind either way, which is the rule
+ /// carries at length and which a copy does not escape: both are items
+ /// of the vault being left, so a host arriving with either would point at something the destination does
+ /// not contain — resolvable here and dangling for everybody else in it.
+ ///
+ ///
+ /// Three kinds of host are skipped rather than refused, and each is counted into the sentence
+ /// afterwards. One written by a newer client cannot be re-encoded here without losing fields; one already
+ /// in the destination has nowhere to go; and a copy of a host into its own vault is
+ /// rather than this. Skipping beats refusing the whole run,
+ /// because a selection of eleven with one read-only row would otherwise do nothing at all and say so
+ /// about the wrong ten.
+ ///
+ ///
+ /// One reload and one sync at the end rather than per host. Both are the expensive half, and a run over
+ /// eleven machines that reloaded eleven times would replace every row in the list under a user watching
+ /// it.
+ ///
+ ///
+ [RelayCommand]
+ private async Task ConfirmSendChosenHostsToAVaultAsync(CancellationToken cancellationToken)
+ {
+ if (SelectedChosenHostVault is not { } target || !IsChoosingHosts)
+ {
+ return;
+ }
+
+ var copying = ChosenHostsAreBeingCopied;
+ var rows = ChosenHosts;
+
+ IsSendingChosenHostsToAVault = false;
+ ChosenHostVaultChoices.Clear();
+ SelectedChosenHostVault = null;
+
+ var done = 0;
+ var skipped = 0;
+
+ await RunAsync(
+ copying ? "Copying…" : "Moving…",
+ async () =>
+ {
+ foreach (var row in rows)
+ {
+ if (row.IsReadOnly || row.VaultId == target.VaultId)
+ {
+ skipped++;
+ continue;
+ }
+
+ _ = await SendOneHostToAVaultAsync(row, target.VaultId, copying, cancellationToken)
+ .ConfigureAwait(true);
+
+ done++;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ ClearHostChoice();
+
+ var verb = copying ? "Copied" : "Moved";
+
+ Status = WhatTheRunDid(
+ done,
+ skipped,
+ $"{verb} {done} host(s) to {target.Name}.",
+ copying ? "nothing was copied" : "nothing was moved");
+ }).ConfigureAwait(true);
+
+ // As a save and a deletion are. A move is two writes in two vaults, and a machine that syncs one of
+ // them and not the other shows the host twice or not at all until the next pass.
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// Writes one host into another vault, leaving the original behind or not.
+ ///
+ /// The group and the tags are dropped on the way across in both directions, which is the one rule the two
+ /// verbs share and the reason they share a method: a host arriving with either would point at an item the
+ /// destination does not contain.
+ ///
+ /// The entity id the host has in the destination, which nothing here needs.
+ private Task SendOneHostToAVaultAsync(
+ HostRowViewModel row,
+ Guid target,
+ bool copying,
+ CancellationToken cancellationToken)
+ {
+ var payload = row.Host with { GroupId = null, TagIds = TagSet.Empty };
+
+ return copying
+ ? session.Hosts.CreateAsync(target, payload, cancellationToken)
+ : session.Hosts.MoveAsync(row.VaultId, target, row.EntityId, payload, cancellationToken);
+ }
+
+ ///
+ /// Asks which group the chosen hosts should be filed under.
+ ///
+ ///
+ ///
+ /// One keychain's groups, so a selection spanning two is refused rather than half-filed. A group is
+ /// an item of one vault: filing a host from another under it would leave everybody but the person who did
+ /// it seeing a machine filed under nothing. That is the same refusal makes
+ /// for a card dragged across the boundary on the desktop, made once here instead of per host.
+ ///
+ ///
+ /// "No group" is an entry rather than an omission, because unfiling a run of machines is exactly as
+ /// common as filing them — it is what somebody does after deleting a heading and finding its hosts under
+ /// UNGROUPED by another name.
+ ///
+ ///
+ [RelayCommand]
+ private void RegroupChosenHosts()
+ {
+ IsHostActionSheetOpen = false;
+
+ if (!IsChoosingHosts || AHostEditorIsInTheWay())
+ {
+ return;
+ }
+
+ var vaults = ChosenHosts.Select(row => row.VaultId).Distinct().ToList();
+
+ if (vaults.Count != 1)
+ {
+ Status = "These hosts are in more than one keychain, and a group belongs to one. Choose hosts "
+ + "from a single keychain to file them together.";
+ return;
+ }
+
+ BuildChosenHostGroupChoices(vaults[0]);
+
+ PendingDeletion = null;
+ IsSendingChosenHostsToAVault = false;
+ IsRegroupingChosenHosts = true;
+ Status = string.Empty;
+ }
+
+ /// Abandons the group picker.
+ [RelayCommand]
+ private void CancelRegroupChosenHosts()
+ {
+ IsRegroupingChosenHosts = false;
+ ChosenHostGroupChoices.Clear();
+ SelectedChosenHostGroup = null;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Files every chosen host under the group that was picked.
+ ///
+ ///
+ /// The same write makes for one dragged card, and deliberately the same
+ /// one: the group is the single field of a host that is about arrangement rather than about the machine,
+ /// which is why it is the only one anything changes without opening the editor.
+ ///
+ [RelayCommand]
+ private async Task ConfirmRegroupChosenHostsAsync(CancellationToken cancellationToken)
+ {
+ if (SelectedChosenHostGroup is not { } choice || !IsChoosingHosts)
+ {
+ return;
+ }
+
+ var rows = ChosenHosts;
+ var name = choice.EntityId is null ? "no group" : choice.Label;
+
+ IsRegroupingChosenHosts = false;
+ ChosenHostGroupChoices.Clear();
+ SelectedChosenHostGroup = null;
+
+ var done = 0;
+ var skipped = 0;
+
+ await RunAsync(
+ $"Filing under {name}…",
+ async () =>
+ {
+ foreach (var row in rows)
+ {
+ if (row.IsReadOnly)
+ {
+ skipped++;
+ continue;
+ }
+
+ if (row.Host.GroupId == choice.EntityId)
+ {
+ // Already there. Counted as done rather than skipped: the user asked for these hosts
+ // to be under this heading, and they are.
+ done++;
+ continue;
+ }
+
+ await session.Hosts
+ .UpdateAsync(
+ row.VaultId,
+ row.EntityId,
+ row.Host with { GroupId = choice.EntityId },
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ done++;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ ClearHostChoice();
+
+ Status = WhatTheRunDid(
+ done, skipped, $"Filed {done} host(s) under {name}.", "nothing was filed");
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ ///
+ /// Writes a second copy of every chosen host beside it, in its own vault.
+ ///
+ ///
+ ///
+ /// The group and the tags do come with it, which is the whole difference between this and a copy
+ /// into another vault: the duplicate stays in the same keychain, so everything it points at is still
+ /// there. It is what somebody does before changing one field on a machine they do not want to lose the
+ /// old shape of.
+ ///
+ ///
+ /// The name gets " copy" and nothing else — no numbering, and duplicates of duplicates are allowed to
+ /// stack. Two hosts called the same thing are not wrong here for the reason two groups are not: hosts are
+ /// pointed at by id, the row says which keychain it is in, and a name this application refused would be a
+ /// name the next sync could hand it anyway.
+ ///
+ ///
+ [RelayCommand]
+ private async Task DuplicateChosenHostsAsync(CancellationToken cancellationToken)
+ {
+ IsHostActionSheetOpen = false;
+
+ if (!IsChoosingHosts || AHostEditorIsInTheWay())
+ {
+ return;
+ }
+
+ var rows = ChosenHosts;
+ var done = 0;
+ var skipped = 0;
+
+ await RunAsync(
+ "Duplicating…",
+ async () =>
+ {
+ foreach (var row in rows)
+ {
+ if (row.IsReadOnly)
+ {
+ skipped++;
+ continue;
+ }
+
+ await session.Hosts
+ .CreateAsync(
+ row.VaultId,
+ row.Host with { Label = row.Host.Label + " copy" },
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ done++;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ ClearHostChoice();
+
+ Status = WhatTheRunDid(
+ done, skipped, $"Duplicated {done} host(s).", "nothing was duplicated");
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ ///
+ /// Asks whether the chosen hosts should go.
+ ///
+ ///
+ /// One question naming a count rather than one question per host, which is the reason
+ /// exists. Terminals still open on any of them are disclosed for
+ /// the reason discloses one: a deletion does not close a session, and somebody
+ /// removing machines they are still working on should know that is what they have done.
+ ///
+ [RelayCommand]
+ private void DeleteChosenHosts()
+ {
+ IsHostActionSheetOpen = false;
+
+ if (!IsChoosingHosts)
+ {
+ return;
+ }
+
+ var rows = ChosenHosts;
+ var live = rows.Count(row => row.IsConnected);
+
+ IsSendingChosenHostsToAVault = false;
+ IsRegroupingChosenHosts = false;
+
+ PendingDeletion = new DeletionRequest(
+ DeletionTarget.ChosenHosts,
+ Guid.Empty,
+ rows.Count == 1
+ ? $"Delete the host '{rows[0].Label}'?"
+ : $"Delete these {rows.Count} hosts?",
+ HowFarADeletionGoes(
+ rows.Count == 1
+ ? "The host and everything saved about it"
+ : "The hosts and everything saved about them"),
+ live switch
+ {
+ 0 => string.Empty,
+ 1 => "A terminal is open on one of them. It stays open — deleting a host does not close it, "
+ + "and nothing will reopen it afterwards.",
+ _ => $"Terminals are open on {live} of them. They stay open — deleting a host does not close "
+ + "one, and nothing will reopen them afterwards.",
+ });
+ }
+
+ /// Queues a tombstone for every host that was agreed to.
+ ///
+ /// Read-only rows are skipped, as they are in every other run over the set, and for the same reason a
+ /// deletion is otherwise allowed on one: a tombstone re-encodes nothing, but the row was written by a
+ /// client this build does not fully understand and removing it here would be acting on a record it cannot
+ /// read back.
+ ///
+ private async Task DeleteChosenHostsNowAsync(CancellationToken cancellationToken)
+ {
+ var rows = ChosenHosts;
+ var done = 0;
+ var skipped = 0;
+
+ await RunAsync(
+ "Deleting…",
+ async () =>
+ {
+ foreach (var row in rows)
+ {
+ if (row.IsReadOnly)
+ {
+ skipped++;
+ continue;
+ }
+
+ await session.Hosts
+ .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
+ .ConfigureAwait(true);
+
+ done++;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ ClearHostChoice();
+
+ Status = WhatTheRunDid(
+ done, skipped, $"Deleted {done} host(s).", "nothing was deleted");
+ }).ConfigureAwait(true);
+
+ // As with saving: a tombstone is worth pushing straight away, so the items do not reappear on
+ // another machine that syncs before the next tick.
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ ///
+ /// What a run over the set says afterwards, counting what it left alone.
+ ///
+ /// How many hosts the run actually wrote.
+ /// How many it passed over.
+ /// The sentence for what it did, naming the count.
+ /// What to say instead when the run wrote nothing at all.
+ ///
+ /// The skipped count is stated rather than swallowed, because it is the difference between "eleven hosts
+ /// moved" and "ten moved and one you will find still here tomorrow". Why each one was passed over is not
+ /// spelled out per host: the rows carry a badge saying they were written by a newer client, which is the
+ /// only reason a run stops short of a machine that was in the destination already.
+ ///
+ private static string WhatTheRunDid(int done, int skipped, string did, string didNothing)
+ {
+ var left = skipped == 0
+ ? string.Empty
+ : $" {skipped} were left alone — a host already in the keychain being written to, or one written "
+ + "by a newer version of DodoSSH.";
+
+ return done == 0 ? $"{char.ToUpperInvariant(didNothing[0])}{didNothing[1..]}.{left}" : did + left;
+ }
+
+ /// Fills the picker with every vault the chosen hosts could be sent to.
+ ///
+ /// Every writable vault where the set spans more than one of them, and all but their own where it does
+ /// not. A set from two keychains has no single vault to leave out, and shrinking the list to the
+ /// intersection would offer nothing at all for a selection that straddles the only two vaults there are.
+ ///
+ private void BuildChosenHostVaultChoices()
+ {
+ ChosenHostVaultChoices.Clear();
+
+ var vaults = ChosenHosts.Select(row => row.VaultId).Distinct().ToList();
+
+ var choices = vaults.Count == 1
+ ? WritableVaultsBesides(vaults[0])
+ : session.ReadableVaults
+ .Where(vault => vault.CanWrite)
+ .OrderByDescending(vault => vault.IsPersonal)
+ .ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
+ .Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
+
+ foreach (var choice in choices)
+ {
+ ChosenHostVaultChoices.Add(choice);
+ }
+
+ SelectedChosenHostVault = ChosenHostVaultChoices.FirstOrDefault();
+ }
+
+ /// Fills the group picker with one vault's groups, and the entry that files under none.
+ private void BuildChosenHostGroupChoices(Guid vaultId)
+ {
+ ChosenHostGroupChoices.Clear();
+ ChosenHostGroupChoices.Add(GroupChoice.None);
+
+ if (groupsByVault.TryGetValue(vaultId, out var groups))
+ {
+ foreach (var group in groups)
+ {
+ ChosenHostGroupChoices.Add(group);
+ }
+ }
+
+ SelectedChosenHostGroup = ChosenHostGroupChoices[0];
+ }
+
+ /// Folds away whatever the bar's menu raised over the list.
+ private void CloseTheChosenHostPanels()
+ {
+ IsSendingChosenHostsToAVault = false;
+ ChosenHostVaultChoices.Clear();
+ SelectedChosenHostVault = null;
+
+ IsRegroupingChosenHosts = false;
+ ChosenHostGroupChoices.Clear();
+ SelectedChosenHostGroup = null;
+
+ if (IsConfirmingChosenHostDeletion)
+ {
+ PendingDeletion = null;
+ }
+ }
+
+ ///
+ /// Writes the ticks back onto the rows and tells the bar what it is about.
+ ///
+ ///
+ /// Called after every change to the set and after every reload, because the two are the same problem seen
+ /// from either end: the set outlives the rows, and the rows are what the ticks are drawn on. Ids that no
+ /// longer resolve are dropped here rather than left, so a colleague's deletion arriving mid-selection
+ /// leaves a count that matches what is on screen.
+ ///
+ private void ApplyTheChosenHosts()
+ {
+ if (chosenHostIds.Count > 0)
+ {
+ chosenHostIds.RemoveWhere(id => !Hosts.Any(row => row.EntityId == id));
+ }
+
+ foreach (var row in Hosts)
+ {
+ row.IsChosen = chosenHostIds.Contains(row.EntityId);
+ }
+
+ // 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)
+ {
+ IsHostActionSheetOpen = false;
+ CloseTheChosenHostPanels();
+ }
+
+ OnPropertyChanged(nameof(IsChoosingHosts));
+ OnPropertyChanged(nameof(ShowsAddButton));
+ OnPropertyChanged(nameof(ChosenHostCount));
+ OnPropertyChanged(nameof(ChosenHostsLabel));
+ OnPropertyChanged(nameof(ChosenHosts));
+ OnPropertyChanged(nameof(TheChosenHost));
+ OnPropertyChanged(nameof(HasOneChosenHost));
+ }
+
///
/// Opens the pane about one host, on the card the pencil was pressed on.
///
@@ -4750,6 +5706,40 @@ internal sealed partial class VaultViewModel(
IsHostPaneOpen = true;
}
+ ///
+ /// Raises whatever the head in front of the user uses to ask about one host.
+ ///
+ /// The machine, which the caller is holding.
+ ///
+ ///
+ /// ◆ One command, two pieces of furniture, and it exists because the phone's has changed. The
+ /// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
+ /// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
+ /// that is a different piece of state, so a caller that only opened the pane would leave this head
+ /// arriving at a list with nothing on it to press.
+ ///
+ ///
+ /// Both are set rather than branching on which head is running, because neither view model knows and
+ /// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
+ /// phone has no drawer.
+ ///
+ ///
+ /// The one caller is MainWindowViewModel.ConnectToRecent, and what it is doing is the deliberate
+ /// act both flags exist to tell apart from browsing.
+ ///
+ ///
+ [RelayCommand]
+ private void AskAboutHost(HostRowViewModel? row)
+ {
+ if (row is null)
+ {
+ return;
+ }
+
+ OpenHostPane(row);
+ ChooseHost(row);
+ }
+
///
/// Puts the drawer away, whichever of the three panels is in it.
///
@@ -7337,6 +8327,10 @@ internal sealed partial class VaultViewModel(
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
+ case DeletionTarget.ChosenHosts:
+ await DeleteChosenHostsNowAsync(cancellationToken).ConfigureAwait(true);
+ break;
+
case DeletionTarget.Key:
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
@@ -7515,19 +8509,19 @@ internal sealed partial class VaultViewModel(
/// The phone's tap, and it lives here rather than in the head because the branch is a product rule
/// rather than a gesture. Which gesture means "open this" is the head's business — that is why the
/// files screen maps its own tap in code-behind — but whether this machine can be reached without
- /// asking for anything is the same question the connect bar's own password box answers, and a copy
- /// of it in a view would be a second reading of a binding chain that already has one.
+ /// asking for anything is the same question the password sheet's own box answers, and a copy of it
+ /// in a view would be a second reading of a binding chain that already has one.
///
///
/// The two outcomes are both "connect": one of them arrives, and the other needs a secret first and so
- /// puts the bar up with the box in it and says so. What it must never do is quietly connect with no
- /// password, or open a bar for a host that did not need one — that bar is five controls over the bottom
- /// of the list, and it is what a tap used to raise for every machine.
+ /// raises the password sheet and says so. What it must never do is quietly connect with no password, or
+ /// raise the sheet for a host that did not need one. See , which
+ /// is what is left of the bar this used to open for every machine.
///
///
- /// A successful tap closes the bar. Tapping a second machine while the first one's bar is up would
- /// otherwise leave the panel behind on the new selection, which is a bar nobody asked for, opened by the
- /// gesture that exists to avoid opening one.
+ /// A successful tap lowers the sheet. Tapping a second machine while the first one's sheet is up would
+ /// otherwise leave it behind on the new selection, which is a question nobody asked, raised by the
+ /// gesture that exists to avoid raising one.
///
///
/// It takes no cancellation token and allows concurrent executions, for the two reasons
@@ -7545,21 +8539,38 @@ internal sealed partial class VaultViewModel(
SelectedHost = row;
// Read after the assignment, because both are about the row that was just chosen. ConnectPassword
- // is checked as well as the binding: a bar already up with a password typed into it is exactly the
+ // is checked as well as the binding: a sheet already up with a password typed into it is exactly the
// second tap this should honour rather than answer with the same instruction again.
if (SelectedHostAsksForAPassword && ConnectPassword.Length == 0)
{
- IsHostPaneOpen = true;
- Status = $"{row.Label} asks for a password. Type it below, then CONNECT.";
+ IsAskingForConnectPassword = true;
+ Status = $"{row.Label} asks for a password.";
return Task.CompletedTask;
}
- IsHostPaneOpen = false;
+ IsAskingForConnectPassword = false;
return ConnectToSelectedHostAsync(CancellationToken.None);
}
+ ///
+ /// Lowers the password sheet without connecting, taking what was typed with it.
+ ///
+ ///
+ /// The box is cleared, on the same terms the files screen clears its own: it is a secret nobody asked to
+ /// keep, and leaving it behind would mean the next tap on a different machine starts with somebody else's
+ /// password already in the box — and, worse, would satisfy the length check above and dial with it.
+ ///
+ [RelayCommand]
+ private void CancelConnectPassword()
+ {
+ IsAskingForConnectPassword = false;
+ ConnectPassword = string.Empty;
+ RemembersConnectPassword = false;
+ Status = string.Empty;
+ }
+
///
///
/// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
@@ -7583,6 +8594,12 @@ internal sealed partial class VaultViewModel(
return;
}
+ // After the refusal and before the dial, so the phone's password sheet stays up for a box that was
+ // filled in wrongly and goes for one that is about to be used. It is set here rather than in the
+ // sheet's own button so that every way of connecting lowers it — a tap on another row, the host-key
+ // retry replaying an attempt, the action bar's CONNECT.
+ IsAskingForConnectPassword = false;
+
await ConnectToAsync(
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
authentication,
@@ -9045,8 +10062,6 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
- OnPropertyChanged(nameof(ShowsConnectBar));
- OnPropertyChanged(nameof(CanEditSelectedHost));
// Every field the drawer's detail pane draws. They are properties of the vault rather than of the
// row because two of them need the group chain read and one needs the keychain searched, and none of
@@ -9189,9 +10204,11 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsConfirmingDeletion));
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
+ OnPropertyChanged(nameof(IsConfirmingChosenHostDeletion));
+ OnPropertyChanged(nameof(AChosenHostPanelIsOpen));
+ OnPropertyChanged(nameof(ShowsAddButton));
OnPropertyChanged(nameof(ShowsHostActions));
OnPropertyChanged(nameof(ShowsHostPaneActions));
- OnPropertyChanged(nameof(ShowsConnectControls));
OnPropertyChanged(nameof(ShowsItemActions));
}
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 9fc2fad..0240b51 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -4768,53 +4768,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
}
- [Fact]
- public async Task TheConnectBar_GoesAwayWhileAnEditorIsUpRatherThanGreyingOut()
- {
- // The editors replace the list rather than floating over it, so a bar left in place would carry
- // CONNECT and EDIT for a host that is no longer on screen — and under the host editor, for the very
- // record being typed into. This was disabled rather than hidden first, which reads as a screen that
- // has broken rather than one that is busy.
- await UnlockedAsync();
- var vault = shell.Vault!;
-
- await AddHostAsync(vault, "prod-db");
-
- // The long press, which is the only thing that raises this bar. Selecting used to be enough.
- vault.OpenHostPaneCommand.Execute(Host(vault, "prod-db"));
-
- vault.ShowsConnectBar.ShouldBeTrue();
-
- vault.OpenAddSheetCommand.Execute(null);
- vault.ShowsConnectBar.ShouldBeFalse("the sheet is over the list");
-
- vault.NewHostCommand.Execute(null);
- vault.ShowsConnectBar.ShouldBeFalse("and the editor is in place of it");
-
- vault.CancelEditCommand.Execute(null);
- vault.ShowsConnectBar.ShouldBeTrue("and it comes back with the list");
- }
-
- [Fact]
- public async Task TheConnectBar_StaysAwayWithNoHostChosen()
- {
- await UnlockedAsync();
- var vault = shell.Vault!;
-
- await AddHostAsync(vault, "prod-db");
- vault.SelectedHost = null;
-
- vault.ShowsConnectBar.ShouldBeFalse();
- }
-
///
/// ◆ The gesture split, from the side that costs something to get wrong. Choosing a host used to
- /// raise the bar — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list — which
- /// meant a tap on a machine's name put five controls in the way of the one thing it obviously means.
- /// A tap connects now, and this pins that it raises nothing on the way past.
+ /// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list —
+ /// which meant a tap on a machine's name put five controls in the way of the one thing it obviously
+ /// means. A tap connects now, and this pins that it raises nothing on the way past.
///
[Fact]
- public async Task ATapOnAHost_ConnectsAndLeavesTheBarWhereItWas()
+ public async Task ATapOnAHost_ConnectsAndRaisesNothing()
{
var vault = await ReadyToConnectAsync();
@@ -4829,17 +4790,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("Connected", Case.Insensitive);
vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
- vault.ShowsConnectBar.ShouldBeFalse("and nothing was raised over the list to do it");
+ vault.IsAskingForConnectPassword.ShouldBeFalse("and nothing was raised over the list to do it");
+ vault.IsChoosingHosts.ShouldBeFalse("a tap is not a way into selection mode");
}
///
- /// The one tap that cannot finish, and the reason the bar still exists. A host that authenticates with a
- /// typed password has nowhere to be given one from a list, so the tap raises the bar with the box in it
- /// and says so. What it must never do is connect with no password, or leave somebody tapping a row that
- /// silently does nothing.
+ /// The one tap that cannot finish, and the whole of what is left of the connect card. A host that
+ /// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the
+ /// password sheet and says so. What it must never do is connect with no password, or leave somebody
+ /// tapping a row that silently does nothing.
///
[Fact]
- public async Task ATapOnAHostThatWantsAPassword_RaisesTheBarInsteadOfConnecting()
+ public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting()
{
var vault = await ReadyToConnectAsync();
@@ -4847,13 +4809,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
- vault.ShowsConnectBar.ShouldBeTrue("there is nowhere else to type it");
+ vault.IsAskingForConnectPassword.ShouldBeTrue("there is nowhere else to type it");
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
vault.Status.ShouldContain("password");
ssh.Requests.ShouldBeEmpty("nothing was dialled with no password");
- // The second tap, with the box filled in, is the one that goes through — otherwise the bar would be
- // answering the instruction it just gave with the same instruction again.
+ // The second tap, with the box filled in, is the one that goes through — otherwise the sheet would
+ // be answering the instruction it just gave with the same instruction again.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ConnectPassword = "typed-in";
@@ -4861,33 +4823,344 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType();
- vault.ShowsConnectBar.ShouldBeFalse("and the bar goes with the tap that succeeded");
+ vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
}
///
- /// The pencil in the phone's header, which is where EDIT went when the bar stopped being raised by
- /// choosing a machine. Both halves matter: it is offered for a chosen host, and it is taken away while an
- /// editor is up — a pencil that opens the form already on screen is a control with nothing to do.
+ /// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker
+ /// makes: a secret left in the box would be somebody else's password sitting in the field the next tap
+ /// reads — and, worse, it would satisfy the emptiness check that decides whether to raise the sheet at
+ /// all, so the next tap would dial with it.
///
[Fact]
- public async Task TheHeaderPencil_IsOfferedForAChosenHostAndNotOverAnEditor()
+ public async Task DismissingThePasswordSheet_EmptiesTheBox()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
+
+ vault.ConnectPassword = "half-typed";
+ vault.RemembersConnectPassword = true;
+
+ vault.CancelConnectPasswordCommand.Execute(null);
+
+ vault.IsAskingForConnectPassword.ShouldBeFalse();
+ vault.ConnectPassword.ShouldBeEmpty();
+ vault.RemembersConnectPassword.ShouldBeFalse("and the tick beside it is not carried either");
+ }
+
+ // ---- ◆ Choosing hosts, and the seven things the action bar does to them ----
+ //
+ // The connect card is gone and a long press chooses instead. What these pin is the shape of that: the
+ // set survives the things that used to empty it, the entries that are about one machine are offered only
+ // for one, and every run over the set says what it left alone.
+
+ ///
+ /// The two gestures, from the side the phone drives them. A long press adds rather than toggling — a
+ /// second one on a machine somebody is holding down on must not take the tick off — and a tap toggles
+ /// once the mode is up. Emptying the set leaves the mode, which is the other way out of it.
+ ///
+ [Fact]
+ public async Task ALongPressChoosesAHost_AndTapsTickAndUntickFromThereOn()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await AddHostAsync(vault, "staging");
+
+ vault.IsChoosingHosts.ShouldBeFalse();
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+
+ vault.IsChoosingHosts.ShouldBeTrue();
+ vault.ChosenHostCount.ShouldBe(1);
+ vault.HasOneChosenHost.ShouldBeTrue();
+ Host(vault, "prod-db").IsChosen.ShouldBeTrue("the tick is drawn on the row");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ vault.ChosenHostCount.ShouldBe(1, "a second long press on the same row is not an untick");
+
+ vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
+
+ vault.ChosenHostCount.ShouldBe(2);
+ vault.HasOneChosenHost.ShouldBeFalse("neither the pencil nor CONNECT is about two machines");
+
+ vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
+ vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-db"));
+
+ vault.IsChoosingHosts.ShouldBeFalse("unticking the last one leaves selection mode");
+ Host(vault, "prod-db").IsChosen.ShouldBeFalse();
+ }
+
+ ///
+ /// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
+ /// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
+ /// somebody choosing what to do with eleven machines.
+ ///
+ [Fact]
+ public async Task TheChosenHosts_SurviveTheListBeingRebuilt()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await AddHostAsync(vault, "staging");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+
+ await vault.SyncCommand.ExecuteAsync(null);
+
+ vault.ChosenHostCount.ShouldBe(1, vault.Status);
+ Host(vault, "prod-db").IsChosen.ShouldBeTrue("written back onto the row the reload made");
+ Host(vault, "staging").IsChosen.ShouldBeFalse();
+ }
+
+ ///
+ /// The cross at the left of the bar, and everything it has to take with it: a picker asking which vault
+ /// to move nothing to is not a state worth having.
+ ///
+ [Fact]
+ public async Task ClearingTheChoice_TakesTheMenuAndItsPanelsWithIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
- vault.SelectedHost = null;
- vault.CanEditSelectedHost.ShouldBeFalse("there is nothing for it to be about");
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ vault.OpenHostActionSheetCommand.Execute(null);
+ vault.DeleteChosenHostsCommand.Execute(null);
- vault.SelectedHost = Host(vault, "prod-db");
- vault.CanEditSelectedHost.ShouldBeTrue("and a tap is enough — it does not need the bar");
+ vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu");
+ vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
+ vault.AChosenHostPanelIsOpen.ShouldBeTrue();
+ vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question");
- vault.EditSelectedHostCommand.Execute(null);
- vault.CanEditSelectedHost.ShouldBeFalse("the form it opens is already on screen");
+ vault.ClearHostChoiceCommand.Execute(null);
+
+ vault.IsChoosingHosts.ShouldBeFalse();
+ vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
+ vault.ShowsAddButton.ShouldBeTrue();
+ }
+
+ ///
+ /// The pencil at the right of the bar. It edits the one ticked host and leaves selection mode, because
+ /// the editor is a page over the list and a bar counting hosts above a form about one of them would be
+ /// two answers to what the screen is about.
+ ///
+ [Fact]
+ public async Task ThePencil_OpensTheEditorOnTheOneChosenHostAndLeavesSelectionMode()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await AddHostAsync(vault, "staging");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "staging"));
+ vault.EditChosenHostCommand.Execute(null);
+
+ vault.IsEditing.ShouldBeTrue();
+ vault.EditorLabel.ShouldBe("staging");
+ vault.IsChoosingHosts.ShouldBeFalse();
vault.CancelEditCommand.Execute(null);
- vault.CanEditSelectedHost.ShouldBeTrue();
+
+ // Two ticked, and the pencil has nothing to be about — the bar collapses it rather than refusing it,
+ // but the command has to agree or a stale binding would open the editor on a guess.
+ vault.ChooseHostCommand.Execute(Host(vault, "staging"));
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+
+ vault.EditChosenHostCommand.Execute(null);
+
+ vault.IsEditing.ShouldBeFalse("there is no sensible reading of editing two machines");
+ }
+
+ ///
+ /// Filing is the reason the set is worth having: thirty imported machines under one heading used to be
+ /// thirty rounds of open, pick, save. It is the same write dragging a card onto a group makes on the
+ /// desktop, run over the whole selection.
+ ///
+ [Fact]
+ public async Task ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce()
+ {
+ 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"));
+
+ vault.RegroupChosenHostsCommand.Execute(null);
+
+ vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
+
+ vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
+ .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
+
+ await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
+
+ var group = vault.Groups.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal));
+
+ Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
+ Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
+ Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
+
+ vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task DuplicatingTheChosenHosts_WritesACopyBesideEachOne()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ await AddGroupAsync(vault, "production");
+ await FileAsync(vault, "prod-db", "production");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+
+ await vault.DuplicateChosenHostsCommand.ExecuteAsync(null);
+
+ vault.Hosts.Count.ShouldBe(2, vault.Status);
+
+ var copy = vault.Hosts.Single(row => row.Label.EndsWith("copy", StringComparison.Ordinal));
+
+ copy.Label.ShouldBe("prod-db copy");
+ copy.Host.Hostname.ShouldBe(Host(vault, "prod-db").Host.Hostname);
+ copy.Host.GroupId.ShouldNotBeNull("a duplicate stays on the shelf it was made from");
+ }
+
+ ///
+ /// One question naming a count, rather than one question per host: six copies of "delete prod-db?" is
+ /// not a confirmation anybody reads. What is pinned as well is that the question is answerable — the
+ /// panel it is drawn in is above the list rather than in place of it.
+ ///
+ [Fact]
+ public async Task RemovingTheChosenHosts_AsksOnceAndThenTakesThemAll()
+ {
+ 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.IsConfirmingChosenHostDeletion.ShouldBeTrue();
+ vault.PendingDeletion!.Question.ShouldContain("2 hosts");
+
+ await vault.ConfirmDeleteCommand.ExecuteAsync(null);
+
+ vault.Hosts.ShouldHaveSingleItem(vault.Status).Label.ShouldBe("staging");
+ vault.IsChoosingHosts.ShouldBeFalse();
+ }
+
+ ///
+ /// KEEP leaves everything alone, including the ticks: the question was about the selection and declining
+ /// it is not a reason to throw the selection away.
+ ///
+ [Fact]
+ public async Task KeepingTheChosenHosts_LeavesTheTicksWhereTheyWere()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ vault.DeleteChosenHostsCommand.Execute(null);
+ vault.CancelDeleteCommand.Execute(null);
+
+ vault.Hosts.ShouldHaveSingleItem();
+ vault.IsChoosingHosts.ShouldBeTrue();
+ vault.ChosenHostCount.ShouldBe(1);
+ }
+
+ ///
+ /// With one writable keychain there is nowhere to send anything, and the honest answer is a sentence
+ /// rather than an empty picker. It is also what somebody in a team whose only other vault is read-only
+ /// sees. The two-vault path is VaultSharingTests' job, which is where a second vault exists.
+ ///
+ [Fact]
+ public async Task MovingTheChosenHostsWithNowhereToPutThem_SaysSoRatherThanOpeningAnEmptyPicker()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ vault.MoveChosenHostsToVaultCommand.Execute(null);
+
+ vault.IsSendingChosenHostsToAVault.ShouldBeFalse();
+ vault.Status.ShouldContain("only keychain");
+ vault.IsChoosingHosts.ShouldBeTrue("and the selection is left alone to be used for something else");
+ }
+
+ ///
+ /// The bar's own CONNECT, which is the entry a tap already is — it is in the menu because the bar is
+ /// what a long press leaves you in, and without it connecting to the machine you had just chosen would
+ /// mean leaving selection mode first.
+ ///
+ [Fact]
+ public async Task ConnectingFromTheActionBar_OpensTheHostAndLeavesSelectionMode()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await AddKeyAsync(vault, "deploy");
+ await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ vault.ChooseHostCommand.Execute(vault.Hosts[0]);
+
+ await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
+
+ vault.Status.ShouldContain("Connected", Case.Insensitive);
+ vault.IsChoosingHosts.ShouldBeFalse();
+ }
+
+ ///
+ /// ◆ "Connect via SFTP", which crosses from the vault to the shell. Which machine is a decrypted
+ /// item and so is the vault's; the screen it leads to and the transfers view model behind it are the
+ /// shell's. This pins the join — the host arrives chosen in the file screen's own list, which is a copy
+ /// rebuilt from the vault's, so handing it the vault's row object would select nothing.
+ ///
+ [Fact]
+ public async Task BrowsingAChosenHost_GoesToTheFilesScreenWithThatHostChosen()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ vault.BrowseChosenHostCommand.Execute(null);
+
+ shell.IsTransfersShowing.ShouldBeTrue();
+ shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
+ vault.IsChoosingHosts.ShouldBeFalse();
+
+ // A host with no key and no credential wants a typed password, and there is nowhere on a list to
+ // give it one — so the picker opens with the machine already chosen and the box beside it, which is
+ // the same branch a tap on the hosts screen makes.
+ shell.Transfers.IsChoosingRemote.ShouldBeTrue();
+ shell.Transfers.Status.ShouldContain("password");
}
[Fact]
@@ -5058,86 +5331,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
// ---- Deleting a host, from the phone's bar ----
///
- ///
- /// The phone's bar carries three things and draws one of them: connecting, the picker asking which vault
- /// to move the host to, and this question. What is pinned here is that the question takes the
- /// controls rather than appearing under them — DELETE pressable a second time underneath its own
- /// confirmation is the reason the desktop's drawer has the same rule.
- ///
- ///
- /// The bar itself stays up, and that is the other half: it is where the question is drawn. A question in
- /// a bar that had collapsed would be a question nobody could answer.
- ///
+ /// ◆ The three panels the action bar's menu can raise, and the rule that at most one is up. They
+ /// are drawn above the list rather than over it — the ticked rows are the information the question exists
+ /// to give — so each one has to disarm the other two on the way up, or two questions about the same six
+ /// machines would be stacked, one of them destructive.
///
[Fact]
- public async Task DeletingAHostFromThePhonesBar_TakesTheControlsWhileItAsks()
+ public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
+ await AddGroupAsync(vault, "production");
- vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
- vault.ShowsConnectBar.ShouldBeTrue();
- vault.ShowsConnectControls.ShouldBeTrue();
+ vault.DeleteChosenHostsCommand.Execute(null);
+ vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
- vault.DeleteHostCommand.Execute(null);
+ vault.RegroupChosenHostsCommand.Execute(null);
- vault.IsConfirmingHostDeletion.ShouldBeTrue();
- vault.ShowsConnectBar.ShouldBeTrue("the bar is where the question is drawn");
- vault.ShowsConnectControls.ShouldBeFalse("so DELETE cannot be pressed under its own question");
+ vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
+ vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up");
- vault.CancelDeleteCommand.Execute(null);
+ vault.DeleteChosenHostsCommand.Execute(null);
- vault.ShowsConnectControls.ShouldBeTrue();
- vault.Hosts.ShouldHaveSingleItem("KEEP keeps it");
- }
-
- [Fact]
- public async Task DeletingAHostFromThePhonesBar_RemovesItOnceItIsAgreedTo()
- {
- await UnlockedAsync();
- var vault = shell.Vault!;
-
- await AddHostAsync(vault, "prod-db");
-
- vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
- vault.DeleteHostCommand.Execute(null);
-
- await vault.ConfirmDeleteCommand.ExecuteAsync(null);
-
- vault.Hosts.ShouldBeEmpty(vault.Status);
- vault.ShowsConnectBar.ShouldBeFalse("and there is nothing left for the bar to be about");
- }
-
- ///
- /// The other panel that takes the bar, asserted here rather than left to the move's own tests: the two
- /// share one property, and a change that put the controls back under one of them would be a CONNECT
- /// button in a bar that is asking something else.
- ///
- [Fact]
- public async Task MovingAHostFromThePhonesBar_TakesTheControlsToo()
- {
- await UnlockedAsync();
- var vault = shell.Vault!;
-
- await AddHostAsync(vault, "prod-db");
-
- vault.SelectedHost = vault.Hosts.Single();
-
- vault.MoveHostCommand.Execute(null);
-
- // One writable vault, so there is nowhere to move it and the panel does not open — which is exactly
- // the state that must still leave the controls showing rather than a bar with nothing in it.
- vault.IsMovingHost.ShouldBeFalse(vault.Status);
- vault.ShowsConnectControls.ShouldBeTrue();
-
- // Set rather than reached through a second vault, which is VaultSharingTests' job and needs a server
- // to make one. What is being pinned here is the one line joining the flag to the bar.
- vault.IsMovingHost = true;
-
- vault.ShowsConnectControls.ShouldBeFalse();
+ vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
+ vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return");
}
// ---- Tags ----
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index 43a53b1..411d98e 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -615,6 +615,144 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("group", Case.Insensitive);
}
+ ///
+ ///
+ /// ◆ The phone's action bar sending a whole selection across, in one run. The single-host move
+ /// above is the desktop's; this is the same write over a set, and the thing worth pinning is that it
+ /// obeys the same rule — the group and the tags are items of the vault being left, so nothing carries
+ /// them across.
+ ///
+ ///
+ /// A host already in the destination is skipped rather than refusing the whole run, and the sentence
+ /// afterwards says how many were left alone. Eleven machines with one that had nowhere to go must not do
+ /// nothing at all and then report about the wrong ten.
+ ///
+ ///
+ [Fact]
+ public async Task MovingTheChosenHostsToAnotherVault_TakesThemAllAndSkipsTheOnesAlreadyThere()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ await SeedForTheChosenHostMoveAsync(vault, sharedVaultId);
+
+ foreach (var row in vault.Hosts.ToList())
+ {
+ vault.ChooseHostCommand.Execute(row);
+ }
+
+ vault.ChosenHostCount.ShouldBe(2);
+ vault.MoveChosenHostsToVaultCommand.Execute(null);
+
+ vault.IsSendingChosenHostsToAVault.ShouldBeTrue(vault.Status);
+ vault.ChosenHostsAreBeingCopied.ShouldBeFalse();
+
+ // Every writable vault, because the selection spans two of them — there is no single vault to leave
+ // out, and shrinking the list to the intersection would offer nothing at all.
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ // As the single-host move's own test does, and for the reason written there: a successful pass
+ // reports what it pushed and supersedes the run's own sentence, which is what is being read here.
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
+
+ vault.Hosts.Count.ShouldBe(2, "nothing was duplicated on the way across");
+ vault.Hosts.ShouldAllBe(row => row.VaultId == sharedVaultId);
+ vault.Hosts
+ .Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
+ .Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
+
+ vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
+ vault.Status.ShouldContain("Platform secrets");
+ vault.Status.ShouldContain("left alone", Case.Insensitive);
+ }
+
+ ///
+ /// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
+ ///
+ ///
+ /// The first is what the move has to strip a group off on the way across; the second is the row that has
+ /// to be skipped rather than turned into a second copy of itself.
+ ///
+ private static async Task SeedForTheChosenHostMoveAsync(VaultViewModel vault, Guid sharedVaultId)
+ {
+ vault.NewGroupCommand.Execute(null);
+ vault.GroupEditorLabel = "Production";
+ await vault.SaveGroupCommand.ExecuteAsync(null);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
+ choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorSelectedVault =
+ vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+ vault.EditorLabel = "prod-web";
+ vault.EditorHostname = "web.internal";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ }
+
+ ///
+ /// The other verb behind the same picker. What makes it worth its own test is the half that is not a
+ /// move: the original stays where it is, so a host shared with a team is still readable by the person
+ /// who shared it.
+ ///
+ [Fact]
+ public async Task CopyingAChosenHostToAnotherVault_LeavesTheOriginalWhereItIs()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ var before = vault.Hosts.Single();
+
+ vault.ChooseHostCommand.Execute(before);
+ vault.CopyChosenHostsToVaultCommand.Execute(null);
+
+ vault.ChosenHostsAreBeingCopied.ShouldBeTrue(vault.Status);
+
+ vault.SelectedChosenHostVault =
+ vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
+
+ vault.Hosts.Count.ShouldBe(2, vault.Status);
+ vault.Hosts.ShouldContain(row => row.VaultId == before.VaultId);
+ vault.Hosts.ShouldContain(row => row.VaultId == sharedVaultId);
+ vault.Hosts.ShouldAllBe(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
+ }
+
///
/// The move is refused where it would have nowhere to go, by the command rather than by an empty
/// picker — and the phone reads the same question to decide whether to draw the button at all.