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
@@ -16,6 +16,43 @@ public enum TerminalServerOpcode : byte
/// <summary>A session has ended. Payload is a UTF-8 reason for the user.</summary>
SessionClosed = 3,
/// <summary>
/// This session's pane is the one to show. No payload.
/// </summary>
/// <remarks>
/// <para>
/// Every session gets a pane and all but one are hidden, so something has to say which. The page picked
/// the newest on its own, which is right exactly once — at the moment a session opens — and wrong for
/// every tab switch afterwards, because the page has no idea a tab strip exists. It is the host that
/// knows which tab is selected, so it is the host that says.
/// </para>
/// <para>
/// An opcode rather than script evaluation through the WebView bridge, which would have been the smaller
/// change. The bridge is UI-thread-bound and unordered with respect to this socket: a switch sent that
/// way could arrive before the <see cref="SessionOpened"/> frame for the pane it names, and the page
/// would activate a session that does not exist yet. On this socket the two are ordered by construction.
/// </para>
/// </remarks>
SessionActivated = 4,
/// <summary>
/// This session is gone for good; the renderer should destroy its pane. No payload.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not the same thing as <see cref="SessionClosed"/>. That one means the shell ended and
/// the pane must <em>stay</em> — the user was probably reading the last thing the remote said, and that
/// is usually why it ended. This one means the user closed the tab, so there is nothing left to read.
/// </para>
/// <para>
/// It exists because a pane is expensive and the page never reclaimed one. Every closed tab used to
/// leave an xterm instance, its scrollback and a WebGL context behind — and WebGL contexts are a hard
/// browser limit of about sixteen, so a working day of opening and closing terminals ends with panes
/// that cannot get a renderer. Nothing surfaced that, because the leak is inside the WebView.
/// </para>
/// </remarks>
SessionRemoved = 5,
}
/// <summary>Frames the renderer sends to the host.</summary>
+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));
}
}
}