Public Access
Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong place. It covered rows, it had room for five things and never a sixth, and every one of them was about exactly one machine — so filing eleven imported hosts under a group was eleven trips through a form, and there was nowhere to put a sixth action if anybody wanted one. A long press now chooses the host it landed on, and the actions move into a bar across the top of the screen, in the vault header's place rather than beside it. That is where Android has put them since contextual action bars existed, and it is the one strip a list can never grow into — but the real reason for it is that while it is up the screen is unambiguously about the ticked hosts and nothing else, which is what lets the count in the middle of it mean something. Left to right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate and Remove. A tap still connects and still raises nothing. Once anything is ticked it ticks and unticks instead, which is what every Android list does and is not merely a convention worth following: a tap that connected while five machines sat ticked would open a terminal on top of a selection somebody was halfway through building. Unticking the last host leaves the mode, so there are two ways out of it and the cross is only one of them. Both gestures now read the row from the element under the finger rather than from the list's selection, and that is a correctness change rather than tidying. A tap on a group heading moves the selection and the view model bounces it straight back to whichever host was chosen before — which answered "a host, or nothing" for free while a tap only ever connected. It stops answering it the moment a tap can tick one: the heading would tick a machine the user was not pointing at, into a set they are about to delete. Three of the seven entries are about one machine and are drawn only for one. A terminal, a file-transfer session and a form each have no reading over six, so they are collapsed rather than refused. The other four read better for a count than without one — it is the reason the set exists — and each of them says afterwards how many hosts it wrote and how many it left alone. Skipping beats refusing the whole run: a selection of eleven with one read-only row would otherwise do nothing at all and then report about the wrong ten. Copy to vault and Duplicate are new, and the difference between them is what each can safely carry. A copy crosses a key boundary, so it drops the group and the tags exactly as a move does — both are items of the vault being left, and a host arriving with either would point at something the destination does not contain, resolvable on the machine that sent it and dangling for everybody else. A duplicate stays in the same keychain, so everything it points at is still there and it keeps both. Change group is the write dragging a card onto a group already makes on the desktop, run over a selection; it refuses one spanning two keychains rather than half-filing it, which is the refusal a drop across that boundary already makes one host at a time. Connect via SFTP is the one action that leaves the vault. Which machine is a decrypted item and so is this object's business; the screen it leads to and the transfers view model behind it are the shell's — so it is an event, on the same division SessionOpened already draws for a shell. The host is re-found in that screen's own copy of the list, because the picker binds to rows in that copy and handing it the vault's object would select nothing. What is left of the card is the password box, and only because it had nowhere else to go: a host that authenticates with a typed password cannot be reached by a tap alone. That tap now raises a sheet rather than the bar, and the difference is that a sheet is up only while a question is on screen — the bar was raised by a long press and stayed, so it was a password box sitting over the list whether or not anything was being asked. Dismissing it empties the box, which is not tidiness either: a secret left behind would satisfy the emptiness check that decides whether to raise the sheet at all, so the next tap would dial with somebody else's password. The pencil moving into that bar takes the host editor with it. It was a card in the list's own row, under the search box and the sync line — twenty controls sharing a screen with two rows of chrome about the list it had replaced. It is a page now, and PhoneShell stands all four of its rows down for it, which is what "opens with all the options" means at 360dp. That needed a second subscription in that control: two of its flags are questions about the vault rather than about the shell, and the shell does not forward the vault's notifications. The ticks are held as entity ids rather than as rows, and written back onto the rows after every reload. Every row object in the list is replaced on every filter keystroke and every synchronisation pass, so a set of rows would empty itself once a minute under somebody choosing what to do with eleven machines. Ids that no longer resolve are dropped, so a colleague's deletion arriving mid-selection leaves a count that matches what is on screen. One caller had to change with it. ConnectToRecent opened the pane about a host, which was the desktop's drawer and the phone's card; the phone's answer is now a tick, and nothing on that list means "selected" any more — so arriving with the host merely selected would be arriving at a screen with nothing to press. Both are raised together, and the one the head in front of the user does not draw is inert.
This commit is contained in:
+85
-42
@@ -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.
|
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
|
**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
|
**Failure means:** a + still drawn over a selection is `ShowsAddButton` no longer reading `IsChoosingHosts`
|
||||||
harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
|
— a control that does something, where the something is not what the screen is about. Nothing in the layout
|
||||||
between two controls, so a button sitting squarely on top of CONNECT reports clean.
|
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
|
### 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.
|
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
|
**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
|
**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.
|
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.
|
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.
|
**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
|
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.
|
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
|
**Pass:** Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate — then a rule,
|
||||||
its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone.
|
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
|
**Pass:** Connect and Connect via SFTP are gone. A terminal and a file session are each about one machine,
|
||||||
puts them back with the host still there.
|
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,
|
Press Remove.
|
||||||
the second press landing on whatever the list rebuilt underneath.
|
|
||||||
|
|
||||||
|
**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.
|
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** —
|
**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.
|
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
|
**Pass:** the header is replaced by a bar carrying ✕, the count `1`, a pencil and a ⋯, and the row itself is
|
||||||
follows to that one rather than staying on the first.
|
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**.
|
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
|
**Pass:** it does not dial. A sheet rises from the bottom naming that host, with a password box, the
|
||||||
for a password. Type it and press CONNECT — or tap the row a second time, which is the same thing — and it
|
remember tick, CONNECT and CANCEL. Type it and press CONNECT — or dismiss the sheet, type nothing, and tap
|
||||||
connects.
|
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
|
**Failure means:** a tap that connects while rows are ticked is `IsChoosingHosts` not being read in
|
||||||
controls over the bottom of the list in the way of the one thing a tap on its name means. A tap that dials
|
`HostsScreen.axaml.cs` — a terminal opened on top of a selection somebody was halfway through building. A
|
||||||
a password host with an empty box is worse: the connection fails somewhere the user cannot see why. A long
|
tap on a group *heading* that ticks a host is worse: it means the handler is reading the list's selection
|
||||||
press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a
|
rather than the element under the finger, so a machine nobody pointed at has gone into a set about to be
|
||||||
hold, so one touch fires both gestures.
|
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
|
**Pass:** the editor takes the **whole screen** — no search box, no sync line, no shells strip, no bottom
|
||||||
greyed one, and while an editor is open it is gone.
|
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
|
Tick two hosts.
|
||||||
`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`.
|
**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
|
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||||||
nothing running.
|
nothing running.
|
||||||
|
|
||||||
**Pass:** tapping the keychain one lands on HOSTS with that host selected **and its connect bar up**.
|
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
|
||||||
Tapping the hand-typed one puts its address back in the box on this screen and leaves the password box
|
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
|
||||||
**empty**. Neither one dials anything on its own.
|
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
|
**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
|
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
|
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
||||||
pane: since 8.15 a selection alone raises nothing, so that is a screen with nothing to press.
|
`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
|
### 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.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
|
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
|
||||||
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
|
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
|
||||||
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
|
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
|
||||||
|
/// ask.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
|
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
|
||||||
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
|
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
|
||||||
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
|
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
|
||||||
/// events fire after the list has moved its selection, which is what lets these read it.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostsScreen : UserControl
|
internal sealed partial class HostsScreen : UserControl
|
||||||
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
|
/// 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
|
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
|
||||||
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
|
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
|
||||||
/// by the tap it suppresses, so it never survives the gesture that set it.
|
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool held;
|
private bool held;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The long press is attached here rather than in the markup so that it sits beside the property that
|
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
|
||||||
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
|
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
|
||||||
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
|
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
|
||||||
/// would go on working and nothing would ever open the bar again.
|
/// every tap would go on working and nothing would ever choose a host again.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
/// ◆ <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>
|
/// <summary>
|
||||||
/// Connects to the row that was tapped.
|
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
|
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
|
||||||
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
|
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
|
||||||
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
|
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
|
||||||
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
|
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
|
||||||
/// a machine the user was not pointing at.
|
/// selection somebody was halfway through building.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
|
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
|
||||||
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
|
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
|
||||||
/// returning a task nothing observes.
|
/// be an event handler returning a task nothing observes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRowTapped(object? sender, TappedEventArgs e)
|
private void OnRowTapped(object? sender, TappedEventArgs e)
|
||||||
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
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>
|
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
|
||||||
/// Raises the bar about the row that was held.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
|
||||||
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
|
|
||||||
held = true;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
||||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
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
|
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||||
@@ -127,30 +127,18 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
|
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
|
||||||
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.
|
|
||||||
|
|
||||||
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
|
It was in the header because a long press raised a connect card carrying EDIT, and a card over
|
||||||
two belong to different view models: which screen is showing is the shell's question, and
|
the bottom of the list was the wrong place for the one action people reach for most. Both
|
||||||
whether there is a host to edit is the vault's. That is the same arrangement the header itself
|
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
|
||||||
is wrapped in one level up.
|
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
|
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||||
vault's name on every screen would be a permanent reminder of a control that is only ever about
|
keychain, and none of them about a row.
|
||||||
one row.
|
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
|
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||||
<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}"
|
|
||||||
ToolTip.Tip="Lock the keychain">
|
ToolTip.Tip="Lock the keychain">
|
||||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||||
Foreground="{StaticResource TextDim}" />
|
Foreground="{StaticResource TextDim}" />
|
||||||
@@ -159,6 +147,21 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</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. -->
|
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
<!--
|
<!--
|
||||||
@@ -271,8 +274,13 @@
|
|||||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
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
|
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.
|
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}"
|
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
{
|
{
|
||||||
private MainWindowViewModel? shell;
|
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>
|
/// <summary>
|
||||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
TryOfferDeviceUnlock();
|
TryOfferDeviceUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
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>
|
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
/// 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<bool> ShowsVaultHeaderProperty =
|
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
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" />
|
/// <inheritdoc cref="IsWideProperty" />
|
||||||
public bool IsWide
|
public bool IsWide
|
||||||
{
|
{
|
||||||
@@ -143,6 +193,20 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
private set => SetValue(ShowsVaultHeaderProperty, value);
|
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>
|
/// <summary>
|
||||||
/// Works out which chrome this surface should be wearing.
|
/// Works out which chrome this surface should be wearing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -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.
|
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// 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
|
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
||||||
/// would notice going stale.
|
/// would notice going stale.
|
||||||
@@ -172,6 +245,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
var wide = body.Bounds.Width >= WideAt;
|
var wide = body.Bounds.Width >= WideAt;
|
||||||
var pages = shell?.IsShowingPages == true;
|
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
|
// 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
|
// 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
|
// 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;
|
IsWide = wide;
|
||||||
ShowsRail = wide && pages;
|
ShowsRail = wide && pages && !editing;
|
||||||
ShowsBottomBar = !wide && pages;
|
ShowsBottomBar = !wide && pages && !editing;
|
||||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
ShowsShellStrip = pages && !editing;
|
||||||
|
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||||
|
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
@@ -195,6 +280,7 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
RefreshChrome();
|
||||||
|
|
||||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||||
@@ -500,16 +586,23 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
|
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
|
||||||
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
|
||||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
|
||||||
/// editor the user was looking at.
|
/// than the editor the user was looking at.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
/// 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
|
/// 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.
|
/// next NEW HOST open on somebody else's abandoned draft.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||||
{
|
{
|
||||||
@@ -518,22 +611,71 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return false;
|
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)
|
if (vault.IsAddSheetOpen)
|
||||||
{
|
{
|
||||||
vault.CloseAddSheetCommand.Execute(null);
|
vault.CloseAddSheetCommand.Execute(null);
|
||||||
return true;
|
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)
|
if (vault.GroupSheet is not null)
|
||||||
{
|
{
|
||||||
vault.CloseGroupSheetCommand.Execute(null);
|
vault.CloseGroupSheetCommand.Execute(null);
|
||||||
return true;
|
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)
|
if (vault.IsEditing)
|
||||||
{
|
{
|
||||||
vault.CancelEditCommand.Execute(null);
|
vault.CancelEditCommand.Execute(null);
|
||||||
@@ -546,6 +688,12 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1133,17 +1133,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
|
/// <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
|
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
|
||||||
/// drawer, the phone's connect bar — carrying whatever authentication the keychain resolves for it and a
|
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
|
||||||
/// password box only if it needs one. Connecting from here instead would be a third connect path that
|
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
|
||||||
/// had to answer all of that again.
|
/// path that had to answer all of that again.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>It opens that panel rather than merely selecting the row, and on the phone it has to.</b>
|
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
|
||||||
/// Choosing a host there no longer raises the bar — a tap on the list connects instead, see
|
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
|
||||||
/// <c>VaultViewModel.ShowsConnectBar</c> — so arriving with the host selected and nothing else would be
|
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
|
||||||
/// arriving at a screen with nothing to press. Asking to go back to a machine is exactly the deliberate
|
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
|
||||||
/// act that flag exists to distinguish from browsing.
|
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
|
/// 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.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||||
&& vault.IsVaultShown(known.VaultId))
|
&& vault.IsVaultShown(known.VaultId))
|
||||||
{
|
{
|
||||||
vault.OpenHostPaneCommand.Execute(known);
|
vault.AskAboutHostCommand.Execute(known);
|
||||||
ShowScreen(ShellScreen.Hosts);
|
ShowScreen(ShellScreen.Hosts);
|
||||||
return;
|
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
|
// 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.
|
// resolves: the vault holds the handler, not the other way round.
|
||||||
oldValue.VaultsChanged -= OnVaultsAdmitted;
|
oldValue.VaultsChanged -= OnVaultsAdmitted;
|
||||||
|
oldValue.FilesRequested -= OnVaultFilesRequested;
|
||||||
|
|
||||||
if (attempts.Count == 0)
|
if (attempts.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -2779,6 +2780,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// end of.
|
// end of.
|
||||||
newValue.VaultsChanged += OnVaultsAdmitted;
|
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
|
// 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
|
// 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,
|
// 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;
|
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" />
|
/// <inheritdoc cref="OnScreenChanged" />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
|
/// <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");
|
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>
|
/// <remarks>
|
||||||
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> Choosing a host used to
|
/// ◆ <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
|
/// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list —
|
||||||
/// meant a tap on a machine's name put five controls in the way of the one thing it obviously means.
|
/// which meant a tap on a machine's name put five controls in the way of the one thing it obviously
|
||||||
/// A tap connects now, and this pins that it raises nothing on the way past.
|
/// means. A tap connects now, and this pins that it raises nothing on the way past.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATapOnAHost_ConnectsAndLeavesTheBarWhereItWas()
|
public async Task ATapOnAHost_ConnectsAndRaisesNothing()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
@@ -4829,17 +4790,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||||
vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
|
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>
|
/// <remarks>
|
||||||
/// The one tap that cannot finish, and the reason the bar still exists. A host that authenticates with a
|
/// The one tap that cannot finish, and the whole of what is left of the connect card. A host that
|
||||||
/// typed password has nowhere to be given one from a list, so the tap raises the bar with the box in it
|
/// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the
|
||||||
/// and says so. What it must never do is connect with no password, or leave somebody tapping a row that
|
/// password sheet and says so. What it must never do is connect with no password, or leave somebody
|
||||||
/// silently does nothing.
|
/// tapping a row that silently does nothing.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATapOnAHostThatWantsAPassword_RaisesTheBarInsteadOfConnecting()
|
public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
@@ -4847,13 +4809,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
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.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||||
vault.Status.ShouldContain("password");
|
vault.Status.ShouldContain("password");
|
||||||
ssh.Requests.ShouldBeEmpty("nothing was dialled with no 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
|
// The second tap, with the box filled in, is the one that goes through — otherwise the sheet would
|
||||||
// answering the instruction it just gave with the same instruction again.
|
// be answering the instruction it just gave with the same instruction again.
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ConnectPassword = "typed-in";
|
vault.ConnectPassword = "typed-in";
|
||||||
@@ -4861,33 +4823,344 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
||||||
|
|
||||||
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
|
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>
|
/// <remarks>
|
||||||
/// The pencil in the phone's header, which is where EDIT went when the bar stopped being raised by
|
/// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker
|
||||||
/// choosing a machine. Both halves matter: it is offered for a chosen host, and it is taken away while an
|
/// makes: a secret left in the box would be somebody else's password sitting in the field the next tap
|
||||||
/// editor is up — a pencil that opens the form already on screen is a control with nothing to do.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[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();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await AddHostAsync(vault, "prod-db");
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
vault.SelectedHost = null;
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
vault.CanEditSelectedHost.ShouldBeFalse("there is nothing for it to be about");
|
vault.OpenHostActionSheetCommand.Execute(null);
|
||||||
|
vault.DeleteChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
vault.SelectedHost = Host(vault, "prod-db");
|
vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu");
|
||||||
vault.CanEditSelectedHost.ShouldBeTrue("and a tap is enough — it does not need the bar");
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
||||||
|
vault.AChosenHostPanelIsOpen.ShouldBeTrue();
|
||||||
|
vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question");
|
||||||
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
vault.CanEditSelectedHost.ShouldBeFalse("the form it opens is already on screen");
|
|
||||||
|
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.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]
|
[Fact]
|
||||||
@@ -5058,86 +5331,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
// ---- Deleting a host, from the phone's bar ----
|
// ---- Deleting a host, from the phone's bar ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> They
|
||||||
/// The phone's bar carries three things and draws one of them: connecting, the picker asking which vault
|
/// are drawn above the list rather than over it — the ticked rows are the information the question exists
|
||||||
/// to move the host to, and this question. What is pinned here is that the question <em>takes</em> the
|
/// to give — so each one has to disarm the other two on the way up, or two questions about the same six
|
||||||
/// controls rather than appearing under them — DELETE pressable a second time underneath its own
|
/// machines would be stacked, one of them destructive.
|
||||||
/// 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>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeletingAHostFromThePhonesBar_TakesTheControlsWhileItAsks()
|
public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await AddHostAsync(vault, "prod-db");
|
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.DeleteChosenHostsCommand.Execute(null);
|
||||||
vault.ShowsConnectControls.ShouldBeTrue();
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
||||||
|
|
||||||
vault.DeleteHostCommand.Execute(null);
|
vault.RegroupChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
vault.IsConfirmingHostDeletion.ShouldBeTrue();
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
||||||
vault.ShowsConnectBar.ShouldBeTrue("the bar is where the question is drawn");
|
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up");
|
||||||
vault.ShowsConnectControls.ShouldBeFalse("so DELETE cannot be pressed under its own question");
|
|
||||||
|
|
||||||
vault.CancelDeleteCommand.Execute(null);
|
vault.DeleteChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
vault.ShowsConnectControls.ShouldBeTrue();
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
||||||
vault.Hosts.ShouldHaveSingleItem("KEEP keeps it");
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return");
|
||||||
}
|
|
||||||
|
|
||||||
[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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tags ----
|
// ---- Tags ----
|
||||||
|
|||||||
@@ -615,6 +615,144 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vault.Status.ShouldContain("group", Case.Insensitive);
|
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>
|
/// <remarks>
|
||||||
/// The move is refused where it would have nowhere to go, by the command rather than by an empty
|
/// 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.
|
/// picker — and the phone reads the same question to decide whether to draw the button at all.
|
||||||
|
|||||||
Reference in New Issue
Block a user