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
+162 -15
View File
@@ -27,6 +27,14 @@ public sealed class TerminalWorkspaceOptions
public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15);
}
/// <summary>A session whose shell has ended.</summary>
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
{
/// <summary>The session that ended.</summary>
public uint SessionId { get; } = sessionId;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -52,6 +60,19 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private readonly TimeProvider clock;
private readonly TerminalWorkspaceOptions options;
private readonly Dictionary<uint, LiveSession> sessions = [];
/// <summary>
/// Guards <see cref="sessions"/> and <see cref="nextSessionId"/>.
/// </summary>
/// <remarks>
/// This dictionary is genuinely touched from more than one thread, which the tab strip made true rather
/// than merely arguable: sessions are opened and closed from the UI thread, and
/// <see cref="RunSessionAsync"/>'s finally now asks whether the session is still known — on whichever
/// thread-pool thread the pump happened to unwind on. An unsynchronised <see cref="Dictionary{TKey,
/// TValue}"/> read concurrent with a write does not merely return a stale answer; it can throw or spin.
/// The data plane guards its own registration table the same way and for the same reason.
/// </remarks>
private readonly Lock sessionGate = new();
private readonly CancellationTokenSource lifetime = new();
private uint nextSessionId = 1;
@@ -95,7 +116,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// is there to prevent.
/// </para>
/// </remarks>
public int LiveSessionCount => sessions.Values.Count(session => !session.Run.IsCompleted);
public int LiveSessionCount
{
get
{
lock (sessionGate)
{
return sessions.Values.Count(session => !session.Run.IsCompleted);
}
}
}
/// <summary>
/// Whether one session's shell is still running.
/// </summary>
/// <remarks>
/// The same question <see cref="LiveSessionCount"/> answers in aggregate, and answered the same way: a
/// completed <c>Run</c> is what "the shell is gone" looks like, because nothing removes the entry when
/// the remote closes the channel on its own. A session id this workspace never issued is not live.
/// </remarks>
public bool IsSessionLive(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted;
}
}
/// <summary>
/// Raised with the session id when a shell ends on its own.
/// </summary>
/// <remarks>
/// <para>
/// A tab has to be able to stop claiming it is connected, and polling would be the alternative: a timer
/// asking <see cref="IsSessionLive"/> often enough to look immediate, for a thing that happens a handful
/// of times a day. The renderer already learns about this instantly — the pump sends
/// <c>SessionClosed</c> and the page writes the reason into the pane — so this is the same fact reaching
/// the half of the interface Avalonia draws.
/// </para>
/// <para>
/// <b>Raised on whatever thread the pump finished on</b>, which is a thread-pool thread. A handler that
/// touches an observable collection has to marshal; this type has no toolkit to do it with, which is
/// exactly why it does not try.
/// </para>
/// <para>
/// Not raised by <see cref="CloseSessionAsync"/>. That path already has a caller who knows the session is
/// going, and telling it what it just asked for is how a tab close turns into a second tab close.
/// </para>
/// </remarks>
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -144,8 +213,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
throw;
}
var sessionId = nextSessionId++;
var pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
uint sessionId;
TerminalSessionPump pump;
lock (sessionGate)
{
sessionId = nextSessionId++;
pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
}
dataPlane.Register(sessionId, pump);
@@ -153,39 +228,90 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// frame has somewhere to go.
var run = RunSessionAsync(sessionId, pump);
sessions[sessionId] = new LiveSession(connection, pump, run);
lock (sessionGate)
{
sessions[sessionId] = new LiveSession(connection, pump, run);
}
return sessionId;
}
/// <summary>
/// Shows one terminal's pane and hides the others.
/// </summary>
/// <remarks>
/// <para>
/// The page keeps a pane per session and displays one of them, so switching tabs is this frame and
/// nothing else — no terminal is destroyed, no scrollback is lost, and the shell behind a hidden pane
/// goes on running and goes on being read. That is the whole reason tabs cost so little here: the
/// expensive object is the WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// Sent for a session id this workspace does not know as readily as for one it does. The page ignores
/// a pane it has never created, and refusing here would mean holding a second copy of the tab strip's
/// idea of what exists — which is the sort of duplicated truth that ends up disagreeing.
/// </para>
/// </remarks>
public ValueTask ActivateSessionAsync(uint sessionId, CancellationToken cancellationToken) =>
dataPlane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []),
cancellationToken);
/// <summary>
/// Closes one terminal.
/// </summary>
/// <remarks>
/// Reached only from <see cref="DisposeAsync"/> today, which is a consequence of the lifetime policy
/// above rather than an accident: nothing else in the application ends a session, because locking
/// deliberately does not and there is no per-tab close in the interface yet. It is here, and tested,
/// because closing one terminal without taking the process down is what a tab close needs.
/// Closing one terminal without taking the process down is what a tab close needs, and what it is now
/// reached for. It is also what <see cref="DisposeAsync"/> uses on the way out, which was its only
/// caller while the interface had no tabs.
/// </remarks>
public async Task CloseSessionAsync(uint sessionId)
{
if (!sessions.Remove(sessionId, out var session))
LiveSession? session;
lock (sessionGate)
{
return;
if (!sessions.Remove(sessionId, out session))
{
return;
}
}
dataPlane.Unregister(sessionId);
await session.Pump.DisposeAsync().ConfigureAwait(false);
await session.Connection.DisposeAsync().ConfigureAwait(false);
// Before the teardown, while the socket is still up. The page holds an xterm instance, its whole
// scrollback and a WebGL context per pane and never reclaimed one on its own; a frame dropped here
// is a leak nothing else would notice, because it is inside the WebView.
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionRemoved, sessionId, []),
CancellationToken.None)
.ConfigureAwait(false);
// The connection goes even if the pump's disposal throws. The entry is already out of the dictionary,
// so nothing will come back for it — not DisposeAsync's loop, which snapshots the keys — and an
// undisposed ISshConnection is a live authenticated channel to a remote host that this process has
// forgotten about. Which of the two is more important is not close.
try
{
await session.Pump.DisposeAsync().ConfigureAwait(false);
}
finally
{
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
// Draining, not doing work. This await exists so the pump's loops have finished before the caller
// moves on; the session is already dead either way, and a fault here is nothing the caller can act
// on — the pump's last act is a best-effort SessionClosed frame on a socket that may have gone with
// the window. Letting that escape would fault a tab-close command over a session that closed fine.
try
{
await session.Run.ConfigureAwait(false);
}
catch (OperationCanceledException)
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Expected: disposing the pump cancels its run.
// Expected on the ordinary path: disposing the pump cancels its run.
}
}
@@ -197,7 +323,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
return;
}
foreach (var sessionId in sessions.Keys.ToArray())
uint[] open;
lock (sessionGate)
{
open = [.. sessions.Keys];
}
foreach (var sessionId in open)
{
await CloseSessionAsync(sessionId).ConfigureAwait(false);
}
@@ -229,6 +362,20 @@ public sealed class TerminalWorkspace : IAsyncDisposable
finally
{
dataPlane.Unregister(sessionId);
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
// entry before it disposes the pump, so a tab the user closed does not come back as news.
bool announce;
lock (sessionGate)
{
announce = sessions.ContainsKey(sessionId);
}
if (announce)
{
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
}
}
}