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.
|
||||
|
||||
Reference in New Issue
Block a user