Public Access
Restyle the drawer, pin folders on a host, and say when it was last connected
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user