Public Access
Compare commits
2
Commits
69858f82d1
...
f1d6499bb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d6499bb5 | ||
|
|
c882fa0cd3 |
+108
-42
@@ -977,13 +977,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
|
||||
|
||||
@@ -1029,7 +1031,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.
|
||||
@@ -1038,7 +1040,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.
|
||||
|
||||
@@ -1115,60 +1117,124 @@ 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.14a The key question is asked for one host and for a move · **needs two writable vaults**
|
||||
|
||||
With a host bound to a key that is in the vault it is leaving, tick that host alone and choose **Move to
|
||||
vault**.
|
||||
|
||||
**Pass:** under the picker, an unticked box offering to bring the key, with the count of what else uses it
|
||||
underneath. Choosing a different destination re-asks it, and it disappears when the destination is the vault
|
||||
the key is already in — the same behaviour as the desktop's, which 3.3 measures.
|
||||
|
||||
Now choose **Copy to vault** on the same host.
|
||||
|
||||
**Pass:** no box at all. A copy that took the key away would leave the original — still sitting in the vault
|
||||
it started in — unable to connect, which is the one thing "copy" promises not to do.
|
||||
|
||||
Tick a second host and open **Move to vault** again.
|
||||
|
||||
**Pass:** still no box. Which key to carry is a fact about one machine, and a selection of six has six
|
||||
answers.
|
||||
|
||||
**Failure means:** a box under a copy is `ChosenBindingToBring` no longer reading
|
||||
`ChosenHostsAreBeingCopied`, and it moves a private key out from under a host that is still using it. A box
|
||||
that is ticked when the panel opens is worse than a bug for the reason 3.3 gives.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1394,14 +1460,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
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.HostActionBar"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
◆ THE CONTEXTUAL ACTION BAR, WHICH IS WHAT THE CONNECT CARD BECAME.
|
||||
|
||||
A long press on a host used to raise a card over the bottom of the list: a password box, CONNECT, EDIT,
|
||||
MOVE and DELETE. It was a menu drawn as a form, in the one part of the screen a list grows into — so it
|
||||
covered rows, it had room for five things and never a sixth, and everything on it was about exactly one
|
||||
machine. What replaced it is a selection and this bar.
|
||||
|
||||
It takes the vault header's place rather than sitting under it, which is the arrangement Android has used
|
||||
for this since contextual action bars existed and is the reason it can be unambiguous: while it is up, the
|
||||
screen is about the ticked hosts and about nothing else. See PhoneShell.ShowsHostSelectionBar, which is
|
||||
where the swap happens and why it is computed rather than bound.
|
||||
|
||||
Left to right: the cross that leaves selection mode, the count, then the two controls. The pencil is out
|
||||
in front of the menu because editing is the thing people reach for most and is worth not opening a menu
|
||||
for; the other six are behind the ⋯, which is a sheet rather than a flyout — see
|
||||
VaultViewModel.IsHostActionSheetOpen.
|
||||
|
||||
── one host or several ──────────────────────────────────────────────────────────────────────────────────
|
||||
The pencil is drawn only while exactly one host is ticked, and it is collapsed rather than greyed. Three
|
||||
of the seven actions are like that — edit, connect, and browse — and the reason is the same for all
|
||||
three: a form, a terminal and a file session are each about one machine, and there is no sensible reading
|
||||
of "edit these six". The three that stay are the ones a count makes better rather than worse.
|
||||
-->
|
||||
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
The cross, and it is the first thing on the bar for the reason back arrows are: the way out of a
|
||||
mode belongs at the edge the thumb reaches without crossing anything it might press by mistake.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="icon" Content="✕"
|
||||
Command="{Binding ClearHostChoiceCommand}"
|
||||
ToolTip.Tip="Stop choosing hosts" />
|
||||
|
||||
<!--
|
||||
The count, and only the count. The bar it sits in is already saying what the number is about, and
|
||||
"6 hosts selected" beside a pencil and a menu is the width those two icons need at 360dp.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="heading" FontSize="17" Margin="8,0"
|
||||
VerticalAlignment="Center" Text="{Binding ChosenHostsLabel}" />
|
||||
|
||||
<Button Grid.Column="2" Classes="icon" Content="✎" FontSize="15"
|
||||
IsVisible="{Binding HasOneChosenHost}"
|
||||
Command="{Binding EditChosenHostCommand}"
|
||||
ToolTip.Tip="Edit this host" />
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="⋯" FontSize="18"
|
||||
Command="{Binding OpenHostActionSheetCommand}"
|
||||
ToolTip.Tip="More things to do with these hosts" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>The bar that takes the header's place while hosts are chosen.</summary>
|
||||
internal sealed partial class HostActionBar : UserControl
|
||||
{
|
||||
public HostActionBar() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
|
||||
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> 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.
|
||||
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
|
||||
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> 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 <c>ListBox</c> rather than on the row, and the row stays a plain
|
||||
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
|
||||
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Avalonia raises <c>Tapped</c> 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.
|
||||
/// </remarks>
|
||||
private bool held;
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The long press is attached here rather than in the markup so that it sits beside the property that
|
||||
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> 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. <see cref="InputElement.IsHoldingEnabledProperty"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
||||
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the row that was tapped.
|
||||
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>VaultViewModel.SelectedSidebarRow</c> — 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.
|
||||
/// ◆ <b>The branch is the selection, not the row.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the bar about the row that was held.
|
||||
/// </summary>
|
||||
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// On <see cref="HoldingState.Started"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It goes through <c>OpenHostPaneCommand</c>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host the gesture landed on, or null where it landed on something that is not one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
|
||||
/// handlers used to do.</b> 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
|
||||
/// <c>VaultViewModel.SelectedSidebarRow</c> — 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 <em>that</em>, which is a machine the user was not pointing
|
||||
/// at going into a set they are about to delete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
|
||||
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
|
||||
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
|
||||
/// context is always the list's item, whatever kind it is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
|
||||
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
|
||||
as HostRowViewModel;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||
@@ -128,30 +128,18 @@
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
|
||||
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
|
||||
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
|
||||
the one of the five common enough to be worth a control that is always in the same place, so it
|
||||
is in the header — the phone's only piece of persistent chrome — and the long press still
|
||||
reaches the other four. See HostsScreen.axaml.
|
||||
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
|
||||
|
||||
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
|
||||
two belong to different view models: which screen is showing is the shell's question, and
|
||||
whether there is a host to edit is the vault's. That is the same arrangement the header itself
|
||||
is wrapped in one level up.
|
||||
It was in the header because a long press raised a connect card carrying EDIT, and a card over
|
||||
the bottom of the list was the wrong place for the one action people reach for most. Both
|
||||
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
|
||||
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
|
||||
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
|
||||
|
||||
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
|
||||
vault's name on every screen would be a permanent reminder of a control that is only ever about
|
||||
one row.
|
||||
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||
keychain, and none of them about a row.
|
||||
-->
|
||||
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
|
||||
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
|
||||
IsVisible="{Binding Vault.CanEditSelectedHost}"
|
||||
Command="{Binding Vault.EditSelectedHostCommand}"
|
||||
ToolTip.Tip="Edit the selected host" />
|
||||
</Panel>
|
||||
|
||||
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
@@ -160,6 +148,21 @@
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ ◆ the contextual action bar ============ -->
|
||||
<!--
|
||||
In the header's row and in its place, never beside it. A bar that stacked under the vault's name
|
||||
would be two rows of chrome above a list at 360dp, and worse, it would leave the keychain's name and
|
||||
LOCK on screen over a list that is no longer about the keychain — see PhoneShell.ShowsHostSelectionBar,
|
||||
which is where the two are made exclusive.
|
||||
|
||||
Wrapped so the data context can be the vault, as every other screen in this file is: what the bar
|
||||
counts and what its four controls do are one vault's business, and the shell's own binding would
|
||||
resolve none of them.
|
||||
-->
|
||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsHostSelectionBar}">
|
||||
<views:HostActionBar DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||
<Panel Grid.Row="1">
|
||||
<!--
|
||||
@@ -272,8 +275,13 @@
|
||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||
whether there are any tabs and this one is about which surface is up.
|
||||
|
||||
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
|
||||
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
|
||||
terminal has, on a screen where the form is the whole point of being there. See
|
||||
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||
-->
|
||||
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
|
||||
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
|
||||
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
|
||||
{
|
||||
private MainWindowViewModel? shell;
|
||||
|
||||
/// <summary>
|
||||
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> 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 <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
|
||||
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
|
||||
/// </remarks>
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||
/// </summary>
|
||||
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
|
||||
TryOfferDeviceUnlock();
|
||||
}
|
||||
|
||||
FollowTheVault();
|
||||
RefreshChrome();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
|
||||
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
||||
/// <remarks>
|
||||
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
||||
@@ -124,6 +166,14 @@ internal sealed partial class PhoneShell : UserControl
|
||||
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
||||
|
||||
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
|
||||
|
||||
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
|
||||
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||||
|
||||
/// <inheritdoc cref="IsWideProperty" />
|
||||
public bool IsWide
|
||||
{
|
||||
@@ -152,12 +202,26 @@ internal sealed partial class PhoneShell : UserControl
|
||||
private set => SetValue(ShowsVaultHeaderProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
|
||||
public bool ShowsHostSelectionBar
|
||||
{
|
||||
get => GetValue(ShowsHostSelectionBarProperty);
|
||||
private set => SetValue(ShowsHostSelectionBarProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||||
public bool ShowsShellStrip
|
||||
{
|
||||
get => GetValue(ShowsShellStripProperty);
|
||||
private set => SetValue(ShowsShellStripProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out which chrome this surface should be wearing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
|
||||
/// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
|
||||
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
||||
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
||||
/// have to be an "or", which a wrapper cannot express at all.
|
||||
@@ -179,7 +243,16 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
|
||||
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
|
||||
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
|
||||
/// <see cref="vault"/>. While hosts are ticked the header stands down and
|
||||
/// <see cref="ShowsHostSelectionBar"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
@@ -198,6 +271,16 @@ internal sealed partial class PhoneShell : UserControl
|
||||
// only way to Hosts or Settings was the system back gesture.
|
||||
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||
|
||||
// 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
|
||||
@@ -209,9 +292,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
}
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && (pages || connectPage);
|
||||
ShowsBottomBar = !wide && (pages || connectPage);
|
||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
||||
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !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)
|
||||
@@ -221,6 +306,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
FollowTheVault();
|
||||
RefreshChrome();
|
||||
|
||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||
@@ -526,16 +612,23 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||
{
|
||||
@@ -544,22 +637,71 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
|
||||
}
|
||||
|
||||
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
|
||||
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
|
||||
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);
|
||||
@@ -572,6 +714,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1133,17 +1133,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two destinations, because a recent row is one of two different things.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>It opens that panel rather than merely selecting the row, and on the phone it has to.</b>
|
||||
/// Choosing a host there no longer raises the bar — a tap on the list connects instead, see
|
||||
/// <c>VaultViewModel.ShowsConnectBar</c> — 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.
|
||||
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> 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
|
||||
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the hosts screen to the files screen, on the host it asked about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>"Connect via SFTP", from the phone's action bar.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It goes through <see cref="ShowFiles"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It navigates and then connects, and the two are separate on purpose.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>TransfersViewModel.Hosts</c> — and its picker binds to rows in it,
|
||||
/// so handing it the vault's object would select nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
/// <remarks>
|
||||
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<SshPasswordCredential>();
|
||||
vault.ShowsConnectBar.ShouldBeFalse("and the bar goes with the tap that succeeded");
|
||||
vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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.
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
|
||||
/// another vault: the copy stays in the same keychain, so everything it points at is still there.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <c>VaultSharingTests</c>' job, which is where a second vault exists.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ <b>"Connect via SFTP", which crosses from the vault to the shell.</b> 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.
|
||||
/// </remarks>
|
||||
[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 ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <em>takes</em> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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 ----
|
||||
|
||||
@@ -615,6 +615,144 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("group", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The phone's action bar sending a whole selection across, in one run.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
@@ -765,6 +903,121 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The same question from the phone's action bar, which is that head's only route to it since the
|
||||
/// connect card went.</b> It is asked in two shapes fewer than the desktop's: one host, because which key
|
||||
/// to carry is a fact about one machine and a selection of six has six answers; and a move rather than a
|
||||
/// copy, because taking the key out from under an original that is staying put would leave that original
|
||||
/// unable to connect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three shapes are asserted in one test on purpose. What is being pinned is not that the box appears
|
||||
/// but that it appears in exactly one of them — a rule that only reads as a rule when the other two are
|
||||
/// beside it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheActionBarAsksAboutTheKey_ForOneHostAndForAMoveOnly()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
|
||||
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
|
||||
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
|
||||
|
||||
var one = vault.Hosts.Single(
|
||||
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.ChooseHostCommand.Execute(one);
|
||||
vault.MoveChosenHostsToVaultCommand.Execute(null);
|
||||
|
||||
vault.SelectedChosenHostVault =
|
||||
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.HasAChosenBindingToBring.ShouldBeTrue(vault.Status);
|
||||
vault.ChosenBindingToBringQuestion.ShouldContain("deploy");
|
||||
vault.ChosenBindingToBringNote.ShouldContain("one other host", Case.Insensitive);
|
||||
vault.BringsTheChosenBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
|
||||
|
||||
// A copy, which must never take the key: the original stays where it is and would be left bound to
|
||||
// something its own vault no longer holds.
|
||||
vault.CopyChosenHostsToVaultCommand.Execute(null);
|
||||
|
||||
vault.SelectedChosenHostVault =
|
||||
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.HasAChosenBindingToBring.ShouldBeFalse("a copy that moved the key would break the original");
|
||||
|
||||
// And two hosts, where the question has two answers and no tick can carry them.
|
||||
vault.ToggleHostChoiceCommand.Execute(
|
||||
vault.Hosts.Single(row => string.Equals(row.Label, "prod-web", StringComparison.Ordinal)));
|
||||
|
||||
vault.MoveChosenHostsToVaultCommand.Execute(null);
|
||||
|
||||
vault.SelectedChosenHostVault =
|
||||
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.HasAChosenBindingToBring.ShouldBeFalse("which key to carry is a fact about one machine");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ticked, from the phone. The desktop's own path is measured above; what this adds is that the batch
|
||||
/// command carries the key before it writes the host, so the host lands naming the id the key arrived
|
||||
/// with rather than a tombstone.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingTheOneChosenHostWithItsKey_TakesTheKeyAcrossAndKeepsTheBinding()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var key = await AddKeyAsync(vault, "deploy");
|
||||
|
||||
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
|
||||
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts.ShouldHaveSingleItem());
|
||||
vault.MoveChosenHostsToVaultCommand.Execute(null);
|
||||
|
||||
vault.SelectedChosenHostVault =
|
||||
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.BringsTheChosenBindingAlong = true;
|
||||
|
||||
server.SyncFailure = new IOException("The server is not answering.");
|
||||
|
||||
await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var movedKey = vault.Keys.ShouldHaveSingleItem();
|
||||
var movedHost = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
|
||||
|
||||
vault.Status.ShouldContain("came with it");
|
||||
vault.BringsTheChosenBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
|
||||
vault.IsChoosingHosts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
|
||||
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
|
||||
|
||||
Reference in New Issue
Block a user