Public Access
Give the window its v5b chrome and each session surface its own shell
This commit is contained in:
@@ -114,6 +114,12 @@ internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
|
||||
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
|
||||
|
||||
/// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
|
||||
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -192,6 +198,24 @@ internal sealed partial class LogsViewModel : ObservableObject
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
|
||||
/// actually records.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
|
||||
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
|
||||
/// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
|
||||
/// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
|
||||
/// error over this fact when there is one to show.
|
||||
/// </remarks>
|
||||
internal string SectionFact => Section is LogSection.Connections
|
||||
? "an entry is written once, when a connection closes"
|
||||
: "one row per write, per device";
|
||||
|
||||
/// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
|
||||
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
OnPropertyChanged(nameof(SectionFact));
|
||||
OnPropertyChanged(nameof(HeaderStatusLine));
|
||||
}
|
||||
|
||||
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</summary>
|
||||
|
||||
@@ -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 && 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
|
||||
|
||||
@@ -187,11 +187,34 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
|
||||
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
||||
/// <remarks>
|
||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
||||
/// otherwise still be there offering to open it again.
|
||||
/// Off while the move panel or the delete confirmation is up, either of which takes their place — the
|
||||
/// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
|
||||
/// that opened one would otherwise still be there offering to open it again.
|
||||
/// </remarks>
|
||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
|
||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving && !IsConfirmingDelete;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Whether the desktop's own delete confirmation card is up.
|
||||
/// </summary>
|
||||
/// <remarks>See <see cref="RequestDelete"/> for why this exists beside <see cref="DeleteAsync"/> rather than in it.</remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
|
||||
private bool isConfirmingDelete;
|
||||
|
||||
/// <summary>Which snippet the open confirmation card is about.</summary>
|
||||
private SnippetRowViewModel? pendingDelete;
|
||||
|
||||
/// <summary>The confirmation card's own question, naming the snippet.</summary>
|
||||
internal string DeleteQuestion => pendingDelete is { } row ? $"Delete \"{row.Label}\"?" : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The confirmation card's own consequence line — vault-wide reach, a tombstone, no undo — in the same
|
||||
/// words <c>VaultViewModel.HowFarADeletionGoes</c> uses for every other kind of item.
|
||||
/// </summary>
|
||||
internal string DeleteConsequence => pendingDelete is { } row
|
||||
? $"This snippet lives in the {row.VaultName} vault — deleting removes it for everyone who holds "
|
||||
+ "that key. This writes a tombstone; there is no undo."
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
||||
/// <remarks>
|
||||
@@ -334,6 +357,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>Deletes the selected snippet.</summary>
|
||||
/// <remarks>
|
||||
/// Unchanged and still reachable on its own — the phone's DELETE row still calls this directly, full
|
||||
/// width and below EDIT and MOVE, with no question in front of it; see the phone's own
|
||||
/// <c>SnippetsScreen.axaml</c> remarks for why a counted confirmation was never drawn there. The
|
||||
/// desktop's own DELETE goes through <see cref="RequestDelete"/> instead — see that command's remarks —
|
||||
/// so this one command now serves two different confirmations rather than the desktop growing a second
|
||||
/// deletion path.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -347,6 +378,55 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Asks before deleting, the way every other keychain item's DELETE already does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Additive rather than a change to <see cref="DeleteAsync"/> itself: that command is what the phone's
|
||||
/// own DELETE row still calls, uncounted, and repurposing it here would leave a phone build with a
|
||||
/// button that arms a card nothing on that screen ever draws — a delete that silently stopped working.
|
||||
/// The desktop's own confirmation lives here instead, mirroring <c>VaultViewModel.PendingDeletion</c>'s
|
||||
/// shape without touching that type: a snippet has no <see cref="DeletionTarget"/> of its own to add
|
||||
/// there, and Snippets.dc.html's own modal copy — vault-wide reach, a tombstone, no undo — is exactly
|
||||
/// what <c>HowFarADeletionGoes</c> already says for every other kind, said here in the same words.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RequestDelete()
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDelete = row;
|
||||
IsConfirmingDelete = true;
|
||||
}
|
||||
|
||||
/// <summary>Carries out the deletion the confirm card just agreed to.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (pendingDelete is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsConfirmingDelete = false;
|
||||
pendingDelete = null;
|
||||
|
||||
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelDelete()
|
||||
{
|
||||
IsConfirmingDelete = false;
|
||||
pendingDelete = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||
/// </summary>
|
||||
@@ -515,6 +595,15 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
CloseMovePanel();
|
||||
}
|
||||
|
||||
// The confirmation card folds away with the selection it was opened about, the same rule the move
|
||||
// panel above follows and for the same reason: a filter or a reload that moved the selection
|
||||
// elsewhere would otherwise leave a "delete this?" card pointed at a row nobody is looking at.
|
||||
if (IsConfirmingDelete && value?.EntityId != pendingDelete?.EntityId)
|
||||
{
|
||||
IsConfirmingDelete = false;
|
||||
pendingDelete = null;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
|
||||
@@ -88,6 +88,20 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
internal string Address { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is
|
||||
/// none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set by <c>MainWindowViewModel</c> from its own clock at the same moment it calls <see cref="Opened"/>
|
||||
/// or constructs a tab that already carries a session id, rather than read here from
|
||||
/// <see cref="TimeProvider.System"/> directly — a view model with its own notion of "now" is a second
|
||||
/// clock the shell's tests would have no way to control. Left on a tab whose session has since ended:
|
||||
/// the pane still holds the scrollback, and "how long that shell ran" stays a true fact about it after it
|
||||
/// stops being live.
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
|
||||
@@ -40,6 +40,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
|
||||
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
||||
|
||||
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
|
||||
/// <remarks>
|
||||
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
|
||||
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
|
||||
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
|
||||
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
|
||||
/// </remarks>
|
||||
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
|
||||
|
||||
/// <remarks>
|
||||
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
||||
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
||||
@@ -84,6 +93,9 @@ internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
||||
|
||||
internal bool IsFile => !entry.IsDirectory;
|
||||
|
||||
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
|
||||
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
|
||||
|
||||
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
@@ -132,8 +144,64 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
? Transfer.RemotePath
|
||||
: Transfer.LocalPath;
|
||||
|
||||
/// <summary>
|
||||
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
|
||||
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
|
||||
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
|
||||
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
|
||||
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
|
||||
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
|
||||
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
|
||||
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
|
||||
/// instead.
|
||||
/// </remarks>
|
||||
internal string Label
|
||||
{
|
||||
get
|
||||
{
|
||||
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
|
||||
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
|
||||
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
|
||||
|
||||
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
|
||||
// translation to name the file at the end of it.
|
||||
var source = System.IO.Path.GetFileName(sourcePath);
|
||||
|
||||
return $"{source} → {destinationDirectory}";
|
||||
}
|
||||
}
|
||||
|
||||
internal double Percent => Transfer.Fraction * 100;
|
||||
|
||||
/// <summary>
|
||||
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
|
||||
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
|
||||
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
|
||||
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
|
||||
/// the bar alone cannot state exactly.
|
||||
/// </remarks>
|
||||
internal string StatusWord => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "queued",
|
||||
TransferState.Running => string.Create(
|
||||
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
|
||||
TransferState.Completed => "done",
|
||||
TransferState.Cancelled => "stopped",
|
||||
_ => "failed",
|
||||
};
|
||||
|
||||
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
|
||||
internal bool IsInProgress => Transfer.State is TransferState.Running;
|
||||
|
||||
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
|
||||
internal bool IsDone => Transfer.State is TransferState.Completed;
|
||||
|
||||
/// <summary>
|
||||
/// What the row says about where it has got to.
|
||||
/// </summary>
|
||||
@@ -192,6 +260,9 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
OnPropertyChanged(nameof(Percent));
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(StatusWord));
|
||||
OnPropertyChanged(nameof(IsInProgress));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsFinished));
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
@@ -297,6 +368,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
||||
|
||||
/// <summary>
|
||||
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
|
||||
/// connected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
|
||||
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
|
||||
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
|
||||
/// the first, for no reader that could not already reach it here.
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="sftp">Opens SFTP sessions.</param>
|
||||
@@ -342,7 +425,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
// which is the life of the process.
|
||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
||||
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
|
||||
Transfers.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTransfers));
|
||||
OnPropertyChanged(nameof(ActiveTransfersLabel));
|
||||
};
|
||||
|
||||
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
|
||||
// vault on every synchronisation pass, so the invitation these flags decide between has to change
|
||||
@@ -619,6 +706,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
||||
|
||||
/// <summary>
|
||||
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A string of its own rather than a converter in the markup, for the reason every other label in this
|
||||
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
|
||||
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
|
||||
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
|
||||
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
|
||||
/// that shape rather than a chip claiming activity that has already finished.
|
||||
/// </remarks>
|
||||
internal string ActiveTransfersLabel => string.Create(
|
||||
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
|
||||
|
||||
internal bool HasTransfers => Transfers.Count > 0;
|
||||
|
||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||
@@ -1653,6 +1754,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
}
|
||||
|
||||
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
|
||||
// rows count as active without the collection itself gaining or losing a row, so the strip's own
|
||||
// count chip needs its own raise here.
|
||||
OnPropertyChanged(nameof(ActiveTransfersLabel));
|
||||
|
||||
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
|
||||
|
||||
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
|
||||
|
||||
internal string Label => key.Secret.Label;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
|
||||
internal string Description => key.Secret switch
|
||||
{
|
||||
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
|
||||
|
||||
internal string Label => credential.Secret.Label;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>What the list shows under the name: the account, never the password.</summary>
|
||||
/// <remarks>
|
||||
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
|
||||
@@ -1101,8 +1119,66 @@ internal sealed record VaultItemRowViewModel(
|
||||
{
|
||||
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
||||
internal bool HasBadge => Badge.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Additive: every other kind of row in this application already carries this fact (see
|
||||
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
|
||||
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
|
||||
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
|
||||
/// </remarks>
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
|
||||
/// nothing does or the kind has no such fact.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
|
||||
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
|
||||
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
|
||||
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
|
||||
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
|
||||
/// showing a region or an endpoint under a column that promises "who uses this".
|
||||
/// </remarks>
|
||||
internal string UsedBySummary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has anything to say about who uses it.</summary>
|
||||
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
|
||||
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
|
||||
/// carries no algorithm field (see docs/design-import-gaps.md).
|
||||
/// </remarks>
|
||||
internal string IconGlyph => Kind switch
|
||||
{
|
||||
VaultItemKind.Key => "",
|
||||
VaultItemKind.Credential => "",
|
||||
VaultItemKind.Tag => "",
|
||||
VaultItemKind.ObjectStore => "",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
|
||||
/// </summary>
|
||||
/// <param name="Label">The host's name.</param>
|
||||
/// <param name="IsConnected">
|
||||
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
|
||||
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
|
||||
/// </param>
|
||||
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
|
||||
|
||||
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||||
internal enum DeletionTarget
|
||||
{
|
||||
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
|
||||
/// that does nothing, on the same "narrows the list and nothing else" reasoning
|
||||
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
|
||||
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
|
||||
/// so what matches is never a surprise to whoever typed it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string itemFilter = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The selected row of the vault table.
|
||||
/// </summary>
|
||||
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
|
||||
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
|
||||
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
|
||||
/// no binding concept to scan at all.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
|
||||
|
||||
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
|
||||
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
|
||||
|
||||
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
|
||||
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => "in use · 1 host",
|
||||
var n => $"in use · {n} hosts",
|
||||
};
|
||||
|
||||
/// <summary>Whether the "in use" chip has anything to say.</summary>
|
||||
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
|
||||
|
||||
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
||||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
||||
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
|
||||
var unreadable = 0;
|
||||
var rows = new List<SshKeyRowViewModel>();
|
||||
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
var readableVaults = session.ReadableVaults.ToList();
|
||||
var several = readableVaults.Count > 1;
|
||||
|
||||
foreach (var vault in readableVaults)
|
||||
{
|
||||
var listing = await session.SshKeys
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
|
||||
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart, as the host grid's badge is.
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
}));
|
||||
}
|
||||
|
||||
Keys.Clear();
|
||||
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
|
||||
var unreadable = 0;
|
||||
var rows = new List<CredentialRowViewModel>();
|
||||
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
var readableVaults = session.ReadableVaults.ToList();
|
||||
var several = readableVaults.Count > 1;
|
||||
|
||||
foreach (var vault in readableVaults)
|
||||
{
|
||||
var listing = await session.Credentials
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
|
||||
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart, as the host grid's badge is.
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
}));
|
||||
}
|
||||
|
||||
Credentials.Clear();
|
||||
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
|
||||
"TAG",
|
||||
tag.Description,
|
||||
tag.Badge,
|
||||
tag.HasUnsyncedChanges));
|
||||
tag.HasUnsyncedChanges)
|
||||
{
|
||||
// A tag's own "used by" is the host count it already carries — the same fact
|
||||
// Description prints, so the two never disagree.
|
||||
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12047,6 +12180,61 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
|
||||
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
|
||||
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
|
||||
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
|
||||
/// </remarks>
|
||||
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||||
{
|
||||
var names = Hosts
|
||||
.Where(row => row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind && binding.EntityId == entityId)
|
||||
.Select(row => row.Label)
|
||||
.ToList();
|
||||
|
||||
return names.Count switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => names[0],
|
||||
2 => $"{names[0]}, {names[1]}",
|
||||
_ => $"{names[0]} +{names.Count - 1}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||||
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
|
||||
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
|
||||
/// the filter has no opinion about which kind a row is.
|
||||
/// </remarks>
|
||||
private void ApplyItemFilter()
|
||||
{
|
||||
var needle = ItemFilter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = VaultItems.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var row = VaultItems[i];
|
||||
|
||||
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
|
||||
{
|
||||
VaultItems.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
|
||||
"SSH KEY",
|
||||
key.Description,
|
||||
key.Badge,
|
||||
key.HasUnsyncedChanges));
|
||||
key.HasUnsyncedChanges)
|
||||
{
|
||||
VaultBadge = key.VaultBadge,
|
||||
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12093,12 +12285,17 @@ internal sealed partial class VaultViewModel(
|
||||
"PASSWORD",
|
||||
credential.Description,
|
||||
credential.Badge,
|
||||
credential.HasUnsyncedChanges));
|
||||
credential.HasUnsyncedChanges)
|
||||
{
|
||||
VaultBadge = credential.VaultBadge,
|
||||
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AddTagRows();
|
||||
AddBucketRows();
|
||||
ApplyItemFilter();
|
||||
|
||||
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
|
||||
RebuildSelectedItemUsage(value);
|
||||
|
||||
// Every kind this table can delete, because one selection covers all of their lists.
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||
@@ -12168,6 +12367,39 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
|
||||
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
|
||||
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
|
||||
/// </remarks>
|
||||
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
|
||||
{
|
||||
SelectedItemUsedByHosts.Clear();
|
||||
|
||||
ResolvedBindingKind? kind = value?.Kind switch
|
||||
{
|
||||
VaultItemKind.Key => ResolvedBindingKind.SshKey,
|
||||
VaultItemKind.Credential => ResolvedBindingKind.Credential,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (kind is { } resolvedKind && value is not null)
|
||||
{
|
||||
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
||||
{
|
||||
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
|
||||
OnPropertyChanged(nameof(SelectedItemInUseSummary));
|
||||
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
||||
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
||||
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
||||
|
||||
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
|
||||
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
|
||||
|
||||
partial void OnUnreadableItemsChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasUnreadableItems));
|
||||
|
||||
Reference in New Issue
Block a user