Give the window its v5b chrome and each session surface its own shell

This commit is contained in:
2026-08-08 00:49:59 +02:00
parent 1b76c51fbb
commit 43c939b697
30 changed files with 4433 additions and 1772 deletions
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
/// <summary>One vault, as a switch in the rail's user popover.</summary>
/// <remarks>
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
/// settable property would be a second copy of a fact the cache already holds.
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
/// of a fact the cache already holds.
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
@@ -37,6 +40,14 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
/// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
/// <remarks>
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
/// letter is the honest half of the same badge.
/// </remarks>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
/// <summary>Whether this vault can be switched off.</summary>
/// <remarks>
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
@@ -328,6 +339,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
private CancellationTokenSource? lastConnectedTick;
/// <summary>
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
/// </summary>
/// <remarks>
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
/// case nothing else does: sitting still on a connected screen while a minute passes.
/// </remarks>
private readonly CancellationTokenSource sessionElapsedTick = new();
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -449,6 +474,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
_ = TellRendererTheFontSizeAsync();
StartSessionShellTracking();
}
/// <summary>
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
/// </summary>
/// <remarks>
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
/// </remarks>
private void StartSessionShellTracking()
{
transfers.PropertyChanged += OnTransfersPropertyChanged;
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? accountName;
/// <summary>
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
/// </summary>
/// <remarks>
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
/// never invented from the subject or the display name the way a naive fallback would.
/// </remarks>
[ObservableProperty]
private string? email;
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
/// <remarks>
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
/// mock's own two-letter shape.
/// </remarks>
internal string AvatarInitials
{
get
{
if (string.IsNullOrWhiteSpace(AccountName))
{
return string.Empty;
}
var words = AccountName.Split(
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
return words switch
{
[] => string.Empty,
[var only] => only[..1].ToUpperInvariant(),
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
};
}
}
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
/// <summary>
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
/// </summary>
/// <remarks>
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
/// did not.
/// </remarks>
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
}
[ObservableProperty]
private VaultViewModel? vault;
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.ManualStatus = string.Empty;
}
// ---- The desktop's fixed tabs ----
// ---- The rail's own page grouping ----
/// <summary>
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
/// </summary>
/// <remarks>
/// <para>
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
/// which is the screen you are looking at.
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
/// </para>
/// <para>
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
/// </para>
/// <para>
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> The
/// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
/// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
/// <summary>
/// Which page the Vaults tab returns to.
/// </summary>
/// <remarks>
/// <para>
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
/// screen.
/// </para>
/// <para>
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
/// worth stating because the two look alike. That one would have been a second copy of "which page",
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
/// remembering its page, the way any tab does — and nothing else reads it.
/// </para>
/// </remarks>
private ShellScreen vaultsScreen = ShellScreen.Hosts;
// ---- The rail's segmented switcher and its mode-dependent first entry ----
//
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
/// <remarks>
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
/// used to treat both as "not SFTP, not S3".
/// </remarks>
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
/// <remarks>
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
/// straight off the same three flags the switcher above lights, so the row and the segment can never
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
/// for the matching glyph and the command the row runs.
/// </remarks>
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
/// <summary>
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
/// </remarks>
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
/// <summary>Runs whichever of the three the row is currently naming.</summary>
/// <remarks>
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
/// answer to, so asking again here would be a second place those three facts could disagree.
/// </remarks>
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
private void ShowFirstRailItem()
{
if (IsBucketsShowing)
{
ShowFiles(RemoteKind.Bucket);
}
else if (IsTransfersShowing)
{
ShowFiles(RemoteKind.Host);
}
else
{
ShowTerminal();
}
}
// ---- Which vaults this window is showing ----
@@ -1299,15 +1431,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
/// </summary>
/// <remarks>
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
/// than the keyring can.
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it the
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
/// opened, so it cannot outlive the session any more than the keyring can.
/// </remarks>
private VaultVisibility? visibility;
/// <summary>
/// One switch per readable vault, for the menu on the Vaults tab.
/// One switch per readable vault, for the rail's user popover.
/// </summary>
/// <remarks>
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
@@ -1779,7 +1911,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
/// <remarks>
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
ShowScreen(ShellScreen.Preferences);
SignOut();
}
/// <summary>
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
/// </summary>
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
LiveSessionCount = workspace.LiveSessionCount;
AccountName = null;
Email = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
// object would otherwise leak.
StopLastConnectedTick();
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
sessionElapsedTick.Dispose();
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
@@ -2984,13 +3141,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
AdoptTab(adopted);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
tab.StartedAt = clock.GetUtcNow();
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
/// </summary>
/// <remarks>
/// <para>
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para>
/// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
/// sites for the list of moments that counts as.
/// </para>
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
ActiveTabPinnedPaths.Clear();
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
return;
}
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
// The active tab's host, and only when it is actually connected: the strip is for a session that
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
// The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
}
/// <summary>
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
/// </summary>
/// <remarks>
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
/// only where they are drawn.
/// </remarks>
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
/// <summary>
/// Whether the pin strip draws at all.
/// Whether the v5b session sidebar draws at all.
/// </summary>
/// <remarks>
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
/// the rare frame between selecting a terminal tab and navigating away from it.
/// <para>
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface &amp;&amp; HasActiveTabPinnedPaths</c>
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
/// own "hides when no session is active".
/// </para>
/// <para>
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
/// </para>
/// </remarks>
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
internal bool ShowsQuickAccessSidebar =>
(IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected);
/// <summary>
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
/// </summary>
/// <remarks>
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
}
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
/// <summary>
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
/// </summary>
/// <remarks>
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
/// underneath it agrees what it is editing.
/// </remarks>
[RelayCommand]
private void AddSnippetFromSidebar()
{
ShowScreen(ShellScreen.Snippets);
SnippetsScreen?.NewCommand.Execute(null);
}
/// <summary>
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
/// </summary>
/// <remarks>
/// <para>
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
/// writing to the renderer directly — that command already carries the whole safety story the snippets
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
/// that runs. A second insert path here would be a second place that story could go stale.
/// </para>
/// <para>
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
/// </para>
/// </remarks>
[RelayCommand]
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
{
if (SnippetsScreen is not { } screen || snip is null)
{
return;
}
screen.Selected = snip;
if (screen.CanInsert)
{
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
return;
}
ShowScreen(ShellScreen.Snippets);
}
/// <summary>
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
/// </summary>
/// <remarks>
/// The closest honest affordance rather than a new one: this application has no way to open the host
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
/// </remarks>
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
ShowScreen(ShellScreen.Hosts);
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>
/// <remarks>
/// <para>
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
/// it and land the remote pane there.
/// </para>
/// <para>
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
/// </para>
/// </remarks>
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
SelectedTab = tab;
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
}
/// <summary>
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
/// </summary>
/// <remarks>
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
/// </remarks>
[RelayCommand]
private async Task OpenTerminalForFilesHostAsync()
{
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
{
return;
}
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>
/// The account and endpoint the session shell's header and status bar are about right now, or null when
/// neither surface has one.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
/// </remarks>
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
_ => null,
};
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
/// <remarks>
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
/// that is still connecting has an address — it is what the connecting card names — but no live shell
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
/// </remarks>
internal bool IsSessionConnected => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IsLive is true,
_ when IsTransfersShowing => Transfers.IsConnected,
_ => false,
};
/// <summary>
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
/// </summary>
/// <remarks>
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
/// </remarks>
internal string? SessionElapsedText
{
get
{
var startedAt = Surface switch
{
ShellSurface.Terminal => SelectedTab?.StartedAt,
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
_ => null,
};
if (startedAt is not { } started)
{
return null;
}
var elapsed = clock.GetUtcNow() - started;
if (elapsed < TimeSpan.Zero)
{
// The clock this ran on and the clock the session opened on can disagree by a hair when both
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
// rather than a session that has not started yet.
elapsed = TimeSpan.Zero;
}
return string.Create(
CultureInfo.InvariantCulture,
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
}
}
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
/// <remarks>
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
/// </remarks>
private void RaiseSessionState()
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
}
/// <remarks>
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
/// fact the header, the status bar or the SFTP tab row's active mark reads.
/// </remarks>
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo))
{
RaiseSessionState();
}
}
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
OnPropertyChanged(nameof(SessionElapsedText));
}
}
catch (OperationCanceledException)
{
// The shell is closing; see DisposeAsync.
}
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
RaiseSurfaceState();
// What the Vaults tab comes back to; see the field.
if (IsVaultsPage(value))
{
vaultsScreen = value;
}
// 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.
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void RaiseSurfaceState()
{
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
// would answer correctly the next time anything else asked it.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
// off a terminal tab to a page screen, even though the property itself would answer correctly the
// next time anything else asked it.
RaiseSessionState();
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
// The rail's switcher and its mode-dependent first row read the same three flags above, so
// whatever moved them has to repaint these too — see the remarks on each.
OnPropertyChanged(nameof(IsSshShowing));
OnPropertyChanged(nameof(FirstRailItemLabel));
OnPropertyChanged(nameof(FirstRailItemIcon));
RaiseTerminalState();
}
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsConnectingShowing));
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
// page reached by clicking away from a terminal tab.
RaiseSessionState();
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not