Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
+220 -55
View File
@@ -997,25 +997,65 @@ public sealed class ShellFlowTests : IAsyncLifetime
// ---- One kind of item at a time ----
/// <remarks>
/// Hosts are not one of these any more. They have their own screen beside the terminal, which is what the
/// design asks for and is the better split anyway: the host list is what you look at while you work, and
/// the keys and passwords behind it are what you go and manage. What is left here is the vault screen's
/// own rail, and it opens on everything at once because the categories are a filter over one table
/// rather than four separate lists.
/// </remarks>
[Fact]
public async Task TheColumnOpensOnHostsAndTheSelectorMovesBetweenSections()
public async Task TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
{
await UnlockedAsync();
var vault = shell.Vault!;
// Hosts, because connecting is what somebody who has just unlocked a vault came to do. Keys and
// credentials exist to make that work, and neither is where the first click belongs.
vault.Section.ShouldBe(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
vault.Section.ShouldBe(VaultSection.All);
vault.ShowsAll.ShouldBeTrue();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsKeys.ShouldBeTrue();
vault.ShowsHosts.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
vault.ShowsAll.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsAll.ShouldBeTrue();
}
/// <remarks>
/// The merged category is what the design's one credential table exists for, and the thing worth pinning
/// about it is that it is a projection rather than a fifth list: every row maps back to the typed row the
/// editors and the delete commands already act on.
/// </remarks>
[Fact]
public async Task TheMergedTableCarriesEveryKindAndSelectingARowSelectsTheTypedOne()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
// Adding leaves the rail on whatever was added last, because opening an editor brings its own
// category into view. Back to the merged one, which is where the screen opens.
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsAll.ShouldBeTrue();
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy", "pg-primary"]);
vault.VaultItems.Select(row => row.Type).ShouldBe(["SSH KEY", "PASSWORD"]);
vault.SelectedVaultItem = vault.VaultItems.First(row => row.Kind is VaultItemKind.Credential);
vault.SelectedCredential.ShouldNotBeNull();
vault.SelectedCredential.Label.ShouldBe("pg-primary");
vault.SelectedItemIsEditable.ShouldBeTrue();
// And narrowing to one kind does not disturb what is selected underneath.
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy"]);
vault.SelectedCredential.Label.ShouldBe("pg-primary", "narrowing the view is not a deselection");
}
/// <remarks>
@@ -1032,33 +1072,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
vault.Section = VaultSection.Hosts;
vault.Section = VaultSection.All;
vault.NewKeyCommand.Execute(null);
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open in the hosts section");
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open with the rail pointing elsewhere");
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.ShowsHosts.ShouldBeTrue();
vault.CancelEditCommand.Execute(null);
// And through the other door into each editor.
// And through the other door into the editor.
vault.Section = VaultSection.All;
vault.SelectedKey = vault.Keys[0];
vault.EditSelectedKeyCommand.Execute(null);
vault.ShowsKeys.ShouldBeTrue();
vault.CancelKeyEditCommand.Execute(null);
// The host editor is the exemption, and it is the point of the split: hosts are a screen of their
// own, so opening their editor has no category to bring into view and must not move the rail.
vault.Section = VaultSection.Keys;
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.ShowsHosts.ShouldBeTrue();
vault.IsEditing.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("editing a host is not a reason to move the vault screen's rail");
}
/// <remarks>
/// The refusal that keeps the rule above true. Leaving the section while an editor is open would hide it,
/// and in the key editor's case that means a pasted private key sitting in a form with nothing on screen
/// to say it is there.
/// The refusal that keeps the rule above true. Leaving the section while a vault-screen editor is open
/// would hide it, and in the key editor's case that means a pasted private key sitting in a form with
/// nothing on screen to say it is there.
/// </remarks>
[Fact]
public async Task SwitchingSectionIsRefusedWhileAnEditorIsOpen()
public async Task SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -1066,7 +1107,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.NewKeyCommand.Execute(null);
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor");
vault.Status.ShouldContain("SSH key");
@@ -1074,18 +1115,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
// A refusal, not a lockout: dealing with the editor releases the selector.
vault.CancelKeyEditCommand.Execute(null);
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsAll.ShouldBeTrue();
}
/// <remarks>
/// The host editor lives on a different screen from the rail, so it does not guard the rail — nothing
/// about a half-typed host is visible or at risk from switching the vault screen's own category, and
/// blocking it here used to leave three quarters of that screen inert with a message pointing at an
/// editor the user could not see.
/// </remarks>
[Fact]
public async Task SwitchingSectionIsNotBlockedByAnOpenHostEditor()
{
await UnlockedAsync();
var vault = shell.Vault!;
// Symmetrically, and with the message naming the editor that is actually open — which matters more
// here than it used to, because the thing to go back to may not be the section on screen.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsHosts.ShouldBeTrue();
vault.Status.ShouldContain("host");
vault.ShowsKeys.ShouldBeTrue("a host editor on another screen has nothing to say about this rail");
vault.IsEditing.ShouldBeTrue("switching category must not close the host editor either");
vault.EditorLabel.ShouldBe("half-typed");
}
@@ -1103,20 +1155,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.NewHostCommand.Execute(null);
vault.Status = string.Empty;
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.Status.ShouldBeEmpty();
vault.IsEditing.ShouldBeTrue();
}
/// <remarks>
/// One editor at a time, still — but no longer for the reason it was introduced for. Both editors used to
/// be <c>Auto</c> rows in one 340-pixel column whose combined height exceeded it; sections ended that, and
/// the layout suite now measures two open editors fitting. What the rule buys today is that an open key
/// editor is always one somebody can see, because it is holding their private key.
/// One editor open at a time <em>within</em> the vault screen, still — but no longer across it and the
/// Hosts screen. Both editors used to be <c>Auto</c> rows in one 340-pixel column whose combined height
/// exceeded it; sections ended that, and the design import gave hosts their own screen, so the host
/// editor no longer shares any column, any visibility or any risk with the key and credential editors.
/// What the rule still buys, inside the vault screen, is that an open key editor is always one somebody
/// can see, because it is holding their private key.
/// </remarks>
[Fact]
public async Task OnlyOneEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
public async Task TheHostEditorAndAVaultScreenEditorCanBeOpenTogether()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -1126,7 +1180,30 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse("the host editor must not open over the key editor");
// Both open at once: the host editor is on a screen of its own, and there is nothing for it to
// clip or hide on the vault screen the key editor is on.
vault.IsEditing.ShouldBeTrue("the host editor is a different screen's business now");
vault.IsEditingKey.ShouldBeTrue("and does not close the vault screen's own editor");
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
}
/// <remarks>
/// One vault-screen editor at a time, still. The key and credential editors share the same screen and
/// the same detail pane, so opening one over the other is exactly the case the rule exists for — unlike
/// the host editor, which does not.
/// </remarks>
[Fact]
public async Task OnlyOneVaultScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewKeyCommand.Execute(null);
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeFalse("the credential editor must not open over the key editor");
vault.IsEditingKey.ShouldBeTrue();
vault.Status.ShouldContain("SSH key");
@@ -1135,43 +1212,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
// And it is a refusal, not a lockout.
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
// Symmetrically, with the host editor holding the column.
vault.EditorLabel = "half-typed";
// Symmetrically, with the credential editor holding the screen.
vault.CredentialEditorPassword = "half-typed";
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
vault.EditorLabel.ShouldBe("half-typed");
vault.Status.ShouldContain("host");
vault.CredentialEditorPassword.ShouldBe("half-typed");
vault.Status.ShouldContain("credential");
}
[Fact]
public async Task EditingAnExistingItem_IsRefusedByTheOtherEditorToo()
public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherVaultScreenEditorToo()
{
// The Edit commands are a second door into the same column, and guarding only the Add ones would
// The Edit commands are a second door into the same screen, and guarding only the Add ones would
// leave it wide open.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
vault.SelectedHost = vault.Hosts[0];
vault.SelectedKey = vault.Keys[0];
vault.SelectedCredential = vault.Credentials[0];
vault.NewKeyCommand.Execute(null);
vault.EditSelectedHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse();
vault.EditSelectedCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeFalse();
vault.CancelKeyEditCommand.Execute(null);
vault.NewHostCommand.Execute(null);
vault.NewCredentialCommand.Execute(null);
vault.EditSelectedKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeFalse();
}
/// <remarks>
/// The host editor's own version of the same rule: opening a second host editor over the first, or
/// editing an existing host while adding one, is refused — this is the one case the split guard still
/// has to cover, because both doors lead to the same single editor on the Hosts screen.
/// </remarks>
[Fact]
public async Task TheHostEditorRefusesToOpenOverItself()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel.ShouldBe("half-typed", "the second door must not discard the first editor's draft");
vault.Status.ShouldContain("host");
}
// ---- Binding a key to a host ----
[Fact]
@@ -1704,8 +1804,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]);
}
/// <remarks>
/// The credential editor guards the vault screen's own rail, exactly as the key editor does — but no
/// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
/// </remarks>
[Fact]
public async Task TheCredentialEditorIsAlsoOneEditorAtATime()
public async Task TheCredentialEditorGuardsTheVaultScreensRail()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -1713,17 +1817,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorPassword = "half-typed";
// The host editor opens freely: it is the Hosts screen's own business now.
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse("the host editor must not open over the credential editor");
vault.Status.ShouldContain("credential");
vault.IsEditing.ShouldBeTrue("a different screen's editor is not this one's to refuse");
vault.IsEditingCredential.ShouldBeTrue("and opening it must not have closed the credential editor");
vault.CredentialEditorPassword.ShouldBe("half-typed");
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsCredentials.ShouldBeTrue("and the selector must not move away from it either");
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsCredentials.ShouldBeTrue("the rail must not move away from an open vault-screen editor");
vault.CancelCredentialEditCommand.Execute(null);
vault.ShowSectionCommand.Execute(VaultSection.Hosts);
vault.ShowsHosts.ShouldBeTrue();
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ShowsAll.ShouldBeTrue();
}
// ---- Pinned host keys ----
@@ -1838,11 +1943,71 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.ShowsKnownHosts.ShouldBeTrue();
vault.ShowsHosts.ShouldBeFalse();
vault.ShowsAll.ShouldBeFalse();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowsCredentials.ShouldBeFalse();
}
// ---- Filtering the host sidebar ----
/// <remarks>
/// The filter's own list is a projection over <see cref="VaultViewModel.Hosts"/>, not the bound source
/// of the sidebar's selection — but <c>HostSidebar</c>'s <c>ListBox</c> two-way binds
/// <c>SelectedItem</c> to <see cref="VaultViewModel.SelectedHost"/> against exactly that projection, so
/// a naive rebuild that cleared the list before refilling it would have the list null the selection out
/// from under the user on every keystroke, even when the filter still matches the selected host.
/// </remarks>
[Fact]
public async Task FilteringTheHostListPreservesTheSelectionWhenItStillMatches()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-web-01");
await AddHostAsync(vault, "prod-web-02");
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
vault.HostFilter = "prod";
vault.VisibleHosts.Count.ShouldBe(2, "both hosts match the filter");
vault.SelectedHost.ShouldNotBeNull();
vault.SelectedHost!.Label.ShouldBe("prod-web-01", "a filter that still matches must not clear it");
vault.HostFilter = "web-02";
vault.VisibleHosts.ShouldHaveSingleItem();
vault.SelectedHost.ShouldBeNull("the selected host no longer matches, so there is nothing to keep");
vault.HostFilter = string.Empty;
vault.VisibleHosts.Count.ShouldBe(2);
}
/// <remarks>
/// The same hazard as the filter, reached through the other caller of the rebuild: a background sync
/// pass reloads the host list every minute, and <c>ReloadHostsAsync</c> deliberately restores the
/// selection before handing off to the sidebar's projection. Losing it there would be exactly the "move
/// the terminal's target out from under the user" outcome that restoration exists to prevent.
/// </remarks>
[Fact]
public async Task ReloadingTheVaultPreservesTheSidebarsSelection()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
await vault.LoadAsync(Token);
vault.SelectedHost.ShouldNotBeNull();
vault.SelectedHost!.Label.ShouldBe("stage-web");
vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;