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
@@ -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