Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -6,6 +6,8 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
@@ -61,7 +63,7 @@ internal enum ShellState
/// </remarks>
internal enum ShellScreen
{
/// <summary>The host list and the terminals, which is where the application opens.</summary>
/// <summary>The host list, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
@@ -75,6 +77,52 @@ internal enum ShellScreen
/// <summary>Preferences.</summary>
Preferences = 4,
/// <summary>The host keys this keychain has approved.</summary>
/// <remarks>
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
/// </remarks>
KnownHosts = 5,
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
/// <remarks>
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
/// something almost nobody is looking at.
/// </remarks>
Import = 6,
/// <summary>The saved commands in this keychain.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Snippets = 7,
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
}
/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
Page = 0,
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
Terminal = 1,
}
/// <summary>
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks>
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly TransfersViewModel transfers;
/// <summary>
/// Where connections are recorded, for as long as a vault is open to record them into.
/// </summary>
/// <remarks>
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
/// </remarks>
private readonly ConnectionRecorder connectionLog;
private readonly TeamsViewModel teams;
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null)
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null)
{
this.paths = paths;
this.caches = caches;
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.resume = resume;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
transfers = new TransfersViewModel(sftpSessions, clock);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
// running after it, and the entry it eventually produces belongs to the vault it was made in.
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
this.workspace.ConnectionLog = connectionLog;
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
// session after unlock, and both go away again on lock. Capturing either would give this screen a
// reference that outlives what it points at — which for a session means holding vault keys past the
// moment locking is supposed to have zeroed them.
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
/// <remarks>
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
/// vault — unlocking, locking and signing out — cannot get out of step with it.
/// </remarks>
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private LogsViewModel? logsScreen;
/// <summary>
/// The teams screen, which the window binds to whether or not a vault is open.
/// </summary>
/// <remarks>
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
/// server rather than a vault, and both of its dependencies are fetched through a function at the
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
/// to rebuild it, and the list it is showing survives both.
/// </remarks>
internal TeamsViewModel Teams => teams;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks>
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// ---- Which screen is showing ----
/// <summary>
/// Which of the nav rail's screens the page area holds.
/// </summary>
/// <remarks>
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
/// It is what dismissing a terminal returns to.
/// </remarks>
[ObservableProperty]
private ShellScreen screen;
/// <summary>
/// Whether the page area is showing rather than a terminal.
/// </summary>
/// <remarks>
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
/// </remarks>
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" />
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
/// at once is one too many.
/// </remarks>
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
/// screens all use the full width), and the quick-connect palette.
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// quick-connect palette.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
/// to the same value.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
/// </para>
/// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
private ShellSurface surface;
/// <summary>Points the nav rail at a screen.</summary>
/// <remarks>
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
/// </remarks>
[RelayCommand]
private void ShowScreen(ShellScreen target) => Screen = target;
private void ShowScreen(ShellScreen target)
{
Screen = target;
Surface = ShellSurface.Page;
}
// ---- Open terminals ----
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
// leave a visible WebView with no pane in it, which reads as the application having broken.
if (Tabs.Count == 0)
{
Surface = ShellSurface.Page;
}
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CloseSearch();
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
// screen — so connecting from the files screen without this would put the question behind the screen
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
// is what moves the surface to the terminal, and only if there is one.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
await RunAsync(
"Creating your vault. This deliberately takes a moment…",
"Creating your keychain. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (Passphrase.Length == 0)
{
StatusMessage = "Enter your vault passphrase.";
StatusMessage = "Enter your keychain passphrase.";
return;
}
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
Vault = new VaultViewModel(
session,
workspace,
knownHosts,
() => connection,
ReconnectAsync,
copyToClipboard,
connectionLog);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts);
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync();
}
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
/// <remarks>
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
/// called by the workspace — so both are attached here rather than constructed per session, and both are
/// released together on every path that closes a vault.
/// </remarks>
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
// The actor is the account that unlocked, which is what makes this an audit record rather than a
// list of events with nobody attached to them.
connectionLog.Open(session, session.Profile.UserId);
}
/// <summary>
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
/// </summary>
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// reappearing behind a lock screen.
knownHosts.Close();
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
// about to be disposed. Tickets already open keep the repository they were opened against, so a
// shell still running closes out into the vault it was actually made in.
connectionLog.Close();
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy.
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
(false, _) =>
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
+ "counted from here, because the vault is locked.",
+ "counted from here, because the keychain is locked.",
(true, 0) =>
"Everything this machine has changed has reached the server, so nothing will be lost.",
(true, 1) =>
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// As Lock does, and before the session it reads from goes.
knownHosts.Close();
connectionLog.Close();
// The same detach locking does, and the same reasoning carried one step further: the host
// rows go because the vault behind them is about to be disposed, and the session and its
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
+ "itself is untouched. Sign in to set this machine up again.";
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
+ "keychain itself is untouched. Sign in to set this machine up again.";
}).ConfigureAwait(true);
}
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
workspace.ConnectionLog = null;
await connectionLog.DisposeAsync().ConfigureAwait(false);
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it.
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
}
// Built from the vault and thrown away with it, here rather than at each of the three places a
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
RaiseSyncState();
}
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
/// <remarks>
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
/// runs against a tab strip that already shows the session it is focusing.
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
/// <summary>
/// Takes a newly opened session into the tab strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these four steps is decided
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab);
RaiseTabState();
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and load-bearing for every one after it.
SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
// be a screen keeping the shell alive.
SnippetsScreen?.TargetChanged();
if (value is not null)
{
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
}
}
/// <summary>Brings one terminal's pane to the front.</summary>
/// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
/// say, the most recently opened would send a command somewhere the user is not looking.
/// </remarks>
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
/// <remarks>
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
/// </remarks>
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
];
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
/// <remarks>
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
/// would answer only one of those.
/// </remarks>
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for.
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
// the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
}
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// directions, and raising only the one that became true leaves the old button lit.
/// </remarks>
partial void OnScreenChanged(ShellScreen value)
{
RaiseSurfaceState();
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
if (value is ShellScreen.Logs && LogsScreen is { } logs)
{
_ = logs.RefreshCommand.ExecuteAsync(null);
}
// Teams are read from the server rather than from the vault, so there is nothing to show until
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
// screen most people never open. Fire-and-forget because a property change cannot await, and
// because the view model turns every failure into its own status line rather than throwing.
if (value is ShellScreen.Team)
{
_ = teams.LoadAsync(CancellationToken.None);
}
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
/// narrow what became stale.
/// </remarks>
private void RaiseSurfaceState()
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));
OnPropertyChanged(nameof(IsTeamShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsTerminalShowing));
}