Restyle the drawer, pin folders on a host, and say when it was last connected

This commit is contained in:
2026-08-07 18:12:31 +02:00
parent c3ef4bd8b4
commit 2ba7c14e35
8 changed files with 1392 additions and 202 deletions
@@ -308,6 +308,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
/// <remarks>
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
/// ever names, so a tick any faster would restring a fact that has not changed in any word it prints.
/// </remarks>
private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Whether the hosts screen was showing the last time <see cref="UpdateLastConnectedVisibility"/> ran.
/// </summary>
/// <remarks>
/// The transition is what matters, not the level — see that method. Without this, every unrelated
/// screen or surface change would re-read the connection log for no reason, which is exactly the
/// background re-read <c>LogsViewModel.cs</c> argues a connection log must never be put on.
/// </remarks>
private bool wasHostsScreenShowing;
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
private CancellationTokenSource? lastConnectedTick;
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -2633,6 +2653,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
// 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();
// 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.
@@ -2892,8 +2917,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
}
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
/// <remarks>
/// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts
/// with neither, and this is the one place both know a rebuild just happened. No log read here — see
/// <see cref="VaultViewModel.RestringLastConnected"/> — only the timestamps already on hand, written
/// onto whichever row objects exist now.
/// </remarks>
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshConnectedHosts();
Vault?.RestringLastConnected();
}
private void RaiseSyncState()
{
@@ -3152,9 +3186,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Marks a tab dead when its shell ends on its own.
/// </summary>
/// <remarks>
/// <para>
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
/// the scrollback, and the renderer has already written the reason into it.
/// </para>
/// <para>
/// <b>Also where a session ending re-reads the connection log</b>, per decision 4: the host just closed
/// is the one whose ago-text is about to stop being suppressed by <see cref="HostRowViewModel.IsConnected"/>,
/// and it deserves "just now" rather than whatever it last said. <see cref="ConnectionRecorder.Closed"/>
/// runs before this event does, but its own write is queued onto a background task rather than made
/// inline — see its remarks — so a read landing before that write drains shows the previous entry
/// instead. It self-heals on the next activation or the next session end, which is judged an acceptable
/// gap rather than worth blocking this handler on the recorder's queue.
/// </para>
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
@@ -3165,29 +3210,110 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
/// <summary>
/// Repaints the host list's status dots from the tab list.
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
/// </summary>
/// <remarks>
/// <para>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
/// wrong than a dot that goes dark when the vault is reopened.
/// </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
/// 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>
/// </remarks>
private void RefreshConnectedHosts()
{
if (Vault is not { } vault)
{
ActiveTabPinnedPaths.Clear();
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
return;
}
HostRowViewModel? connectedActiveHost = null;
foreach (var host in vault.Hosts)
{
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.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
{
connectedActiveHost = host;
}
}
ActiveTabPinnedPaths.Clear();
if (connectedActiveHost is not null)
{
foreach (var path in connectedActiveHost.Host.PinnedPaths)
{
ActiveTabPinnedPaths.Add(path);
}
}
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
}
/// <summary>
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
/// </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.
/// </remarks>
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
/// <summary>
/// Whether the pin strip 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.
/// </remarks>
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
/// <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
/// <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.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
{
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;
}
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
}
partial void OnLiveSessionCountChanged(int value)
@@ -3253,6 +3379,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = vaults.LoadAsync(CancellationToken.None);
}
UpdateLastConnectedVisibility();
}
/// <summary>
@@ -3324,7 +3451,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// so handing it the vault's object would select nothing.
/// </para>
/// </remarks>
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
_ = GoToHostFilesAsync(e.Host, null);
/// <summary>
/// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
/// </summary>
/// <remarks>
/// <para>
/// The shared body behind <see cref="OnVaultFilesRequested"/> and <see cref="OpenPinnedPathAsync"/>: which
/// machine and where to land once it is open are the only two things that differ between "Browse files"
/// and a click on the pin strip, and both are parameters here.
/// </para>
/// <para>
/// <b>A host asking for a typed password still only opens the picker.</b> <paramref name="path"/> is not
/// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files
/// already had before there was a path to carry, and closing it would mean holding a pending navigation
/// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin
/// that has to be clicked again once the session is open.
/// </para>
/// <para>
/// <b>It reconnects rather than checking whether this host is already open.</b> The existing "Browse
/// files" flow — <see cref="TransfersViewModel.ConnectCommand"/> — never asked, and reusing it here
/// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files
/// does not have. See <c>hosts-v5-design-spec.md</c>: SFTP is a second authenticated connection, every
/// time it is opened, by design.
/// </para>
/// </remarks>
private async Task GoToHostFilesAsync(HostRowViewModel host, string? path)
{
ShowFiles(RemoteKind.Host);
@@ -3336,7 +3490,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
Transfers.SelectedHost =
Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);
if (Transfers.SelectedHost is null)
{
@@ -3346,11 +3500,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (Transfers.SelectedHostAsksForAPassword)
{
Transfers.BeginChoosingRemoteCommand.Execute(null);
Transfers.Status = $"{e.Host.Label} asks for a password. Type it here, then CONNECT.";
Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT.";
return;
}
_ = Transfers.ConnectCommand.ExecuteAsync(null);
await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
if (path is not null && Transfers.IsConnected)
{
await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true);
}
}
/// <inheritdoc cref="OnScreenChanged" />
@@ -3369,6 +3528,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
RaiseSurfaceState();
UpdateLastConnectedVisibility();
}
/// <summary>
/// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
/// </summary>
/// <remarks>
/// <para>
/// Called from both <see cref="OnScreenChanged"/> and <see cref="OnSurfaceChanged"/>, because either one
/// alone can be what makes <see cref="IsHostsShowing"/> flip: <see cref="ShowScreenCommand"/> moves both
/// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface,
/// and switching groups on the hosts screen itself moves neither. <see cref="wasHostsScreenShowing"/> is
/// what turns two call sites into one decision rather than two chances to double the work.
/// </para>
/// <para>
/// The read this starts is the one <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already argues
/// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the
/// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait
/// on a decrypt pass over its log.
/// </para>
/// </remarks>
private void UpdateLastConnectedVisibility()
{
var showing = IsHostsShowing;
if (showing == wasHostsScreenShowing)
{
return;
}
wasHostsScreenShowing = showing;
if (showing)
{
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
StartLastConnectedTick();
}
else
{
StopLastConnectedTick();
}
}
/// <summary>
/// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
/// </summary>
/// <remarks>
/// <b>No log read here or in <see cref="RunLastConnectedTickAsync"/>.</b> The tick only turns the
/// timestamps <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already read into new words, through
/// <see cref="VaultViewModel.RestringLastConnected"/> — reading the connection log itself on a timer is
/// exactly what <c>LogsViewModel.cs</c> argues that log must never be put on.
/// </remarks>
private void StartLastConnectedTick()
{
if (lastConnectedTick is not null)
{
return;
}
var cts = new CancellationTokenSource();
lastConnectedTick = cts;
_ = RunLastConnectedTickAsync(cts.Token);
}
/// <summary>Stops the loop <see cref="StartLastConnectedTick"/> began, if one is running.</summary>
/// <remarks>
/// Cancelled rather than merely forgotten, so the loop's own <c>PeriodicTimer</c> wait unblocks and the
/// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
/// </remarks>
private void StopLastConnectedTick()
{
if (lastConnectedTick is not { } cts)
{
return;
}
lastConnectedTick = null;
cts.Cancel();
cts.Dispose();
}
private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
Vault?.RestringLastConnected();
}
}
catch (OperationCanceledException)
{
// The hosts screen navigated away, or the shell is closing.
}
}
/// <remarks>
@@ -3378,6 +3633,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));
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsKeychainScreen));
@@ -3423,6 +3684,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));
// 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
// on screen; see TerminalTabViewModel.IsShowing.
@@ -617,10 +617,11 @@ internal sealed partial class HostRowViewModel(
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
/// </summary>
/// <remarks>
/// <b>Not computed here.</b> A later wave fills this from the synced connection log on the hosts
/// screen's activation and on <c>SessionEnded</c>, and restrings it on a tick while the screen is
/// visible. This row carries only the string, the way <see cref="IsConnected"/> carries a fact the vault
/// does not own either.
/// <b>Not computed here.</b> <see cref="VaultViewModel.RefreshLastConnectedAsync"/> fills this from the
/// connection log on the hosts screen's activation and on <c>SessionEnded</c>, and
/// <see cref="VaultViewModel.RestringLastConnected"/> re-strings it on a tick while the screen is visible
/// and after every rebuild of <see cref="VaultViewModel.Hosts"/>. This row carries only the string, the
/// way <see cref="IsConnected"/> carries a fact the vault does not own either.
/// </remarks>
[ObservableProperty]
private string lastConnectedText = string.Empty;
@@ -1485,6 +1486,135 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
/// <summary>
/// The most recent <c>StartedAt</c> the connection log carries for each host, by <c>HostId</c>.
/// </summary>
/// <remarks>
/// Read once per <see cref="RefreshLastConnectedAsync"/> and kept here rather than only on the rows,
/// because <see cref="Hosts"/> is rebuilt wholesale on every synchronisation pass — see the remark on
/// <see cref="HostRowViewModel.IsConnected"/> — and a rebuilt row has to get its text back from
/// somewhere that survived the rebuild. <see cref="RestringLastConnected"/> is what writes it back on;
/// <c>MainWindowViewModel.OnVaultHostsChanged</c> is what calls that after every rebuild.
/// </remarks>
private readonly Dictionary<Guid, DateTimeOffset> lastConnectedAt = [];
/// <summary>
/// Re-reads the connection log and refreshes every host row's <see cref="HostRowViewModel.LastConnectedText"/>.
/// </summary>
/// <remarks>
/// <para>
/// On demand, not on the sync loop — the same rule <c>LogsViewModel</c> states for the log itself:
/// nobody is waiting for their own connection from an hour ago to update on a screen they are not
/// looking at, and a decrypt pass over the whole log is not free. The caller decides when "on demand"
/// is: the hosts screen's own activation and a session ending, both in <c>MainWindowViewModel</c>.
/// </para>
/// <para>
/// <b>Scoped to <see cref="VaultSession.ActiveVaultId"/> alone</b>, the same limitation
/// <c>LogsViewModel</c> and <c>MainWindowViewModel.RecentConnections</c> already carry, unlike
/// <see cref="Hosts"/> itself, which reads every readable vault. A host filed in a second shown vault
/// gets no ago-text until this reads that vault's log too — worth fixing the day something on this
/// screen already reads more than one vault's connection history; nothing does yet.
/// </para>
/// <para>
/// Failures are swallowed, like every other advisory read on this screen: a missing ago-text is not
/// worth a status line under a screen whose job is letting somebody connect.
/// </para>
/// </remarks>
internal async Task RefreshLastConnectedAsync(CancellationToken cancellationToken)
{
try
{
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
lastConnectedAt.Clear();
foreach (var entry in connections.Items)
{
if (entry.Secret.HostId is not { } hostId)
{
continue;
}
if (!lastConnectedAt.TryGetValue(hostId, out var current)
|| entry.Secret.StartedAt > current)
{
lastConnectedAt[hostId] = entry.Secret.StartedAt;
}
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return;
}
RestringLastConnected();
}
/// <summary>
/// Turns the timestamps <see cref="RefreshLastConnectedAsync"/> already read into the words each card
/// shows, without reading the log again.
/// </summary>
/// <remarks>
/// What the hosts screen's one-minute tick calls, and what <c>MainWindowViewModel.OnVaultHostsChanged</c>
/// calls after every rebuild of <see cref="Hosts"/> — neither of those is a reason to ask the vault
/// anything, only to say the same fact in fresher words or on a new set of row objects.
/// <para>
/// <b>A connected host shows nothing here, per the design's own decision.</b> The green dot already
/// says "open right now"; printing "3 min ago" beside it would be answering a question about a
/// connection that is not the one still running, on the one card where that answer is most likely to
/// be misread as current.
/// </para>
/// </remarks>
internal void RestringLastConnected()
{
var now = TimeProvider.System.GetUtcNow();
foreach (var row in Hosts)
{
row.LastConnectedText = !row.IsConnected && lastConnectedAt.TryGetValue(row.EntityId, out var at)
? DescribeElapsed(now - at)
: string.Empty;
}
}
/// <summary>The word a card prints for how long ago a connection started, given how long ago that was.</summary>
/// <remarks>
/// A pure function of the gap rather than of a clock, which is what lets <see cref="RestringLastConnected"/>
/// call it once a tick and once after every log read alike, and what lets a test hand it a gap directly
/// instead of freezing time to manufacture one.
/// <para>
/// Each unit's own top end is the next unit's first tick: fifty-nine seconds still reads "just now" and
/// sixty is the first "1 min ago"; fifty-nine minutes stays minutes and sixty crosses into "1 hr ago";
/// twenty-three hours stays hours and twenty-four crosses into "1 day ago". A negative gap — a clock that
/// skipped backwards — reads as "just now" rather than as a claim nothing here can back up.
/// </para>
/// </remarks>
internal static string DescribeElapsed(TimeSpan elapsed)
{
if (elapsed < TimeSpan.FromMinutes(1))
{
return "just now";
}
if (elapsed < TimeSpan.FromHours(1))
{
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalMinutes} min ago");
}
if (elapsed < TimeSpan.FromDays(1))
{
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalHours} hr ago");
}
var days = (int)elapsed.TotalDays;
return days == 1
? "1 day ago"
: string.Create(CultureInfo.InvariantCulture, $"{days} days ago");
}
/// <summary>
/// What the hosts grid says when it has nothing in it.
/// </summary>
@@ -3195,6 +3325,113 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(HasTagChoices));
}
/// <summary>
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
/// </summary>
/// <remarks>
/// <para>
/// The authority while an editor is open, on the same footing <see cref="editorTagIds"/> holds for tags:
/// populated from <see cref="HostSecret.PinnedPaths"/> when the editor opens, mutated by
/// <see cref="AddEditorPin"/> and <see cref="RemoveEditorPin"/> while it stays open, and read back by
/// <see cref="BuildHost"/> on Save. Unlike the tag set there is nothing else to project it onto — a pin
/// has no id and no vault-wide list of every pin that exists, so this collection is both the staging area
/// and the thing QUICK ACCESS binds to directly, with no <c>BuildTagChoices</c>-style rebuild step.
/// </para>
/// <para>
/// An <see cref="ObservableCollection{T}"/> rather than a field replaced wholesale, because a row in the
/// editor is a button that removes one path — a rebuilt collection would have to re-diff itself against
/// the one the list was bound to, where an in-place <c>Add</c>/<c>Remove</c> is what the binding already
/// knows how to redraw incrementally.
/// </para>
/// </remarks>
internal ObservableCollection<string> EditorPinnedPaths { get; } = [];
/// <summary>The box QUICK ACCESS's add row holds before ADD is pressed.</summary>
[ObservableProperty]
private string editorNewPin = string.Empty;
/// <summary>
/// Pins a path on the host being edited, from the editor's own box.
/// </summary>
/// <remarks>
/// <para>
/// Refuses everything <see cref="HostSecret.TryValidate"/> would, and for the same reason
/// <see cref="AddEditorTagAsync"/> validates a tag's label before writing it anywhere: a refusal that
/// waits for SAVE is a refusal that throws away five other fields typed since, where one at the box that
/// caused it costs nothing else on the form. <c>HostSecret.MaxPinnedPaths</c> and
/// <c>MaxPinnedPathLength</c> are read from the domain type rather than restated here, so a bound that
/// moves there cannot go stale at this, its only other reader.
/// </para>
/// <para>
/// A path already pinned is refused rather than duplicated. <see cref="PinnedPathList.Create"/> would
/// silently drop the second one at Save regardless, but refusing here is the honest version — a user who
/// typed the same path twice is told why nothing changed, rather than watching it vanish once the drawer
/// closes.
/// </para>
/// </remarks>
[RelayCommand]
private void AddEditorPin()
{
var path = EditorNewPin.Trim();
if (path.Length == 0)
{
Status = "A pinned path cannot be blank.";
return;
}
if (EditorPinnedPaths.Count >= HostSecret.MaxPinnedPaths)
{
Status = $"A host cannot pin more than {HostSecret.MaxPinnedPaths} paths.";
return;
}
if (path.Length > HostSecret.MaxPinnedPathLength)
{
Status = $"A pinned path cannot be longer than {HostSecret.MaxPinnedPathLength} characters.";
return;
}
if (path.Any(char.IsControl))
{
Status = "A pinned path cannot contain a control character.";
return;
}
if (EditorPinnedPaths.Contains(path, StringComparer.Ordinal))
{
Status = $"'{path}' is already pinned.";
return;
}
EditorPinnedPaths.Add(path);
EditorNewPin = string.Empty;
Status = string.Empty;
}
/// <summary>Unpins a path from the host being edited.</summary>
[RelayCommand]
private void RemoveEditorPin(string? path)
{
if (path is not null)
{
EditorPinnedPaths.Remove(path);
}
}
/// <summary>Stages an existing host's pins for editing. See <see cref="EditorPinnedPaths"/>.</summary>
private void LoadEditorPinnedPaths(PinnedPathList pinned)
{
EditorPinnedPaths.Clear();
foreach (var path in pinned)
{
EditorPinnedPaths.Add(path);
}
EditorNewPin = string.Empty;
}
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
@@ -6969,7 +7206,7 @@ internal sealed partial class VaultViewModel(
/// Puts the drawer away, whichever of the three panels is in it.
/// </summary>
/// <remarks>
/// One button for all three, because what it means is "give the grid its 304 pixels back" rather than
/// One button for all three, because what it means is "give the grid its 320 pixels back" rather than
/// "cancel". An open editor is abandoned by it — the same thing its own CANCEL does, and the same thing
/// the arrow has to mean, since a header button that refused while a form was open would be a control
/// that is sometimes furniture and sometimes a decision. The selection survives: the card stays lit and
@@ -7021,6 +7258,8 @@ internal sealed partial class VaultViewModel(
editorTagIds = TagSet.Empty;
EditorNewTag = string.Empty;
BuildTagChoices();
EditorPinnedPaths.Clear();
EditorNewPin = string.Empty;
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
BuildEditorVaultChoices(editingHostVaultId);
@@ -7109,6 +7348,8 @@ internal sealed partial class VaultViewModel(
EditorNewTag = string.Empty;
BuildTagChoices();
LoadEditorPinnedPaths(row.Host.PinnedPaths);
BuildEditorVaultChoices(editingHostVaultId);
BuildGroupChoices(row.Host.GroupId);
@@ -11432,6 +11673,12 @@ internal sealed partial class VaultViewModel(
// something else.
TagIds = editorTagIds,
// The editor's own staged list — see EditorPinnedPaths — canonicalised on the way out the same
// way TagIds is: PinnedPathList.Create dedupes and keeps order, so a path added twice by some
// path this box's own guard in AddEditorPin did not catch still lands on the host once rather
// than twice.
PinnedPaths = PinnedPathList.Create(EditorPinnedPaths),
// Both read off the one picker, including the id of something that has gone missing. Reading them
// from the picker rather than carrying the originals through is what lets a binding be removed at
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One