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
@@ -117,7 +117,95 @@ public sealed class TerminalWorkspaceTests
}
/// <remarks>
/// Disposal is the one path that does close sessions, because it is process shutdown. Asserted so
/// <para>
/// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk
/// away"; this answers "is <em>this</em> tab still connected", and a tab that went on claiming a shell
/// it no longer has would be the same lie one row down.
/// </para>
/// <para>
/// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace
/// never issued, or has already closed, is not live — it must not throw and must not be optimistic.
/// </para>
/// </remarks>
[Fact]
public async Task LivenessIsAnsweredPerSession()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var first = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
var second = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
workspace.IsSessionLive(first).ShouldBeTrue();
workspace.IsSessionLive(second).ShouldBeTrue();
workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(first);
workspace.IsSessionLive(first).ShouldBeFalse();
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
}
/// <remarks>
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
/// closed has a caller who already knows, and telling it would turn one close into two.
/// </remarks>
[Fact]
public async Task ASessionEndingOnItsOwnIsAnnounced()
{
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
// so the pump finishes with nobody asking it to.
var connections = new FakeConnectionFactory(bytesPerShell: 0);
await using var workspace = CreateWorkspace(connections);
var ended = new List<uint>();
workspace.SessionEnded += (_, e) =>
{
lock (ended)
{
ended.Add(e.SessionId);
}
};
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await WaitUntilAsync(() =>
{
lock (ended)
{
return ended.Contains(sessionId);
}
});
}
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
[Fact]
public async Task ClosingASessionIsNotAnnouncedBack()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var announcements = 0;
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
Volatile.Read(ref announcements)
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
}
/// <remarks>
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
/// that the SSH connections are known to be released rather than assumed to be.
/// </remarks>
[Fact]