Public Access
Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to another vault, the second question asking whether a group's deletion takes its hosts with it, and — since long before either — deleting a host at all. What that left was a head whose v3 + can fill a keychain and whose editors can correct one, with no way to empty either. The commands could not simply be bound. DeleteGroup and MoveGroup aim at GroupTarget, which is the selected card or the open group, and the phone has neither: its list draws headings, and a heading's selection deliberately bounces back to the host. Called bare on that head they would have returned having done nothing — a DELETE that appears to have been pressed and has not. Both now take the row and fall back to GroupTarget for the desktop's menu, and ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than from the selection, which is also the honester answer on the desktop: what moves is the shelf the panel was opened on. The heading's pencil became a menu. Three icons after a chevron, a name, a vault badge and a count is what would be left of the name at 360dp, so the ⋯ raises the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete — the desktop's card menu, in the one idiom this screen already has. It does not carry Open: the desktop's grid holds one level of the group tree and this list holds all of it flattened, so there is nowhere to open a group into. DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than beside them. A phone has no hover and no tooltip, so where a thumb lands is the only thing separating a destructive control from an ordinary one. Both questions take the controls that asked them — ShowsConnectControls, which is the phone's half of the rule ShowsHostPaneActions already carries for the desktop's drawer — so DELETE cannot be pressed a second time underneath its own confirmation. Preferences gained the running version, and the sentence saying this head does not replace itself and that no DodoSSH server will ever offer one. It reads Updates.CurrentVersion off the same view model the desktop's UPDATES section does, over the null channel that reports itself unsupported. Nothing was needed for the realtime push: it is composed in ServerConnection, which both heads use. Seven tests, all phone-shaped — a group acted on with nothing selected, the menu waved away leaving nothing armed, the ungrouped heading raising none, and the bar's three states. The rectangles remain unmeasurable for the reason phase 8 gives; the checks for them are 8.10 to 8.13 and 13.6.
This commit is contained in:
@@ -390,8 +390,9 @@ to" picker on the Keychain screen and cannot be moved yet.
|
|||||||
|
|
||||||
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
||||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||||
the desktop. That is the desktop only, because the phone draws groups as headings in the host list and has
|
the desktop. The phone has the same three on a sheet raised from the group's heading, since it draws no
|
||||||
never had a way to delete or move one. It is the same re-seal
|
cards and has no right-click; it does not have Open, because its list shows the whole tree flat and there is
|
||||||
|
nothing to open a group into. It is the same re-seal
|
||||||
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
|
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
|
||||||
host filed under any of them, each taking a new id in the destination. Moving less than that was never
|
host filed under any of them, each taking a new id in the destination. Moving less than that was never
|
||||||
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
|
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
|
||||||
@@ -500,9 +501,12 @@ transfer starts, the download runs into the cache, and the finished bytes are co
|
|||||||
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
|
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
|
||||||
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
|
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
|
||||||
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
|
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
|
||||||
is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from
|
is in the background, where Android will not show one at all. Hosts and groups are made, corrected and taken
|
||||||
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
|
away here now, from a floating + on the Hosts screen and a menu on each group's heading, and both editors are
|
||||||
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
|
cards in the list's own row rather than dialogs, so the form never covers the thing it is about. Every
|
||||||
|
question a deletion asks is the desktop's, counted the same way and drawn in the place the buttons that
|
||||||
|
asked it were — including the tick deciding whether a group's machines go with it. The keychain has no
|
||||||
|
editor of its own: SSH keys and buckets are
|
||||||
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
||||||
confirmation — without offering to change it. What this head does make, it makes where the need arises
|
confirmation — without offering to change it. What this head does make, it makes where the need arises
|
||||||
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
|
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
|
||||||
|
|||||||
+14
-2
@@ -604,8 +604,20 @@ What is left, in the order it matters:
|
|||||||
the decision above for the shape and for the one thing about it a person will notice — an empty file where
|
the decision above for the shape and for the one thing about it a person will notice — an empty file where
|
||||||
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
|
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
|
||||||
save picker names one destination, so SAVE FILE takes the selected row.
|
save picker names one destination, so SAVE FILE takes the selected row.
|
||||||
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
- ~~**Editors.**~~ **Half built.** The v3 hosts screen has both a host editor and a group editor — cards in
|
||||||
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
the list's own row, swapped for the list rather than stacked over it — reached from the design's floating
|
||||||
|
`+` through a sheet asking which of the two is being added. The keychain still has none, so keys,
|
||||||
|
passwords and buckets remain create-on-desktop-and-sync; its one action is DELETE.
|
||||||
|
|
||||||
|
**And everything can now be taken away as well as made.** DELETE under a host, and a group menu on the
|
||||||
|
heading carrying Edit, Move to another vault and Delete — the desktop's card menu, as a bottom sheet,
|
||||||
|
because this head draws no group cards and has no right-click. Both questions replace the controls that
|
||||||
|
asked them rather than stacking over them, which is the rule this document's step 7 already recorded for
|
||||||
|
the keychain's. A screen that could fill a keychain and never empty one was the shape before it.
|
||||||
|
|
||||||
|
The one entry the phone's menu does not carry is Open. The desktop's grid holds one level of the group
|
||||||
|
tree at a time and this list holds all of it flattened, so there is nothing here to open a group *into* —
|
||||||
|
see the correction under *Sidebar* above, which is the same difference stated from the other end.
|
||||||
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
||||||
the three whose view model needed nothing new on the phone, because none of that screen is vault content.
|
the three whose view model needed nothing new on the phone, because none of that screen is vault content.
|
||||||
- **The App Link upgrade**, unchanged from step 5.
|
- **The App Link upgrade**, unchanged from step 5.
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
||||||
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
|
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
|
||||||
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
|
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
|
||||||
|
> | Nothing at all on a group beyond its heading | ◆ **A menu on the heading, which is the second thing on this list the design had no slot for.** The desktop puts Edit, Move to another vault and Delete on the group's own card; this head draws no group cards and has no right-click, so the `⋯` raises the add sheet's shape with the same three entries in the same order — Move above the rule because it is not a deletion, Delete below it. Open is the one entry it does not carry: the desktop's grid holds one level of the tree and this list holds all of it flattened, so there is nowhere to open a group *into*. It was a pencil alone while Edit was the only thing behind it. |
|
||||||
|
> | Nothing that removes a host | ◆ **DELETE, on a row of its own under EDIT and MOVE.** A phone has no hover and no tooltip, so where a thumb lands is the only thing separating a destructive button from an ordinary one — full width in the danger colour, below both, is the same arrangement KEYCHAIN uses. The counted question takes the bar's controls in place rather than stacking over them, which is what stops DELETE being pressed a second time under its own confirmation. Until v3 this head could fill a keychain and never empty one. |
|
||||||
|
> | PREFERENCES, which v2 draws as a list of settings | ◆ **One setting, one fact, and a paragraph of absences.** The setting is whether this phone may unlock with a fingerprint — see the screen. The fact is the running version, which matters more here than on the desktop: there it sits beside a CHECK NOW that will say whether it is current, and this head does not replace itself at all. What it says instead is where a newer one comes from, which is ADR 0011 rule 2 in plain words — never from the server you sign in to. |
|
||||||
> | TERMINAL's empty state: a sentence saying nothing is open | ◆ **More than the design asked for, which is rare enough on this list to say out loud.** The tab is called **Connections** and with nothing running the screen is where one is made: a box taking `user@host` or `user@host:port` with a password, and the machines most recently connected to underneath it, read from the vault's own connection log. The box is the only path in the product to a machine that is not in the keychain — the design assumes every connection starts from a saved host, and the case it has no answer for is an address somebody was handed five minutes ago. Nothing typed there is saved, and the screen says so. |
|
> | TERMINAL's empty state: a sentence saying nothing is open | ◆ **More than the design asked for, which is rare enough on this list to say out loud.** The tab is called **Connections** and with nothing running the screen is where one is made: a box taking `user@host` or `user@host:port` with a password, and the machines most recently connected to underneath it, read from the vault's own connection log. The box is the only path in the product to a machine that is not in the keychain — the design assumes every connection starts from a saved host, and the case it has no answer for is an address somebody was handed five minutes ago. Nothing typed there is saved, and the screen says so. |
|
||||||
>
|
>
|
||||||
> ## The desktop's v2
|
> ## The desktop's v2
|
||||||
|
|||||||
+88
-4
@@ -847,10 +847,10 @@ add a bucket — then come back.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8 — Adding hosts on the phone
|
## Phase 8 — Adding and removing on the phone's host list
|
||||||
|
|
||||||
Nine checks, and the reason there are nine rather than none is worth stating: **the layout suite cannot see
|
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite
|
||||||
any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||||
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
|
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
|
||||||
@@ -936,15 +936,87 @@ Tap + → New host, then use the back gesture.
|
|||||||
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
|
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
|
||||||
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
|
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
|
||||||
|
|
||||||
|
Repeat with the group menu: tap the `⋯` on a group heading, then back.
|
||||||
|
|
||||||
|
**Pass:** the menu closes and nothing is armed behind it — no editor, no question, no move panel.
|
||||||
|
|
||||||
### 8.9 Every target is a thumb's size
|
### 8.9 Every target is a thumb's size
|
||||||
|
|
||||||
Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and the four buttons.
|
Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the four buttons.
|
||||||
|
|
||||||
**Pass:** nothing is under 44 effective pixels tall.
|
**Pass:** nothing is under 44 effective pixels tall.
|
||||||
|
|
||||||
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
|
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
|
||||||
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
|
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
|
||||||
|
|
||||||
|
### 8.10 The group menu names its group, and the heading still fits
|
||||||
|
|
||||||
|
Needs a vault with at least one group holding a host. On HOSTS, look at the group's heading row first.
|
||||||
|
|
||||||
|
**Pass:** chevron, name, count and a `⋯` all on one line, with the name trimmed rather than pushing the
|
||||||
|
count off the right edge. On a shared session the vault badge is between the name and the count and the name
|
||||||
|
gives way before the badge does.
|
||||||
|
|
||||||
|
Tap the `⋯`.
|
||||||
|
|
||||||
|
**Pass:** a sheet rises carrying **GROUP** and the group's own name, then three rows — Edit, Move to another
|
||||||
|
vault, a rule, and Delete in the danger colour — each with a sentence under it, and CANCEL. Tapping the
|
||||||
|
scrim closes it.
|
||||||
|
|
||||||
|
**Failure means:** if the name is missing, the sheet is bound to the wrong thing; the whole point of naming
|
||||||
|
it is that the heading it was raised from may have scrolled away under the scrim.
|
||||||
|
|
||||||
|
### 8.11 Deleting a group asks the second question
|
||||||
|
|
||||||
|
`⋯` → Delete on a group with at least two hosts filed under it.
|
||||||
|
|
||||||
|
**Pass:** a question above the list, naming the group, saying how many hosts are filed under it and that
|
||||||
|
left alone they move to UNGROUPED — with an **unticked** box offering to delete them as well, below the
|
||||||
|
count and not above it. DELETE and KEEP under that.
|
||||||
|
|
||||||
|
Press KEEP, then raise the question again on a *different* group.
|
||||||
|
|
||||||
|
**Pass:** the box is unticked again. A tick carried over from the last question would delete a second
|
||||||
|
group's machines on the strength of a decision about the first.
|
||||||
|
|
||||||
|
Now tick it and press DELETE.
|
||||||
|
|
||||||
|
**Pass:** the group and its hosts are gone. Untick it on a third group and the hosts survive under UNGROUPED.
|
||||||
|
|
||||||
|
### 8.12 Moving a group names the shelf · **needs a second writable vault**
|
||||||
|
|
||||||
|
`⋯` → Move to another vault.
|
||||||
|
|
||||||
|
**Pass:** a panel above the list reading **MOVE GROUP** and the group's name, a vault picker that does not
|
||||||
|
offer the vault it is already in, the paragraph about what stays behind, and MOVE / CANCEL.
|
||||||
|
|
||||||
|
Press MOVE.
|
||||||
|
|
||||||
|
**Pass:** the group, the groups nested inside it and every host under any of them are in the other vault,
|
||||||
|
the group is at the top level, and the status line says so.
|
||||||
|
|
||||||
|
**Failure means:** if MOVE appears to do nothing at all, the command is reading the desktop's group
|
||||||
|
selection, which this head never sets. That is the exact failure the header-aimed commands exist to prevent
|
||||||
|
— see `VaultViewModel.MoveGroupFromHeading`.
|
||||||
|
|
||||||
|
With only one writable vault, the entry answers on the status line instead and no panel opens. That is the
|
||||||
|
intended answer, not a failure.
|
||||||
|
|
||||||
|
### 8.13 DELETE under a host is not beside EDIT
|
||||||
|
|
||||||
|
Select a host so the connect bar rises.
|
||||||
|
|
||||||
|
**Pass:** CONNECT, then EDIT and MOVE side by side, then DELETE full width in the danger colour on a row of
|
||||||
|
its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone.
|
||||||
|
|
||||||
|
Press DELETE.
|
||||||
|
|
||||||
|
**Pass:** the question takes the whole bar — no CONNECT, no EDIT, no second DELETE underneath it — and KEEP
|
||||||
|
puts them back with the host still there.
|
||||||
|
|
||||||
|
**Failure means:** a bar that kept its buttons under the question is one where DELETE can be pressed twice,
|
||||||
|
the second press landing on whatever the list rebuilt underneath.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9 — Tag chips and the picker
|
## Phase 9 — Tag chips and the picker
|
||||||
@@ -1406,6 +1478,18 @@ rather than two. There is no confirmation prompt, deliberately.
|
|||||||
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
||||||
half that matters when the handset is the thing that was lost.
|
half that matters when the handset is the thing that was lost.
|
||||||
|
|
||||||
|
### 13.6 The phone says which build it is
|
||||||
|
|
||||||
|
Scroll the same screen to **THIS BUILD**.
|
||||||
|
|
||||||
|
**Pass:** a VERSION row carrying a real version rather than `1.0.0`, matching the tag the APK was built
|
||||||
|
from, with the paragraph under it saying this head does not replace itself and that no DodoSSH server will
|
||||||
|
ever offer you a newer one.
|
||||||
|
|
||||||
|
**Failure means:** `1.0.0` is MinVer answering from a checkout with no tags — see check 16.5 for the same
|
||||||
|
failure on the desktop. A missing paragraph means `Updates.IsUnsupported` came back false on a head that
|
||||||
|
has no updater at all, which would be the null channel not being the one composed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 14 — Moving files to and from the phone's remote
|
## Phase 14 — Moving files to and from the phone's remote
|
||||||
|
|||||||
@@ -89,6 +89,94 @@
|
|||||||
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
|
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
|
||||||
Text="{Binding UnreadableSummary}" />
|
Text="{Binding UnreadableSummary}" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ ◆ what the sheet raised about a group ============ -->
|
||||||
|
<!--
|
||||||
|
Both of these are about a heading in the list below, and both are drawn above it rather than over it
|
||||||
|
— which is what KEYCHAIN does with its own confirmation and for the reason written there: a card on
|
||||||
|
top of the list hides the row it is talking about, and the row is the information the question exists
|
||||||
|
to give. Neither replaces the list, because neither replaces the heading either.
|
||||||
|
|
||||||
|
At most one of the two is ever up. MoveGroup disarms a deletion and DeleteGroup folds the move away,
|
||||||
|
which is the same pairing the desktop's GROUPS section makes.
|
||||||
|
|
||||||
|
◆ THE DELETION QUESTION, and the tick under it is the whole reason it is a question rather than a
|
||||||
|
warning. Off is "the machines stay and turn up under UNGROUPED", which is recoverable; on is not, and
|
||||||
|
it is reset to off on every question — see VaultViewModel.DeletionTakesTheHostsToo. Below the count
|
||||||
|
rather than above it, so nobody answers it before reading how many.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsConfirmingGroupDeletion}" Background="{StaticResource DangerWash}"
|
||||||
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12" Padding="13,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Question}" />
|
||||||
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||||
|
Text="{Binding PendingDeletion.Consequence}" />
|
||||||
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||||
|
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||||||
|
Text="{Binding PendingDeletion.Usage}" />
|
||||||
|
|
||||||
|
<CheckBox MinHeight="44" IsVisible="{Binding PendingDeletion.HasChoice}"
|
||||||
|
IsChecked="{Binding DeletionTakesTheHostsToo}">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource WarnText}" Text="{Binding PendingDeletion.Choice}" />
|
||||||
|
</CheckBox>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||||
|
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||||
|
Command="{Binding CancelDeleteCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ MOVING THE GROUP TO ANOTHER VAULT. The host's panel one level up, and not drawn in the danger
|
||||||
|
colours beside it: what is being asked is which vault, and a move is undone by moving it back.
|
||||||
|
|
||||||
|
It names the group, which the desktop's copy of this panel does not have to — there the group's own
|
||||||
|
card is on screen underneath the picker, and here the panel is above a list the heading has scrolled
|
||||||
|
out of. Same reason the connect bar names the host. See VaultViewModel.MovingGroupLabel.
|
||||||
|
|
||||||
|
The sentence is not decoration. A group cannot go anywhere alone: the groups nested inside it and
|
||||||
|
every machine filed under any of them are items of the vault it is leaving, so all of them are
|
||||||
|
re-sealed under the destination's key. What cannot come is the group it was nested under, which is why
|
||||||
|
it arrives at the top level, and the hosts' tags stay behind with it. On a phone the status line
|
||||||
|
afterwards is one line at the bottom of a screen somebody has already navigated away from, so before
|
||||||
|
the tap is the only place this reliably gets read.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsMovingGroup}" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12"
|
||||||
|
Padding="13,12">
|
||||||
|
<StackPanel Spacing="9">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Classes="label" Text="MOVE GROUP" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding MovingGroupLabel}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding MoveGroupVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveGroupVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding ConfirmMoveGroupCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveGroupCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ============ the list ============ -->
|
<!-- ============ the list ============ -->
|
||||||
@@ -144,16 +232,21 @@
|
|||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The phone's only way into a group's editor. The desktop reaches it from a groups panel this
|
◆ The phone's only way to do anything to a group. The desktop reaches all three from a menu on
|
||||||
head does not draw, and a heading's own selection deliberately bounces back to the host — so
|
the group's own card, which this head does not draw, and a heading's own selection deliberately
|
||||||
without this a group could be created here and never corrected, which matters more for a group
|
bounces back to the host — so without this a group could be created here and never corrected,
|
||||||
than for a host: its defaults are what every host beneath it falls back to.
|
moved or removed. It matters more for a group than for a host: its defaults are what every host
|
||||||
|
beneath it falls back to, and it is the one thing on this screen that can take hosts with it.
|
||||||
|
|
||||||
Hidden on the ungrouped heading, which has no group behind it to edit.
|
It was the pencil alone, and became a menu when there were three things behind it rather than
|
||||||
|
one. Three icons after a chevron, a name, a vault badge and a count is what would be left of the
|
||||||
|
name at 360dp; see VaultViewModel.OpenGroupSheet.
|
||||||
|
|
||||||
|
Hidden on the ungrouped heading, which has no group behind it to act on.
|
||||||
-->
|
-->
|
||||||
<Button Grid.Column="3" Classes="icon" FontSize="13" Content="✎" Margin="4,0,0,0"
|
<Button Grid.Column="3" Classes="icon" FontSize="15" Content="⋯" Margin="4,0,0,0"
|
||||||
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
|
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).EditGroupFromHeadingCommand}"
|
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).OpenGroupSheetCommand}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -470,12 +563,14 @@
|
|||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Everything about connecting, in one group so that the move panel below can take the bar rather
|
Everything about connecting, in one group so that the two panels below can take the bar rather
|
||||||
than appear underneath it. A password box and a CONNECT button under a form asking which vault to
|
than appear underneath it. A password box and a CONNECT button under a form asking which vault to
|
||||||
move the host into would be two unrelated questions in one bar, and the taller of the two would
|
move the host into would be two unrelated questions in one bar, and the taller of the two would
|
||||||
push the other off a phone screen.
|
push the other off a phone screen. The deletion question is the second such panel and needs it
|
||||||
|
more: DELETE must not be pressable underneath its own confirmation. See
|
||||||
|
VaultViewModel.ShowsConnectControls, which is the one question both of them ask.
|
||||||
-->
|
-->
|
||||||
<StackPanel Spacing="10" IsVisible="{Binding !IsMovingHost}">
|
<StackPanel Spacing="10" IsVisible="{Binding ShowsConnectControls}">
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<TextBlock Classes="label" Text="CONNECT TO" />
|
<TextBlock Classes="label" Text="CONNECT TO" />
|
||||||
@@ -522,7 +617,7 @@
|
|||||||
so it is a re-seal into one and a tombstone in the other — nothing a SAVE could do. It shows only
|
so it is a re-seal into one and a tombstone in the other — nothing a SAVE could do. It shows only
|
||||||
where there is somewhere to move to; see VaultViewModel.CanMoveSelectedHost.
|
where there is somewhere to move to; see VaultViewModel.CanMoveSelectedHost.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsMovingHost}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding ShowsConnectControls}">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||||
Command="{Binding EditSelectedHostCommand}" />
|
Command="{Binding EditSelectedHostCommand}" />
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||||
@@ -530,6 +625,40 @@
|
|||||||
Command="{Binding MoveHostCommand}" />
|
Command="{Binding MoveHostCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ A row of its own, under the pair rather than beside them, and that is the point of it. A phone
|
||||||
|
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary one
|
||||||
|
is where a thumb lands — and DELETE sharing a row with EDIT would put the two a few millimetres
|
||||||
|
apart. Full width in the danger colour, below both, is the same arrangement KEYCHAIN uses for the
|
||||||
|
one action it has.
|
||||||
|
|
||||||
|
It was the last thing this head could not do to a host. The + creates them and EDIT corrects them,
|
||||||
|
which left a screen that could fill a keychain and never empty one.
|
||||||
|
-->
|
||||||
|
<Button Classes="danger" Height="44" Content="DELETE" IsVisible="{Binding ShowsConnectControls}"
|
||||||
|
Command="{Binding DeleteHostCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE QUESTION, in the place the buttons that asked it were. The count is the whole difference
|
||||||
|
between a sentence somebody reads and one they press past — here it is the terminal still open on
|
||||||
|
the machine, which a deletion deliberately does not close.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="7" IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Question}" />
|
||||||
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||||
|
Text="{Binding PendingDeletion.Consequence}" />
|
||||||
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||||
|
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||||||
|
Text="{Binding PendingDeletion.Usage}" />
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||||
|
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||||
|
Command="{Binding CancelDeleteCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ MOVING THE HOST TO ANOTHER VAULT, in the place the connect controls were. A picker and two
|
◆ MOVING THE HOST TO ANOTHER VAULT, in the place the connect controls were. A picker and two
|
||||||
buttons rather than a question with a yes: what is being asked is which vault, and a move is
|
buttons rather than a question with a yes: what is being asked is which vault, and a move is
|
||||||
@@ -646,6 +775,87 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ ◆ the group sheet ============ -->
|
||||||
|
<!--
|
||||||
|
The three things that can be done to a group, raised from the ⋯ on its heading. The desktop draws the
|
||||||
|
same three as a right-click menu on the group's card; this head has no card and no right-click, so it
|
||||||
|
is the add sheet's shape doing the same job — a scrim, a bottom panel with the top two corners rounded,
|
||||||
|
and a grab handle that is decoration.
|
||||||
|
|
||||||
|
Dismissible, on the add sheet's terms and not the host key sheet's: "which of these three" has no wrong
|
||||||
|
answer and none of them is a perfectly good outcome.
|
||||||
|
|
||||||
|
It names the group in its own title, which the desktop's menu does not have to — a right-click menu
|
||||||
|
opens over the card it is about, and this opens over a list the heading may have scrolled out of. Two
|
||||||
|
vaults may each hold a "production", so the badge on the heading is the other half of that answer.
|
||||||
|
|
||||||
|
◆ Delete is below a rule and Move is above it, which is the desktop's own order and its own separator:
|
||||||
|
moving is not a field of the group and it is not a deletion either. The two vaults are encrypted under
|
||||||
|
different keys, so a move is a re-seal of the whole shelf into one and a tombstone per item in the
|
||||||
|
other — the entry says so rather than reading as a filing gesture.
|
||||||
|
|
||||||
|
Open is the entry the desktop has and this does not. Its grid holds one level of the tree at a time and
|
||||||
|
this list holds all of it flattened, so there is nothing here to open a group into.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding GroupSheet, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
|
||||||
|
<Button Classes="scrim" Command="{Binding CloseGroupSheetCommand}" />
|
||||||
|
|
||||||
|
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
|
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
|
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Classes="title" Text="GROUP" FontSize="13" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding GroupSheetLabel}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Classes="row" Margin="0,10,0,0"
|
||||||
|
Command="{Binding EditGroupFromHeadingCommand}"
|
||||||
|
CommandParameter="{Binding GroupSheet}">
|
||||||
|
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Edit" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5"
|
||||||
|
Text="Its name, where it sits, and the port, user and key its hosts fall back to." />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="row"
|
||||||
|
Command="{Binding MoveGroupFromHeadingCommand}"
|
||||||
|
CommandParameter="{Binding GroupSheet}">
|
||||||
|
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Text="Move to another vault" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="Takes the groups inside it and every host filed under them, re-encrypted with the other vault's key." />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Height="1" Background="{StaticResource Border}" Margin="0,8" />
|
||||||
|
|
||||||
|
<Button Classes="row"
|
||||||
|
Command="{Binding DeleteGroupFromHeadingCommand}"
|
||||||
|
CommandParameter="{Binding GroupSheet}">
|
||||||
|
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Danger}" Text="Delete" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="Asks first, and asks separately whether the hosts filed under it go too." />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="secondary" Content="CANCEL" Margin="0,12,0,0"
|
||||||
|
Command="{Binding CloseGroupSheetCommand}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -370,8 +370,8 @@ 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 add sheet sits over the list and the two editors sit in place of it, so
|
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
|
||||||
/// the sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
||||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
||||||
/// editor the user was looking at.
|
/// editor the user was looking at.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -394,6 +394,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
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)
|
||||||
|
{
|
||||||
|
vault.CloseGroupSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.IsEditing)
|
if (vault.IsEditing)
|
||||||
{
|
{
|
||||||
vault.CancelEditCommand.Execute(null);
|
vault.CancelEditCommand.Execute(null);
|
||||||
|
|||||||
@@ -81,6 +81,37 @@
|
|||||||
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
|
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
|
||||||
Text="{Binding StatusMessage}" />
|
Text="{Binding StatusMessage}" />
|
||||||
|
|
||||||
|
<!-- ============ ◆ which build this is ============ -->
|
||||||
|
<!--
|
||||||
|
A fact, printed, in the shape ACCOUNT uses on the desktop's screen — and the same string the desktop
|
||||||
|
prints under UPDATES, read off the same place. It comes from the assembly's own informational
|
||||||
|
version, so it answers on a build run from a checkout as well as on a released one; see
|
||||||
|
ClientVersion.Current.
|
||||||
|
|
||||||
|
It is worth a row of its own on the head that has no updater. On the desktop the version sits beside
|
||||||
|
a CHECK NOW that will tell you whether it is current; here it is the only answer, and it is the one
|
||||||
|
thing to read out when somebody asks which build is misbehaving. The sentence under it says where a
|
||||||
|
newer one comes from, which is ADR 0011 rule 2 in plain words: never from the server you sign in to.
|
||||||
|
|
||||||
|
Bound through Updates, which is the shell's update view model and exists on this head too — over the
|
||||||
|
null channel, so it reports itself unsupported and its loop never starts. That is why there is no
|
||||||
|
button here rather than a disabled one. See ADR 0013.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="section" Text="THIS BUILD" Margin="6,26,0,0" />
|
||||||
|
|
||||||
|
<Border Classes="card" Margin="0,10,0,0">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="VERSION" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="13" Margin="10,0,0,0"
|
||||||
|
HorizontalAlignment="Right" Text="{Binding Updates.CurrentVersion}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" IsVisible="{Binding Updates.IsUnsupported}"
|
||||||
|
Text="This head does not replace itself. A newer DodoSSH is an APK from the project's own release page, installed the way this one was — and no DodoSSH server will ever offer you one, whatever it says. A server that does is not a server to trust." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="section" Text="NOT HERE" Margin="6,26,0,0" />
|
<TextBlock Classes="section" Text="NOT HERE" Margin="6,26,0,0" />
|
||||||
|
|
||||||
<Border Classes="card" Margin="0,10,0,0">
|
<Border Classes="card" Margin="0,10,0,0">
|
||||||
|
|||||||
@@ -1994,6 +1994,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowsConnectControls))]
|
||||||
private bool isMovingHost;
|
private bool isMovingHost;
|
||||||
|
|
||||||
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
|
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
|
||||||
@@ -2056,11 +2057,22 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private VaultChoiceViewModel? selectedMoveGroupVault;
|
private VaultChoiceViewModel? selectedMoveGroupVault;
|
||||||
|
|
||||||
|
/// <summary>The group the open move panel is about, by name.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For the phone, which draws this panel over its host list rather than beside the card it was opened
|
||||||
|
/// from — so unlike the desktop, where the group's own tile is on screen underneath the picker, there is
|
||||||
|
/// nothing left saying which shelf is about to move. The same reason its connect bar names the host.
|
||||||
|
/// Held rather than read back through <see cref="GroupTarget"/>, which is a desktop selection and is
|
||||||
|
/// null on the head that needs this.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string movingGroupLabel = string.Empty;
|
||||||
|
|
||||||
// There is deliberately no CanMoveGroup to match CanMoveSelectedHost. That one exists so the phone can
|
// There is deliberately no CanMoveGroup to match CanMoveSelectedHost. That one exists so the phone can
|
||||||
// leave a button out rather than draw one that answers with a refusal; a group is reached through the
|
// leave a button out rather than draw one that answers with a refusal; a group is reached through a menu
|
||||||
// card's right-click menu, which is not drawn until it is opened and whose entries do not move. The one
|
// on both heads — the desktop's right-click, the phone's sheet — and a menu is not drawn until it is
|
||||||
// place the question decides anything is MoveGroup, which asks it by building the picker and saying so
|
// opened and its entries do not move. The one place the question decides anything is MoveGroup, which
|
||||||
// when it comes back empty.
|
// asks it by building the picker and saying so when it comes back empty.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the drawer's header says it is about.
|
/// What the drawer's header says it is about.
|
||||||
@@ -2118,16 +2130,47 @@ internal sealed partial class VaultViewModel(
|
|||||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||||
private bool isAddSheetOpen;
|
private bool isAddSheetOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group heading the phone's action sheet is open on, or null when it is closed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The phone's answer to the desktop's right-click menu, and it holds a heading rather than a row
|
||||||
|
/// because a heading is what its list draws — see <see cref="EditGroupFromHeading"/> for why a group
|
||||||
|
/// there is not a thing that can be selected, which is what leaves the three commands with nothing to
|
||||||
|
/// aim at unless the gesture carries it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// One nullable property rather than a flag beside a field, so that "open" and "open on what" cannot
|
||||||
|
/// disagree — the sheet names the group in its own title, and a flag left true beside a cleared header
|
||||||
|
/// would be a menu about nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The heading is not resolved to a group until one of the entries is pressed. A sheet is a menu and
|
||||||
|
/// deciding not to use it is a perfectly good outcome, so nothing is looked up on the way in; a heading
|
||||||
|
/// whose group has gone by the time an entry is pressed is dropped there. See <see cref="GroupOf"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(GroupSheetLabel))]
|
||||||
|
private SidebarGroupHeader? groupSheet;
|
||||||
|
|
||||||
|
/// <summary>Which group the sheet says it is about.</summary>
|
||||||
|
internal string GroupSheetLabel => GroupSheet?.Label ?? string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether anything the host screen can put over its list is showing.
|
/// Whether anything the host screen can put over its list is showing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// One property rather than three tests at each call site, and it exists because two controls need
|
/// One property rather than four tests at each call site, and it exists because two controls need
|
||||||
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
|
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
|
||||||
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
|
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
|
||||||
/// does nothing — and the back gesture closes them before it considers leaving the screen.
|
/// does nothing — and the back gesture closes them before it considers leaving the screen.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;
|
internal bool AnEditorIsOpen =>
|
||||||
|
IsAddSheetOpen || GroupSheet is not null || IsEditing || IsEditingGroup;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the phone's connect bar has anything to be about.
|
/// Whether the phone's connect bar has anything to be about.
|
||||||
@@ -2140,6 +2183,22 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the phone's connect bar is showing its own controls rather than one of the two panels that
|
||||||
|
/// take their place.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The bar carries three things and draws one of them: connecting to the host, the picker asking which
|
||||||
|
/// vault to move it to, and the question asking whether to delete it. This is the first, and the reason
|
||||||
|
/// it is a property rather than two conditions in the markup is that Avalonia's bindings have no "and".
|
||||||
|
/// <para>
|
||||||
|
/// It is the same rule <see cref="ShowsHostPaneActions"/> carries for the desktop's drawer, minus that
|
||||||
|
/// one's <c>IsShowingHostDetail</c>: the phone has no pane to open, so its bar's own
|
||||||
|
/// <see cref="ShowsConnectBar"/> already answers whether there is a host at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsConnectControls => !IsMovingHost && !IsConfirmingHostDeletion;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string editorLabel = string.Empty;
|
private string editorLabel = string.Empty;
|
||||||
|
|
||||||
@@ -4985,6 +5044,62 @@ internal sealed partial class VaultViewModel(
|
|||||||
Status = $"Editing {row.Label}.";
|
Status = $"Editing {row.Label}.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the phone's menu of the three things that can be done to a group.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The desktop's right-click menu, as a bottom sheet, and the same three entries in the same order for
|
||||||
|
/// the same reasons: opening is a gesture on the desktop and so is offered here as well, moving sits
|
||||||
|
/// above the separator because it is not a deletion, and deleting sits below it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Open is the entry this one does <em>not</em> carry, and the difference is real rather than an
|
||||||
|
/// abbreviation: the desktop's grid holds one level of the tree at a time and the phone's list holds all
|
||||||
|
/// of it flattened, so there is nothing on this head to open a group <em>into</em>. See the note on
|
||||||
|
/// <c>SidebarRows</c> in the desktop's HostsScreen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It is a menu rather than three buttons on the heading row, and that is a width decision before it is
|
||||||
|
/// a taste one: the row already carries a chevron, a name, a vault badge and a count at 360dp, and three
|
||||||
|
/// icons after them would leave the name a dozen characters. It is also what makes the two destructive
|
||||||
|
/// entries reachable without either of them being a control a thumb can brush.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenGroupSheet(SidebarGroupHeader? header)
|
||||||
|
{
|
||||||
|
// The ungrouped heading has no group behind it, so there is nothing for the three entries to act on.
|
||||||
|
// The button is left off that row as well; this is the guard for the path a stale row would take.
|
||||||
|
if (header?.GroupId is not null)
|
||||||
|
{
|
||||||
|
GroupSheet = header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the group's menu without doing anything.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reached from CANCEL and from a tap on the scrim, and the sheet is dismissible that way for the reason
|
||||||
|
/// the add sheet is and the host key sheet deliberately is not: "which of these three" has no wrong
|
||||||
|
/// answer, and none of them is one of them.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseGroupSheet() => GroupSheet = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group a heading in the host list names, or null where it names none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A heading carries an id, a label and a count; every one of the three commands below needs the record.
|
||||||
|
/// A heading whose group has gone — the ungrouped heading, or one a sync deleted between the list being
|
||||||
|
/// drawn and the entry being pressed — resolves to nothing and is dropped, rather than opening an editor
|
||||||
|
/// or arming a question on nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private HostGroupRowViewModel? GroupOf(SidebarGroupHeader? header) =>
|
||||||
|
header?.GroupId is { } groupId
|
||||||
|
? Groups.FirstOrDefault(row => row.EntityId == groupId)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a group's editor from its heading in the host list.
|
/// Opens a group's editor from its heading in the host list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4994,8 +5109,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// desktop reaches the group editor through the groups panel, which selects a
|
/// desktop reaches the group editor through the groups panel, which selects a
|
||||||
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
|
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
|
||||||
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
|
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
|
||||||
/// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
|
/// not a thing to be selected. So the heading needs a menu, and the menu's entries need commands that
|
||||||
/// the header rather than the selection.
|
/// take the header rather than the selection.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
|
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
|
||||||
@@ -5003,28 +5118,66 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// inherited: getting one wrong is wrong for every host beneath it at once.
|
/// inherited: getting one wrong is wrong for every host beneath it at once.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
|
|
||||||
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
|
|
||||||
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
|
|
||||||
/// opening an editor on nothing.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The row is handed to <see cref="EditGroup"/> rather than selected first, which it used to be. A group
|
/// The row is handed to <see cref="EditGroup"/> rather than selected first, which it used to be. A group
|
||||||
/// selection now clears the host selection — the two grids share one mark — and the phone draws no group
|
/// selection now clears the host selection — the two grids share one mark — and the phone draws no group
|
||||||
/// cards, so selecting one here would have taken the highlight off the machine in the list with nothing
|
/// cards, so selecting one here would have taken the highlight off the machine in the list with nothing
|
||||||
/// on screen to say where it had gone.
|
/// on screen to say where it had gone.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The sheet is closed first and unconditionally, including where the heading resolves to nothing. A
|
||||||
|
/// menu left standing over a command that declined to run is a menu somebody presses again.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
||||||
{
|
{
|
||||||
if (header?.GroupId is not { } groupId
|
var row = GroupOf(header);
|
||||||
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
|
|
||||||
|
GroupSheet = null;
|
||||||
|
|
||||||
|
if (row is not null)
|
||||||
{
|
{
|
||||||
return;
|
EditGroupCommand.Execute(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EditGroupCommand.Execute(row);
|
/// <summary>Opens the group's move panel from its heading in the host list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Aimed by the header rather than by <see cref="GroupTarget"/>, which is the whole reason this exists:
|
||||||
|
/// that property reads the selected card or the open group, and the phone has neither — so
|
||||||
|
/// <see cref="MoveGroup"/> called bare on this head would silently do nothing at all. The sheet is
|
||||||
|
/// closed first, on <see cref="EditGroupFromHeading"/>'s terms.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveGroupFromHeading(SidebarGroupHeader? header)
|
||||||
|
{
|
||||||
|
var row = GroupOf(header);
|
||||||
|
|
||||||
|
GroupSheet = null;
|
||||||
|
|
||||||
|
if (row is not null)
|
||||||
|
{
|
||||||
|
MoveGroupCommand.Execute(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the group's deletion question from its heading in the host list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Aimed by the header for the reason <see cref="MoveGroupFromHeading"/> is, and it matters more here:
|
||||||
|
/// a <see cref="DeleteGroup"/> that quietly aimed at nothing would be a DELETE that appeared to have
|
||||||
|
/// been pressed and had not.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void DeleteGroupFromHeading(SidebarGroupHeader? header)
|
||||||
|
{
|
||||||
|
var row = GroupOf(header);
|
||||||
|
|
||||||
|
GroupSheet = null;
|
||||||
|
|
||||||
|
if (row is not null)
|
||||||
|
{
|
||||||
|
DeleteGroupCommand.Execute(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
|
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
|
||||||
@@ -5325,14 +5478,21 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
|
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Aims where Edit does: at the selected card, which on the desktop is the one the menu opened on. See
|
/// Aims where Edit does: at the group it is handed, and failing that at the selected card, which on the
|
||||||
/// <see cref="GroupTarget"/>.
|
/// desktop is the one the menu opened on. See <see cref="GroupTarget"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The argument is the phone's and the fallback is the desktop's.</b> The desktop's menu passes
|
||||||
|
/// nothing, because the code-behind has already selected whatever was right-clicked; the phone has no
|
||||||
|
/// group selection to make — a heading is not a thing its list can select — so its sheet passes the row
|
||||||
|
/// it was opened on. See <see cref="DeleteGroupFromHeading"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="group">The group to ask about, or null to use <see cref="GroupTarget"/>.</param>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void DeleteGroup()
|
private void DeleteGroup(HostGroupRowViewModel? group)
|
||||||
{
|
{
|
||||||
if (GroupTarget is not { } row)
|
if ((group ?? GroupTarget) is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5850,11 +6010,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
|
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
|
||||||
/// as <see cref="DeleteGroup"/> is: this rewrites hosts.
|
/// as <see cref="DeleteGroup"/> is: this rewrites hosts.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The argument is the phone's and the fallback is the desktop's</b>, exactly as it is on
|
||||||
|
/// <see cref="DeleteGroup"/>. See <see cref="MoveGroupFromHeading"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="group">The group to move, or null to use <see cref="GroupTarget"/>.</param>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void MoveGroup()
|
private void MoveGroup(HostGroupRowViewModel? group)
|
||||||
{
|
{
|
||||||
if (GroupTarget is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
if ((group ?? GroupTarget) is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5877,6 +6042,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// As MoveHost disarms a deletion aimed at the same host.
|
// As MoveHost disarms a deletion aimed at the same host.
|
||||||
PendingDeletion = null;
|
PendingDeletion = null;
|
||||||
movingGroupId = row.EntityId;
|
movingGroupId = row.EntityId;
|
||||||
|
MovingGroupLabel = row.Label;
|
||||||
IsMovingGroup = true;
|
IsMovingGroup = true;
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -5892,6 +6058,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
IsMovingGroup = false;
|
IsMovingGroup = false;
|
||||||
movingGroupId = null;
|
movingGroupId = null;
|
||||||
|
MovingGroupLabel = string.Empty;
|
||||||
MoveGroupVaultChoices.Clear();
|
MoveGroupVaultChoices.Clear();
|
||||||
SelectedMoveGroupVault = null;
|
SelectedMoveGroupVault = null;
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
@@ -5928,12 +6095,20 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// exist for. What is reported is a binding now outside the destination, since that is precisely what
|
/// exist for. What is reported is a binding now outside the destination, since that is precisely what
|
||||||
/// the other holders of it will not be able to resolve.
|
/// the other holders of it will not be able to resolve.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The group is resolved from the panel's own id rather than from <see cref="GroupTarget"/>.</b> That
|
||||||
|
/// property is the desktop's selection and is null on the phone, whose sheet aims the move by handing
|
||||||
|
/// the row in — so reading it here would leave the phone's MOVE button doing nothing at all. It also
|
||||||
|
/// says the honest thing on both heads: what this moves is the shelf the panel was opened on, and a
|
||||||
|
/// selection that has since gone elsewhere has already folded the panel away. See
|
||||||
|
/// <see cref="CloseTheGroupMovePanelIfAimedElsewhere"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
|
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (GroupTarget is not { } row
|
if (movingGroupId is not { } moved
|
||||||
|| movingGroupId != row.EntityId
|
|| Groups.FirstOrDefault(group => group.EntityId == moved) is not { } row
|
||||||
|| SelectedMoveGroupVault is not { } target)
|
|| SelectedMoveGroupVault is not { } target)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -8831,6 +9006,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
||||||
OnPropertyChanged(nameof(ShowsHostActions));
|
OnPropertyChanged(nameof(ShowsHostActions));
|
||||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||||
|
OnPropertyChanged(nameof(ShowsConnectControls));
|
||||||
OnPropertyChanged(nameof(ShowsItemActions));
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4576,6 +4576,180 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.IsEditingGroup.ShouldBeFalse();
|
vault.IsEditingGroup.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at
|
||||||
|
/// all. <c>DeleteGroup</c> and <c>MoveGroup</c> aim at <c>GroupTarget</c>, which is the selected card or
|
||||||
|
/// the open group — and the phone has neither, because its list draws headings and a heading is not a
|
||||||
|
/// thing that list can select. Called bare on that head they would return having done nothing, which is
|
||||||
|
/// a DELETE that appears to have been pressed and has not.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The tick is asserted <em>off</em> as well as present. Off is "the machines stay and turn up under
|
||||||
|
/// UNGROUPED", which is recoverable; on is not, and a question that arrived with the destructive answer
|
||||||
|
/// already given would be worse than one that never asked.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AGroupsHeading_AsksAboutThatGroupWithNothingSelected()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddGroupAsync(vault, "production");
|
||||||
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||||
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");
|
||||||
|
|
||||||
|
vault.OpenGroupSheetCommand.Execute(heading);
|
||||||
|
|
||||||
|
vault.GroupSheetLabel.ShouldBe("production", "the menu names what it is about");
|
||||||
|
vault.AnEditorIsOpen.ShouldBeTrue("so the + stands down, as it does under the add sheet");
|
||||||
|
|
||||||
|
vault.DeleteGroupFromHeadingCommand.Execute(heading);
|
||||||
|
|
||||||
|
vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
|
||||||
|
vault.IsConfirmingGroupDeletion.ShouldBeTrue();
|
||||||
|
vault.PendingDeletion!.Question.ShouldContain("production");
|
||||||
|
vault.PendingDeletion.HasChoice.ShouldBeTrue("a host is filed under it, so it has a second question");
|
||||||
|
vault.DeletionTakesTheHostsToo.ShouldBeFalse("keeping them is the answer that needs no decision");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A menu is a thing you are allowed to decide against, which is why this one is dismissible where the
|
||||||
|
/// host key sheet deliberately is not. Nothing may be left armed behind it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheGroupsMenu_WavedAway_LeavesEverythingAsItWas()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddGroupAsync(vault, "production");
|
||||||
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||||
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.OpenGroupSheetCommand.Execute(heading);
|
||||||
|
vault.CloseGroupSheetCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.GroupSheet.ShouldBeNull();
|
||||||
|
vault.AnEditorIsOpen.ShouldBeFalse();
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeFalse();
|
||||||
|
vault.IsMovingGroup.ShouldBeFalse();
|
||||||
|
vault.IsEditingGroup.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheUngroupedHeading_RaisesNoMenu()
|
||||||
|
{
|
||||||
|
// Nothing behind it for the three entries to act on. The button is left off that row, so this is the
|
||||||
|
// guard for the path the markup does not control.
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddGroupAsync(vault, "production");
|
||||||
|
|
||||||
|
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||||
|
.Single(row => row.GroupId is null);
|
||||||
|
|
||||||
|
vault.OpenGroupSheetCommand.Execute(ungrouped);
|
||||||
|
|
||||||
|
vault.GroupSheet.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Deleting a host, from the phone's bar ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The phone's bar carries three things and draws one of them: connecting, the picker asking which vault
|
||||||
|
/// to move the host to, and this question. What is pinned here is that the question <em>takes</em> the
|
||||||
|
/// controls rather than appearing under them — DELETE pressable a second time underneath its own
|
||||||
|
/// confirmation is the reason the desktop's drawer has the same rule.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The bar itself stays up, and that is the other half: it is where the question is drawn. A question in
|
||||||
|
/// a bar that had collapsed would be a question nobody could answer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingAHostFromThePhonesBar_TakesTheControlsWhileItAsks()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = vault.Hosts.Single();
|
||||||
|
|
||||||
|
vault.ShowsConnectBar.ShouldBeTrue();
|
||||||
|
vault.ShowsConnectControls.ShouldBeTrue();
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.IsConfirmingHostDeletion.ShouldBeTrue();
|
||||||
|
vault.ShowsConnectBar.ShouldBeTrue("the bar is where the question is drawn");
|
||||||
|
vault.ShowsConnectControls.ShouldBeFalse("so DELETE cannot be pressed under its own question");
|
||||||
|
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.ShowsConnectControls.ShouldBeTrue();
|
||||||
|
vault.Hosts.ShouldHaveSingleItem("KEEP keeps it");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingAHostFromThePhonesBar_RemovesItOnceItIsAgreedTo()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = 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 ----
|
||||||
//
|
//
|
||||||
// The type has been storable since the domain landed and unreachable until now. What these pin is the
|
// The type has been storable since the domain landed and unreachable until now. What these pin is the
|
||||||
|
|||||||
@@ -981,6 +981,64 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vault.Status.ShouldContain("only vault you can write to");
|
vault.Status.ShouldContain("only vault you can write to");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The phone's route into the same move, and it is a different route rather than the same one reached
|
||||||
|
/// differently. That head's list draws group headings rather than cards, a heading is deliberately not
|
||||||
|
/// something it can select, and nothing there opens a group — so <c>GroupTarget</c> is null and a move
|
||||||
|
/// that only read it would leave the menu entry doing nothing at all. The panel is aimed by the heading
|
||||||
|
/// the menu was raised on instead.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The innermost shelf is the one moved, because it is the one with a machine on it and so the one with
|
||||||
|
/// a heading. That it arrives at the top level is the same rule the card's move follows: the group it
|
||||||
|
/// was nested under belongs to the vault it is leaving.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task MovingAGroupFromItsHeading_TakesItsHostsWithNothingSelected()
|
||||||
|
{
|
||||||
|
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 SeedNestedShelfAsync(vault);
|
||||||
|
|
||||||
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||||
|
row => string.Equals(row.Label, "web", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");
|
||||||
|
|
||||||
|
vault.OpenGroupSheetCommand.Execute(heading);
|
||||||
|
vault.MoveGroupFromHeadingCommand.Execute(heading);
|
||||||
|
|
||||||
|
vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
|
||||||
|
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
|
||||||
|
vault.MovingGroupLabel.ShouldBe("web", "the panel names the shelf, having left the list behind");
|
||||||
|
|
||||||
|
vault.SelectedMoveGroupVault =
|
||||||
|
vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
|
||||||
|
await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var moved = Named(vault, "web");
|
||||||
|
|
||||||
|
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||||
|
moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");
|
||||||
|
|
||||||
|
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
host.VaultId.ShouldBe(sharedVaultId, "the machine came with the shelf");
|
||||||
|
host.Host.GroupId.ShouldBe(moved.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The group card with a given name, re-found because every row is replaced on every reload.</summary>
|
/// <summary>The group card with a given name, re-found because every row is replaced on every reload.</summary>
|
||||||
private static HostGroupRowViewModel Named(VaultViewModel vault, string label) =>
|
private static HostGroupRowViewModel Named(VaultViewModel vault, string label) =>
|
||||||
vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|||||||
Reference in New Issue
Block a user