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