diff --git a/README.md b/README.md
index bac43c1..1ea0717 100644
--- a/README.md
+++ b/README.md
@@ -342,10 +342,16 @@ Six limits, stated rather than discovered:
into it that is not there.
Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
-editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
-vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it
-again — and the picker is therefore absent when you edit an existing host rather than present and refusing.
-Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen.
+editor has its own picker**, beside the name, because it is the decision on that form that decides who can
+read the host. It is absent when you edit an existing one rather than present and refusing, and that is not
+because the host is stuck: **a host can be moved to another vault** — "Move to another vault…" in the detail
+pane's menu on the desktop, MOVE beside EDIT on the phone. It is a separate act because it is not a save.
+The two vaults are encrypted under different keys, so a move is a re-seal into one and a tombstone in the
+other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
+vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
+What a move cannot do is reach a machine that has already synced the host, which is the same limit
+everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
+to" picker on the Keychain screen and cannot be moved yet.
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault.
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index 9cf65d9..830da52 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
-> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes — is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. |
+> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index 821f6f2..fd589c3 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -262,10 +262,12 @@
@@ -279,7 +281,7 @@
+ Text="A host in a shared vault is readable by everybody holding that vault's key. It can be moved out later, with MOVE under the host — what it cannot do is become unreadable to somebody who has already synced it." />
@@ -432,45 +434,97 @@
BorderThickness="0,1,0,0" Padding="14,12">
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/HostDrawer.axaml b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
index 24b4813..21063d7 100644
--- a/src/DodoSSH.Client.App/Views/HostDrawer.axaml
+++ b/src/DodoSSH.Client.App/Views/HostDrawer.axaml
@@ -71,11 +71,12 @@
One row for all three panels, which is why what it says is on the view model rather than repeated
three times here. See VaultViewModel.DrawerTitle.
- The subtitle is the vault this host is filed in, and the design's chevron beside it is not drawn:
- an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
- is a delete and a retype — and a picker offering the move would be offering something no layer below
- this can do. Choosing the vault at the moment a host is created is a different question and does
- have an answer; it is in the editor, beside the name.
+ The subtitle is the vault this host is filed in, and the design's chevron beside it is still not
+ drawn although a host can now be moved. The two are encrypted under different keys, so a move is a
+ re-seal into one vault and a tombstone in the other — it leaves the host's group and tags behind and
+ gives it a new id, none of which a chevron on a subtitle would lead anybody to expect. It is in the
+ menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
+ moment a host is created is a different question, and it is in the editor beside the name.
-->
@@ -91,10 +92,15 @@
+
@@ -365,12 +372,16 @@
@@ -624,6 +635,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index 1340856..65373b8 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -1817,8 +1817,58 @@ internal sealed partial class VaultViewModel(
/// while the deletion question is up it would offer to ask it again — which is the rule
/// has always carried for the row of buttons these two replaced. The
/// question takes CONNECT's place in the footer for the same reason it took DELETE's.
+ ///
+ /// The move panel is in that list too and for the same reason. It takes the footer as well, so leaving
+ /// CONNECT under it would put two things in one row — and the menu it came from would still be offering
+ /// to open it.
+ ///
///
- internal bool ShowsHostPaneActions => IsShowingHostDetail && !IsConfirmingHostDeletion;
+ internal bool ShowsHostPaneActions =>
+ IsShowingHostDetail && !IsConfirmingHostDeletion && !IsMovingHost;
+
+ ///
+ /// Whether the panel asking which vault to move the selected host to is up.
+ ///
+ ///
+ /// The armed-state idiom this window uses everywhere instead of a modal, and here it carries a choice
+ /// rather than a yes: the question is not "are you sure" but "which vault", and the sentence beside it
+ /// says what will be left behind. See .
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
+ private bool isMovingHost;
+
+ /// Which host the open move panel is about. Null when it is closed.
+ ///
+ /// Held rather than read off the selection, so the panel survives a reload replacing every row object —
+ /// see , which is the only thing that reads it.
+ ///
+ private Guid? movingHostId;
+
+ /// Where the selected host could be moved: every vault this session can write to but its own.
+ internal ObservableCollection MoveVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedMoveVault;
+
+ ///
+ /// Whether the selected host has anywhere to move to.
+ ///
+ ///
+ ///
+ /// Asked so the phone can leave the button out rather than offer one that answers with a refusal — it
+ /// has room for two buttons under a host and no room to explain a third that does nothing. The desktop
+ /// keeps its menu entry either way: a menu that grew and shrank would be a menu whose items move.
+ ///
+ ///
+ /// It counts vaults rather than merely asking whether there are two, because the answer is per host: a
+ /// host already in the only other writable vault has nowhere to go, and a read-only vault is not
+ /// somewhere anything can be moved to.
+ ///
+ ///
+ internal bool CanMoveSelectedHost =>
+ SelectedHost is { IsReadOnly: false } row
+ && session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
///
/// What the drawer's header says it is about.
@@ -1839,11 +1889,12 @@ internal sealed partial class VaultViewModel(
/// The line under it: which keychain this is filed in, or what a group is for.
///
///
- /// The vault's name and not a picker for it, although the design draws one with a chevron. An item
- /// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
- /// under different keys — so a control offering the move would be offering something no layer below
- /// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
- /// .
+ /// The vault's name and not a picker for it, although the design draws one with a chevron. A host can
+ /// be moved between vaults now — see — and it is still not a field: the move is
+ /// a re-seal under another key and a tombstone under this one, so binding it to a control that saves
+ /// with the rest of the form would let somebody correcting a port move a machine by leaving a picker
+ /// where they found it. It has its own panel and its own button. Where a *new* item goes is chosen on
+ /// the keychain screen's own picker; see .
///
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
{
@@ -1961,9 +2012,10 @@ internal sealed partial class VaultViewModel(
///
///
/// Only while creating, and only where there is more than one vault to choose between. An existing
- /// host's vault is not editable and the picker is not shown disabled beside it: the two are encrypted
- /// under different keys, so moving an item is a delete and a retype rather than a save — see the note
- /// on the drawer's header, which says where the host is filed.
+ /// host's vault is not a field of this form and the picker is not shown disabled beside it: the two
+ /// are encrypted under different keys, so moving one is a re-seal and a tombstone rather than a save.
+ /// That is offered — by , from the pane's own menu — and it is a separate act
+ /// precisely because it must not happen as a side effect of saving something else.
///
///
/// Hidden at one vault rather than shown with a single option, which is the rule
@@ -4907,6 +4959,185 @@ internal sealed partial class VaultViewModel(
return imported;
}
+ ///
+ /// Opens the panel that asks which vault the selected host should move to.
+ ///
+ ///
+ ///
+ /// A panel rather than a picker in the host editor, and the reason is what a move is underneath: the
+ /// item is re-sealed under another vault's key and the one it came from gets a tombstone — see
+ /// VaultItemRepository.MoveAsync . That is not a field of the host and must not be saved with
+ /// one, or somebody correcting a port would move a machine into a colleague's vault by leaving a
+ /// picker where they found it.
+ ///
+ ///
+ /// Refused for a host written by a newer client, exactly as editing one is: the move re-encodes the
+ /// payload, so a field this build cannot represent would be dropped on the way across.
+ ///
+ ///
+ [RelayCommand]
+ private void MoveHost()
+ {
+ if (SelectedHost is not { } row || AHostEditorIsInTheWay())
+ {
+ return;
+ }
+
+ if (row.IsReadOnly)
+ {
+ Status = "This host was written by a newer version of DodoSSH. Moving it would re-encode it "
+ + "here and lose what this build cannot read. Update first.";
+ return;
+ }
+
+ BuildMoveVaultChoices(row.VaultId);
+
+ if (MoveVaultChoices.Count == 0)
+ {
+ // The one-vault case, and the honest sentence rather than an empty picker. It is also what
+ // somebody in a team whose only other vault is read-only sees.
+ Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
+ return;
+ }
+
+ // Disarms a deletion aimed at the same host. Two questions about one machine, one of which
+ // destroys it, is not a pane anybody should have to read carefully.
+ PendingDeletion = null;
+ movingHostId = row.EntityId;
+ IsMovingHost = true;
+ Status = string.Empty;
+ }
+
+ /// Abandons the move panel.
+ [RelayCommand]
+ private void CancelMoveHost()
+ {
+ IsMovingHost = false;
+ movingHostId = null;
+ MoveVaultChoices.Clear();
+ SelectedMoveVault = null;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Moves the selected host into the chosen vault.
+ ///
+ ///
+ ///
+ /// The group and the tags are left behind, and that is the whole of what makes this honest. Both
+ /// are items of the vault the host is leaving: the group picker in the editor offers one vault's groups
+ /// and the tag chips are drawn from one vault's tags, so a host carrying either across would point at
+ /// something the destination does not contain. On this machine it would still resolve — groups and tags
+ /// are resolved across every readable vault — and for everybody else in the destination it would dangle,
+ /// which means the mover and their colleagues would see two different hosts. Cleared and reported beats
+ /// carried and invisible.
+ ///
+ ///
+ /// The key or password binding is kept , and the difference is not inconsistency. Those genuinely
+ /// resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for —
+ /// so clearing them would take a working host and make it one that cannot connect. What it can do is say
+ /// when the binding is now in a different vault from the host, because that is exactly what the other
+ /// members of the destination will not be able to resolve.
+ ///
+ ///
+ /// The row is re-selected by its new id afterwards. A move that left the pane on a host that no longer
+ /// exists would read as the machine having been deleted.
+ ///
+ ///
+ [RelayCommand]
+ private async Task ConfirmMoveHostAsync(CancellationToken cancellationToken)
+ {
+ if (SelectedHost is not { } row || SelectedMoveVault is not { } target)
+ {
+ return;
+ }
+
+ var name = target.Name;
+ var dropped = WhatWasLeftBehind(row.Host);
+ var stranded = BindingOutside(row.Host, target.VaultId);
+
+ var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
+
+ IsMovingHost = false;
+ movingHostId = null;
+ MoveVaultChoices.Clear();
+ SelectedMoveVault = null;
+
+ await RunAsync(
+ "Moving…",
+ async () =>
+ {
+ var entityId = await session.Hosts
+ .MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
+
+ Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
+ }).ConfigureAwait(true);
+
+ // As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
+ // them and not the other shows the host twice or not at all until the next pass.
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// What the move left behind, said only when it left something.
+ private static string WhatWasLeftBehind(HostSecret host) =>
+ (host.GroupId is not null, host.TagIds.Count > 0) switch
+ {
+ (true, true) => " Its group and tags were left behind — both belong to the vault it came from.",
+ (true, false) => " Its group was left behind — a group belongs to the vault it is in.",
+ (false, true) => " Its tags were left behind — a tag belongs to the vault it is in.",
+ _ => string.Empty,
+ };
+
+ ///
+ /// The warning about a key or password that is not in the vault the host has moved to.
+ ///
+ ///
+ /// Named rather than counted, because which one it is decides what to do about it — and the answer is
+ /// usually to put a copy of that key in the destination vault, which needs to know which key.
+ ///
+ private string BindingOutside(HostSecret host, Guid vaultId)
+ {
+ if (host.SshKeyId is { } keyId
+ && Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
+ && key.VaultId != vaultId)
+ {
+ return $" It still authenticates with the key '{key.Label}', which is in another vault — "
+ + "everybody else in this one will find that binding unresolvable.";
+ }
+
+ if (host.CredentialId is { } credentialId
+ && Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
+ && credential.VaultId != vaultId)
+ {
+ return $" It still authenticates with the password '{credential.Label}', which is in another "
+ + "vault — everybody else in this one will find that binding unresolvable.";
+ }
+
+ return string.Empty;
+ }
+
+ /// Fills the move panel's picker with every vault this session can write to but that one.
+ private void BuildMoveVaultChoices(Guid vaultId)
+ {
+ MoveVaultChoices.Clear();
+
+ foreach (var choice in session.ReadableVaults
+ .Where(vault => vault.CanWrite && vault.VaultId != vaultId)
+ .OrderByDescending(vault => vault.IsPersonal)
+ .ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
+ .Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)))
+ {
+ MoveVaultChoices.Add(choice);
+ }
+
+ SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
+ }
+
/// Asks whether the selected host should go.
///
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -7039,10 +7270,10 @@ internal sealed partial class VaultViewModel(
/// Moves a half-typed host into the vault just chosen for it.
///
///
- /// Only while creating. An existing host's vault is fixed, and this guard is what makes that true
- /// rather than the view merely not drawing the control: an item cannot be moved between vaults, so a
- /// path that reassigned this on an edit would write the host into a second vault and leave the
- /// original behind.
+ /// Only while creating, and this guard is what makes that true rather than the view merely not drawing
+ /// the control. An existing host can change vaults — see — but not this
+ /// way and not as part of a save: reassigning it here on an edit would write the host into a second
+ /// vault and leave the original behind, which is a fork rather than a move.
///
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
@@ -7427,6 +7658,7 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsDrawerOpen));
OnPropertyChanged(nameof(IsShowingHostDetail));
OnPropertyChanged(nameof(ShowsHostPaneActions));
+ OnPropertyChanged(nameof(CanMoveSelectedHost));
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
@@ -7434,6 +7666,17 @@ internal sealed partial class VaultViewModel(
SelectedSidebarRow = value;
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
+
+ // The move panel goes with the selection, as the deletion question does — and by entity id for the
+ // same reason DisarmIfAimedElsewhere compares them: a background pass replaces every row object in
+ // the list, so a panel closed on row identity would fold up once a minute under somebody who was
+ // still choosing a vault in it. A click onto a different host is the case that needs handling, and
+ // it is cleared rather than re-aimed: which vault to move to is a choice about the host it was
+ // asked for.
+ if (IsMovingHost && movingHostId != value?.EntityId)
+ {
+ CancelMoveHostCommand.Execute(null);
+ }
}
///
diff --git a/src/DodoSSH.Client.Sync/HostRepository.cs b/src/DodoSSH.Client.Sync/HostRepository.cs
index 14044b6..531b8a3 100644
--- a/src/DodoSSH.Client.Sync/HostRepository.cs
+++ b/src/DodoSSH.Client.Sync/HostRepository.cs
@@ -38,6 +38,15 @@ public sealed class HostRepository(
CancellationToken cancellationToken) =>
hosts.UpdateAsync(vaultId, entityId, host, cancellationToken);
+ ///
+ public Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ HostSecret host,
+ CancellationToken cancellationToken) =>
+ hosts.MoveAsync(fromVaultId, toVaultId, entityId, host, cancellationToken);
+
///
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
hosts.DeleteAsync(vaultId, entityId, cancellationToken);
diff --git a/src/DodoSSH.Client.Sync/VaultItemRepository.cs b/src/DodoSSH.Client.Sync/VaultItemRepository.cs
index b46ce78..bc0936b 100644
--- a/src/DodoSSH.Client.Sync/VaultItemRepository.cs
+++ b/src/DodoSSH.Client.Sync/VaultItemRepository.cs
@@ -234,6 +234,72 @@ internal sealed class VaultItemRepository(
}
}
+ ///
+ /// Moves an item into another vault.
+ ///
+ /// The vault it is in.
+ /// The vault it should be in.
+ /// The item.
+ ///
+ /// What to write into the destination. The caller's, rather than read from here, because moving is the
+ /// one operation where the item does not arrive unchanged: references to things that live in the vault
+ /// it is leaving are the mover's to resolve, and this layer has no way to know which those are.
+ ///
+ /// Cancellation token.
+ /// The id the item has in its new vault.
+ ///
+ ///
+ /// A copy and a tombstone, and it cannot be anything else. An item's payload is sealed under
+ /// its vault's key and its AAD binds the vault, the entity id and the item version — so there is no
+ /// edit that moves one, and no server call that could: the server holds ciphertext it cannot read.
+ /// What crosses is the plaintext, in this process, between an unwrap under one key and a seal under
+ /// another.
+ ///
+ ///
+ /// A new id, deliberately. Keeping it would put one entity id in two vaults, and the item table
+ /// is keyed on the type and the id rather than on the vault — so the destination's row and the
+ /// source's tombstone would be the same row, and the move would delete what it had just written.
+ /// Callers holding the old id have to take the new one back.
+ ///
+ ///
+ /// The write comes first and the tombstone second , which decides what an interruption leaves
+ /// behind: a copy in both vaults, which is visible and can be deleted, rather than a tombstone with
+ /// nothing on the other side, which is the host gone. Both are queued rather than sent, so the window
+ /// is a crash between two local writes — narrow, and worth choosing the survivable side of anyway.
+ ///
+ ///
+ /// Two activity lines, not one: a create in the destination and a delete in the source, which is what
+ /// the vaults actually record. A single "moved" line would have to be written to one of them and would
+ /// be missing from the other's history.
+ ///
+ ///
+ internal async Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ TSecret secret,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ if (fromVaultId == toVaultId)
+ {
+ throw new ArgumentException(
+ "That item is already in that vault.", nameof(toVaultId));
+ }
+
+ // Both keys before either write, so a destination this session cannot write to is refused with
+ // nothing having happened rather than after the source item has gone.
+ _ = Key(fromVaultId);
+ _ = Key(toVaultId);
+
+ var moved = await CreateAsync(toVaultId, secret, cancellationToken).ConfigureAwait(false);
+
+ await DeleteAsync(fromVaultId, entityId, cancellationToken).ConfigureAwait(false);
+
+ return moved;
+ }
+
///
/// Deletes an item.
///
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 75e8441..c9633cb 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -223,6 +223,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
+ ///
+ ///
+ /// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
+ /// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
+ /// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
+ /// parts of this drawer that is not inside a ScrollViewer , so nothing brings it back into view.
+ ///
+ ///
+ /// The state is set here rather than through MoveHostCommand , which would refuse: this fixture's
+ /// account holds one vault, and the command declines rather than open a picker with nothing in it. What
+ /// this test is about is the rectangle, and the flow that fills it is covered in
+ /// DodoSSH.Client.App.Tests .
+ ///
+ ///
+ [Fact]
+ public async Task TheHostDrawerFitsWithTheMovePanelOpen()
+ {
+ vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
+
+ vault.MoveVaultChoices.Add(
+ new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
+
+ vault.SelectedMoveVault = vault.MoveVaultChoices[0];
+ vault.IsMovingHost = true;
+
+ vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");
+
+ await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
+ }
+
///
///
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index 5a61ec9..e29bb5c 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -532,6 +532,121 @@ public sealed class VaultSharingTests : IAsyncLifetime
row.VaultId.ShouldBe(sharedVaultId);
}
+ ///
+ ///
+ /// Moving a host into a shared vault, which is the operation that used to require deleting it and
+ /// typing it again: the two vaults are encrypted under different keys, so what happens underneath is a
+ /// re-seal into one and a tombstone in the other. The host has to arrive intact, be gone from where it
+ /// was, and carry a new id — one entity id in two vaults would make the destination's row and the
+ /// source's tombstone the same row.
+ ///
+ ///
+ /// The group is asserted cleared, and that is the half worth a test rather than a comment. A group is
+ /// an item of the vault the host is leaving, so a host that carried the reference across would resolve
+ /// it on this machine — groups are resolved over every readable vault — and dangle for everybody else
+ /// in the destination. The mover and their colleagues would be looking at two different hosts.
+ ///
+ ///
+ [Fact]
+ public async Task MovingAHostToAnotherVault_ReSealsItThereAndLeavesItsGroupBehind()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ // In the personal vault, under a group of its own, which is what the move has to leave behind.
+ 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.EditorUsername = "deploy";
+ vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
+ choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ var before = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
+
+ before.VaultId.ShouldNotBe(sharedVaultId);
+ before.Host.GroupId.ShouldNotBeNull("the host was filed under a group before the move");
+
+ vault.SelectedHost = before;
+ vault.CanMoveSelectedHost.ShouldBeTrue("there is a second vault this session can write to");
+
+ vault.MoveHostCommand.Execute(null);
+
+ vault.IsMovingHost.ShouldBeTrue(vault.Status);
+ vault.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
+
+ vault.SelectedMoveVault =
+ vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ // The pass that follows every write on this screen is made to fail, so that the move's own sentence
+ // is still on the status line to be read. That is not a contrivance to dodge a race: a successful
+ // pass reports what it moved and supersedes the confirmation of every save, delete and move alike —
+ // pre-existing behaviour of the whole screen — and the state asserted here is the one where the
+ // sentence matters most, because nothing has reached the server yet.
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
+
+ var after = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
+
+ after.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
+ after.Host.Hostname.ShouldBe("db.internal");
+ after.Host.Username.ShouldBe("deploy");
+ after.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
+
+ vault.SelectedHost?.EntityId.ShouldBe(after.EntityId, "the pane follows the host it moved");
+ vault.Status.ShouldContain("Platform secrets");
+ vault.Status.ShouldContain("group", Case.Insensitive);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task MovingAHostWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
+ {
+ await UnlockedAsync();
+
+ var vault = shell.Vault!;
+
+ await vault.LoadAsync(Token);
+
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = "prod-db";
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.SelectedHost = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
+
+ vault.CanMoveSelectedHost.ShouldBeFalse("the personal vault is the only one there is");
+
+ vault.MoveHostCommand.Execute(null);
+
+ vault.IsMovingHost.ShouldBeFalse();
+ vault.MoveVaultChoices.ShouldBeEmpty();
+ vault.Status.ShouldContain("only vault you can write to");
+ }
+
///
///
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,