diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml
index f674c14..55a2a72 100644
--- a/src/DodoSSH.Client.App/App.axaml
+++ b/src/DodoSSH.Client.App/App.axaml
@@ -585,6 +585,30 @@
+
+
+
+
+
+
+
+
+
+
-
@@ -79,14 +86,19 @@
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
moment a host is created is a different question, and it is in the editor beside the name.
-->
-
-
-
+
+
-
@@ -138,7 +150,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
@@ -345,22 +392,35 @@
-
+
+
-
+
-
+
@@ -368,9 +428,9 @@
-
+
-
+
+ HorizontalAlignment="Stretch" Height="40">
@@ -453,37 +513,48 @@
+
-
+
-
+
+ Height="58" TextWrapping="Wrap" />
-
+
-
-
-
+
+
+
+ HorizontalAlignment="Stretch" Height="40">
@@ -513,26 +584,42 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -727,10 +874,17 @@
-
-
-
-
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 983ebd0..ee07ef8 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -308,6 +308,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly Dictionary attempts = [];
+ /// How often the hosts screen's ago-text is restrung while it is visible.
+ ///
+ /// 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.
+ ///
+ private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Whether the hosts screen was showing the last time ran.
+ ///
+ ///
+ /// 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 LogsViewModel.cs argues a connection log must never be put on.
+ ///
+ private bool wasHostsScreenShowing;
+
+ /// The loop started, or null while the hosts screen is not showing.
+ private CancellationTokenSource? lastConnectedTick;
+
private IVaultServer? connection;
/// The refresh token last written to the cache, so a rotation is noticed without reading it back.
@@ -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) =>
+ ///
+ /// 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
+ /// — only the timestamps already on hand, written
+ /// onto whichever row objects exist now.
+ ///
+ 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.
///
///
+ ///
/// 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.
+ ///
+ ///
+ /// Also where a session ending re-reads the connection log, per decision 4: the host just closed
+ /// is the one whose ago-text is about to stop being suppressed by ,
+ /// and it deserves "just now" rather than whatever it last said.
+ /// 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.
+ ///
///
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);
});
///
- /// 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.
///
///
+ ///
/// 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.
+ ///
+ ///
+ /// 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.
+ ///
///
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));
+ }
+
+ ///
+ /// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
+ ///
+ ///
+ /// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
+ /// not connected — see , which is the one place this is filled.
+ ///
+ internal ObservableCollection ActiveTabPinnedPaths { get; } = [];
+
+ /// Whether the active tab's host has anything for the pin strip to draw.
+ internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
+
+ ///
+ /// Whether the pin strip draws at all.
+ ///
+ ///
+ /// Terminal surface and nothing else: 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.
+ ///
+ internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
+
+ ///
+ /// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
+ ///
+ ///
+ /// The pin strip's click handler. It shares 's plumbing — the same
+ /// refusal, the same re-found row, the same password-sheet branch for a host
+ /// that cannot be dialled unattended — through , and the host is found by
+ /// the same label match used to decide the chip is there to click.
+ ///
+ [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();
}
///
@@ -3324,7 +3451,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// so handing it the vault's object would select nothing.
///
///
- private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
+ private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
+ _ = GoToHostFilesAsync(e.Host, null);
+
+ ///
+ /// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
+ ///
+ ///
+ ///
+ /// The shared body behind and : 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.
+ ///
+ ///
+ /// A host asking for a typed password still only opens the picker. 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.
+ ///
+ ///
+ /// It reconnects rather than checking whether this host is already open. The existing "Browse
+ /// files" flow — — 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 hosts-v5-design-spec.md: SFTP is a second authenticated connection, every
+ /// time it is opened, by design.
+ ///
+ ///
+ 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);
+ }
}
///
@@ -3369,6 +3528,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
RaiseSurfaceState();
+ UpdateLastConnectedVisibility();
+ }
+
+ ///
+ /// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
+ ///
+ ///
+ ///
+ /// Called from both and , because either one
+ /// alone can be what makes flip: 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. is
+ /// what turns two call sites into one decision rather than two chances to double the work.
+ ///
+ ///
+ /// The read this starts is the one 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.
+ ///
+ ///
+ private void UpdateLastConnectedVisibility()
+ {
+ var showing = IsHostsShowing;
+
+ if (showing == wasHostsScreenShowing)
+ {
+ return;
+ }
+
+ wasHostsScreenShowing = showing;
+
+ if (showing)
+ {
+ _ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
+ StartLastConnectedTick();
+ }
+ else
+ {
+ StopLastConnectedTick();
+ }
+ }
+
+ ///
+ /// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
+ ///
+ ///
+ /// No log read here or in . The tick only turns the
+ /// timestamps already read into new words, through
+ /// — reading the connection log itself on a timer is
+ /// exactly what LogsViewModel.cs argues that log must never be put on.
+ ///
+ private void StartLastConnectedTick()
+ {
+ if (lastConnectedTick is not null)
+ {
+ return;
+ }
+
+ var cts = new CancellationTokenSource();
+ lastConnectedTick = cts;
+ _ = RunLastConnectedTickAsync(cts.Token);
+ }
+
+ /// Stops the loop began, if one is running.
+ ///
+ /// Cancelled rather than merely forgotten, so the loop's own PeriodicTimer wait unblocks and the
+ /// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
+ ///
+ 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.
+ }
}
///
@@ -3378,6 +3633,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
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.
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index 6e46c95..b67806c 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -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.
///
///
- /// Not computed here. A later wave fills this from the synced connection log on the hosts
- /// screen's activation and on SessionEnded, and restrings it on a tick while the screen is
- /// visible. This row carries only the string, the way carries a fact the vault
- /// does not own either.
+ /// Not computed here. fills this from the
+ /// connection log on the hosts screen's activation and on SessionEnded, and
+ /// re-strings it on a tick while the screen is visible
+ /// and after every rebuild of . This row carries only the string, the
+ /// way carries a fact the vault does not own either.
///
[ObservableProperty]
private string lastConnectedText = string.Empty;
@@ -1485,6 +1486,135 @@ internal sealed partial class VaultViewModel(
///
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
+ ///
+ /// The most recent StartedAt the connection log carries for each host, by HostId.
+ ///
+ ///
+ /// Read once per and kept here rather than only on the rows,
+ /// because is rebuilt wholesale on every synchronisation pass — see the remark on
+ /// — and a rebuilt row has to get its text back from
+ /// somewhere that survived the rebuild. is what writes it back on;
+ /// MainWindowViewModel.OnVaultHostsChanged is what calls that after every rebuild.
+ ///
+ private readonly Dictionary lastConnectedAt = [];
+
+ ///
+ /// Re-reads the connection log and refreshes every host row's .
+ ///
+ ///
+ ///
+ /// On demand, not on the sync loop — the same rule LogsViewModel 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 MainWindowViewModel.
+ ///
+ ///
+ /// Scoped to alone, the same limitation
+ /// LogsViewModel and MainWindowViewModel.RecentConnections already carry, unlike
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ 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();
+ }
+
+ ///
+ /// Turns the timestamps already read into the words each card
+ /// shows, without reading the log again.
+ ///
+ ///
+ /// What the hosts screen's one-minute tick calls, and what MainWindowViewModel.OnVaultHostsChanged
+ /// calls after every rebuild of — 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.
+ ///
+ /// A connected host shows nothing here, per the design's own decision. 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.
+ ///
+ ///
+ 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;
+ }
+ }
+
+ /// The word a card prints for how long ago a connection started, given how long ago that was.
+ ///
+ /// A pure function of the gap rather than of a clock, which is what lets
+ /// 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.
+ ///
+ /// 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.
+ ///
+ ///
+ 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");
+ }
+
///
/// What the hosts grid says when it has nothing in it.
///
@@ -3195,6 +3325,113 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(HasTagChoices));
}
+ ///
+ /// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
+ ///
+ ///
+ ///
+ /// The authority while an editor is open, on the same footing holds for tags:
+ /// populated from when the editor opens, mutated by
+ /// and while it stays open, and read back by
+ /// 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 BuildTagChoices-style rebuild step.
+ ///
+ ///
+ /// An 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 Add/Remove is what the binding already
+ /// knows how to redraw incrementally.
+ ///
+ ///
+ internal ObservableCollection EditorPinnedPaths { get; } = [];
+
+ /// The box QUICK ACCESS's add row holds before ADD is pressed.
+ [ObservableProperty]
+ private string editorNewPin = string.Empty;
+
+ ///
+ /// Pins a path on the host being edited, from the editor's own box.
+ ///
+ ///
+ ///
+ /// Refuses everything would, and for the same reason
+ /// 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. HostSecret.MaxPinnedPaths and
+ /// MaxPinnedPathLength 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.
+ ///
+ ///
+ /// A path already pinned is refused rather than duplicated. 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.
+ ///
+ ///
+ [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;
+ }
+
+ /// Unpins a path from the host being edited.
+ [RelayCommand]
+ private void RemoveEditorPin(string? path)
+ {
+ if (path is not null)
+ {
+ EditorPinnedPaths.Remove(path);
+ }
+ }
+
+ /// Stages an existing host's pins for editing. See .
+ private void LoadEditorPinnedPaths(PinnedPathList pinned)
+ {
+ EditorPinnedPaths.Clear();
+
+ foreach (var path in pinned)
+ {
+ EditorPinnedPaths.Add(path);
+ }
+
+ EditorNewPin = string.Empty;
+ }
+
/// The item being edited, or null when creating.
private Guid? editingEntityId;
@@ -6969,7 +7206,7 @@ internal sealed partial class VaultViewModel(
/// Puts the drawer away, whichever of the three panels is in it.
///
///
- /// 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
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
index 1a0cc52..3ab0367 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
@@ -40,9 +40,11 @@ internal static class LayoutHarness
///
/// This was HostSidebarWidth at 268, taken from a column definition on the hosts screen. The
/// drawer states its own width instead — it is the only thing in its column and the column is
- /// Auto — so the number lives on the control now, and this constant follows it.
+ /// Auto — so the number lives on the control now, and this constant follows it. It was 304 through
+ /// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and App.axaml's
+ /// Border.tile narrowed to keep two columns fitting the grid beside it at the window's minimum.
///
- internal const double HostDrawerWidth = 304;
+ internal const double HostDrawerWidth = 320;
/// The nav rail's fixed width, from NavRail.axaml.
internal const double NavRailWidth = 190;
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index b21017a..c464da9 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -141,7 +141,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
//
// This was the host sidebar's section. The control kept the half of that column that is about one host
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
- // the right.
+ // the right. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
+ // LayoutHarness.HostDrawerWidth.
///
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
@@ -161,7 +162,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
///
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
- /// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
+ /// wrapped row of tag chips, a checkbox, a relay card, QUICK ACCESS's own rows and its add row, a
+ /// paragraph of hint text and three buttons.
+ ///
+ /// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
+ /// harness skips the scrolled content's height (see the remark above
+ /// ) but not its width, and a row's own mono path
+ /// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
///
[Fact]
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
@@ -177,6 +184,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
+ vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
+ vault.AddEditorPinCommand.Execute(null);
+
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
@@ -226,7 +236,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
///
///
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
- /// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
+ /// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
/// parts of this drawer that is not inside a ScrollViewer, so nothing brings it back into view.
///
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 5c34cf7..b566a74 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
@@ -996,6 +997,126 @@ public sealed class ShellFlowTests : IAsyncLifetime
IsReadOnly: false),
isLive: false);
+ // ---- Last connected ----
+ //
+ // The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
+ // is the log read, and the two triggers below — the hosts screen coming back on screen and a session
+ // ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
+
+ ///
+ /// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
+ /// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
+ /// none of this needs a fake clock — see the remark on VaultViewModel.DescribeElapsed itself.
+ ///
+ [Theory]
+ [InlineData(0, "just now")]
+ [InlineData(59, "just now")]
+ [InlineData(60, "1 min ago")]
+ [InlineData(150, "2 min ago")]
+ [InlineData(3599, "59 min ago")]
+ [InlineData(3600, "1 hr ago")]
+ [InlineData(7200, "2 hr ago")]
+ [InlineData(86399, "23 hr ago")]
+ [InlineData(86400, "1 day ago")]
+ [InlineData(172800, "2 days ago")]
+ public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
+ VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
+
+ ///
+ /// The hosts screen's own activation — one of the two moments VaultViewModel.RefreshLastConnectedAsync
+ /// is read on. ReadyToConnectAsync already visited this screen once, before the log held anything
+ /// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
+ /// transition MainWindowViewModel.UpdateLastConnectedVisibility actually keys off, rather than the
+ /// level, which fired already.
+ ///
+ [Fact]
+ public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
+ {
+ var vault = await ReadyToConnectAsync();
+ var host = vault.Hosts[0];
+
+ await vault.Session.ConnectionLog.CreateAsync(
+ vault.Session.ActiveVaultId,
+ new ConnectionLogSecret
+ {
+ HostLabel = host.Label,
+ Address = host.Address,
+ HostId = host.EntityId,
+ StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
+ DeviceName = "a workstation",
+ },
+ Token);
+
+ shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
+ shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
+
+ await EventuallyAsync(
+ () => host.LastConnectedText.Length > 0,
+ "activating the hosts screen should have read the log");
+
+ host.LastConnectedText.ShouldBe("3 days ago");
+ }
+
+ ///
+ /// A host the log has never named. Empty rather than a dash or the word "never" — see
+ /// HostRowViewModel.LastConnectedText's own remarks: a host nobody has connected to and a host
+ /// whose log simply has not been read yet look identical from this row, and neither is a claim it can
+ /// make on its own.
+ ///
+ [Fact]
+ public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
+ {
+ var vault = await ReadyToConnectAsync();
+ var host = vault.Hosts[0];
+
+ await vault.RefreshLastConnectedAsync(Token);
+
+ host.LastConnectedText.ShouldBeEmpty();
+ }
+
+ ///
+ /// Decision 4's second half: a host with a session open right now shows the green dot instead of an
+ /// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
+ /// and the older answer is the one likeliest to be misread as current.
+ ///
+ [Fact]
+ public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
+ {
+ var vault = await ReadyToConnectAsync();
+ var host = vault.Hosts[0];
+
+ await vault.Session.ConnectionLog.CreateAsync(
+ vault.Session.ActiveVaultId,
+ new ConnectionLogSecret
+ {
+ HostLabel = host.Label,
+ Address = host.Address,
+ HostId = host.EntityId,
+ StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
+ DeviceName = "a workstation",
+ },
+ Token);
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ host.IsConnected.ShouldBeTrue();
+
+ await vault.RefreshLastConnectedAsync(Token);
+
+ host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
+ }
+
+ // The other trigger — a session ending on its own — has no test here. It runs inside
+ // MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
+ // RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
+ // window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
+ // queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
+ // nothing about the one line this wave added, since the untestable half is wiring this wave did not
+ // write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
+ // directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
+ // VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
+
///
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
@@ -2774,8 +2895,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "platform");
- vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
- vault.EditGroupCommand.Execute(null);
+ vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
vault.GroupEditorDefaultPort = 2222;
await AddKeyAsync(vault, "deploy");
@@ -4267,17 +4387,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
///
///
- /// What dragging a host card onto a group card does. It is the same write the editor makes — one field
- /// of the host, pushed straight away — reached without opening a form, because filing thirty imported
- /// machines through the editor is thirty rounds of open, pick, save.
+ /// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
+ /// — which is the one thing every head can still do. What survives to measure is
+ /// and 's own
+ /// "one level of the tree" filtering, fed by directly now that
+ /// nothing sets it through a command — see that property's own remarks.
///
///
- /// The card goes into the group and off the level it was dragged from, which is the whole of what
- /// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
- /// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
- /// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
- /// read that property, and none of them should be aimed at a card that has left the screen. See
- /// VaultViewModel.Matches and RebuildVisibleHosts.
+ /// The card goes into the group and off the level it was filed from, which is the whole of what
+ /// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
+ /// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
///
///
[Fact]
@@ -4290,13 +4409,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
var group = vault.Groups.Single().EntityId;
- var host = vault.Hosts.Single();
- await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
+ await FileAsync(vault, "prod-db", "production");
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
- vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
// tree flattened, so the host is still in it.
@@ -4310,22 +4427,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts.Single().HasGroup.ShouldBeTrue();
// Opening the group is where it went, and the way to it.
- vault.OpenGroupCommand.Execute(vault.Groups.Single());
+ vault.GroupFilter = vault.Groups.Single();
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
- // And back out again, which is what the host's own editor is for now that the drop has one target.
- vault.OpenGroupCommand.Execute(null);
+ // And back out again.
+ vault.GroupFilter = null;
- await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
+ vault.SelectedHost = vault.Hosts.Single();
+ vault.EditSelectedHostCommand.Execute(null);
+ vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
+ choice => choice.EntityId is null);
+ await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
-
- // Coming out of a group is the direction that lands the host back on this level, so here the
- // selection does survive the move.
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
- vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
///
@@ -4355,11 +4472,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SidebarRows.OfType().Select(row => row.Label)
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
- vault.OpenGroupCommand.Execute(vault.Groups.Single());
+ vault.GroupFilter = vault.Groups.Single();
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
- vault.OpenGroupCommand.Execute(null);
+ vault.GroupFilter = null;
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
}
@@ -4392,8 +4509,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
.ShouldBe("prod-db", "two levels down, and the box reaches it");
// And inside a group it is that group's subtree: estate holds production, which holds the host.
- vault.OpenGroupCommand.Execute(
- vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
+ vault.GroupFilter =
+ vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
@@ -4427,8 +4544,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
///
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
/// inside a group and filed under none would vanish from the screen it was created on, which is the
- /// papercut that comes free with a grid holding one level — so the editor opens on the group the user
- /// is standing in, and the picker shows it before anything is saved.
+ /// papercut that comes free with a grid holding one level — so the editor opens on the group
+ /// names, and the picker shows it before anything is saved.
+ /// GroupFilter is written directly rather than through the deleted OpenGroupCommand — see
+ /// that property's own remarks.
///
[Fact]
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
@@ -4438,7 +4557,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
- vault.OpenGroupCommand.Execute(vault.Groups.Single());
+ vault.GroupFilter = vault.Groups.Single();
vault.NewHostCommand.Execute(null);
@@ -4457,31 +4576,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
}
- ///
- /// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
- /// one is open would be a save nobody asked for, and one they could then not cancel.
- ///
- [Fact]
- public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
- {
- await UnlockedAsync();
- var vault = shell.Vault!;
-
- await AddHostAsync(vault, "prod-db");
- await AddGroupAsync(vault, "production");
-
- vault.SelectedHost = vault.Hosts.Single();
- vault.EditSelectedHostCommand.Execute(null);
- vault.EditorLabel = "half-typed";
-
- await vault.MoveHostToGroupCommand.ExecuteAsync(
- new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
-
- vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
- vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
- vault.Status.ShouldContain("editing");
- }
-
///
///
/// Deleting a group leaves the machines under it alone and stops them naming it. It used to do
@@ -4505,8 +4599,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
- vault.SelectedGroup = vault.Groups.Single();
- vault.DeleteGroupCommand.Execute(null);
+ vault.DeleteGroupCommand.Execute(vault.Groups.Single());
var question = vault.PendingDeletion.ShouldNotBeNull();
@@ -4549,8 +4642,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
- vault.SelectedGroup = vault.Groups.Single();
- vault.DeleteGroupCommand.Execute(null);
+ vault.DeleteGroupCommand.Execute(vault.Groups.Single());
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
vault.DeletionTakesTheHostsToo = true;
@@ -4580,17 +4672,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "staging");
await FileAsync(vault, "prod-db", "production");
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, "production", StringComparison.Ordinal));
-
- vault.DeleteGroupCommand.Execute(null);
+ vault.DeleteGroupCommand.Execute(vault.Groups.Single(
+ row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
vault.DeletionTakesTheHostsToo = true;
vault.CancelDeleteCommand.Execute(null);
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
-
- vault.DeleteGroupCommand.Execute(null);
+ vault.DeleteGroupCommand.Execute(vault.Groups.Single(
+ row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
}
@@ -4615,8 +4703,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
- vault.SelectedGroup = vault.Groups.Single();
- vault.DeleteGroupCommand.Execute(null);
+ vault.DeleteGroupCommand.Execute(vault.Groups.Single());
vault.PendingDeletion.ShouldBeNull("the question was never put");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
@@ -4676,8 +4763,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
- vault.SelectedGroup = vault.Groups.Single();
- vault.EditGroupCommand.Execute(null);
+ vault.EditGroupCommand.Execute(vault.Groups.Single());
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
@@ -4879,10 +4965,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await SetGroupParentAsync(vault, "production", "estate");
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
-
- vault.EditGroupCommand.Execute(null);
+ vault.EditGroupCommand.Execute(vault.Groups.Single(
+ row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
vault.GroupEditorParentChoices
.Select(choice => choice.Label)
@@ -5355,24 +5439,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
}
- ///
- /// The desktop's drag, once more than one card is ticked.
- ///
///
///
- /// Dragging one host onto a group card has always been MoveHostToGroup; a set dragged onto one has
- /// to file all of it, because moving whichever card the pointer happened to be holding and leaving the
- /// other five where they are is a gesture that quietly does a fraction of what it looks like it does. It
- /// is the picker's write with the picker skipped — see ConfirmRegroupChosenHosts, which shares it.
- ///
- ///
- /// The refusal is the one RefusesTheDrop makes for a single card, made once for the set: a drop is
- /// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
- /// and could not then cancel.
+ /// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
+ /// cards, and FileChosenHostsUnderCommand went with it; ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce
+ /// covers what a set files to now that the group picker is the only route. What survives here is the
+ /// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
+ /// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
+ /// refusal at the point it is raised instead — see —
+ /// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
+ /// way a card once was.
///
///
[Fact]
- public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
+ public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -5385,25 +5465,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
- var card = vault.Groups.Single(
- row => string.Equals(row.Label, "production", StringComparison.Ordinal));
-
vault.NewHostCommand.Execute(null);
- await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
+ vault.RegroupChosenHostsCommand.Execute(null);
- Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
- vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
+ vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
vault.CancelEditCommand.Execute(null);
- await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
+ vault.RegroupChosenHostsCommand.Execute(null);
- Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
- Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
+ vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
+
+ var group = vault.Groups.Single(
+ row => string.Equals(row.Label, "production", StringComparison.Ordinal));
+
+ vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
+ .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
+
+ await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
+
+ Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
+ Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
-
- vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
}
///
@@ -5555,12 +5639,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.Status.ShouldContain("password");
}
- [Fact]
- public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
+ // ---- The terminal pin strip ----
+
+ ///
+ /// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
+ /// the connection already open and the tab it opened already selected.
+ ///
+ private async Task ConnectedHostWithAPinAsync(string path = "/var/www/app")
{
- // The phone's only route into a group editor: it draws no groups panel, and a heading's own
- // selection bounces back to the host on purpose. The command has to work off the heading it was
- // pressed on rather than off SelectedGroup, or pressing one heading would edit another.
+ var vault = await ReadyToConnectAsync();
+
+ await AddKeyAsync(vault, "deploy");
+ await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
+
+ vault.EditSelectedHostCommand.Execute(null);
+ vault.EditorNewPin = path;
+ vault.AddEditorPinCommand.Execute(null);
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
+
+ return vault;
+ }
+
+ [Fact]
+ public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
+ {
+ await ConnectedHostWithAPinAsync();
+
+ shell.ShowsPinStrip.ShouldBeTrue();
+ shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
+ }
+
+ [Fact]
+ public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ vault.EditSelectedHostCommand.Execute(null);
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ // Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
+ // that happens to be selected on the hosts screen.
+ shell.ShowsPinStrip.ShouldBeFalse();
+ shell.ActiveTabPinnedPaths.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await AddKeyAsync(vault, "deploy");
+ await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+
+ vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
+ await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
+
+ shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
+ shell.ActiveTabPinnedPaths.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
+ {
+ await ConnectedHostWithAPinAsync();
+
+ shell.ShowsPinStrip.ShouldBeTrue();
+
+ shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
+
+ shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
+
+ shell.SelectTabCommand.Execute(shell.Tabs[0]);
+
+ shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
+ }
+
+ ///
+ /// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
+ /// terminal connection and the SFTP one are both real ISshConnectionFactory/
+ /// ISftpSessionFactory calls against FakeSshConnectionFactory, so this is proof the two
+ /// really are the second authenticated connection the design docs say they are — SftpRequests gets an
+ /// entry independent of Requests.
+ ///
+ [Fact]
+ public async Task ClickingAPinChip_OpensFilesAtThatPath()
+ {
+ var vault = await ConnectedHostWithAPinAsync();
+
+ await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
+
+ shell.IsTransfersShowing.ShouldBeTrue();
+ shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
+ shell.Transfers.IsConnected.ShouldBeTrue();
+ shell.Transfers.RemotePath.ShouldBe("/var/www/app");
+
+ ssh.SftpRequests.ShouldHaveSingleItem();
+ vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
+ }
+
+ [Fact]
+ public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
+ {
+ // Both heads' only route into a group editor since v5: neither draws a group card to select one
+ // from any more, so the command has to work off the heading it was pressed on. Two groups exist here
+ // so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
await UnlockedAsync();
var vault = shell.Vault!;
@@ -5569,23 +5760,19 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
-
var heading = vault.SidebarRows.OfType().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.EditGroupFromHeadingCommand.Execute(heading);
vault.IsEditingGroup.ShouldBeTrue();
- vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
+ vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
}
///
- /// The heading hands its group to the editor rather than selecting it first, and this is why. A group
- /// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
- /// group cards at all, so selecting one here would take the highlight off the machine in the list with
- /// nothing on screen to say where it had gone, or how to get it back.
+ /// The heading hands its group straight to the editor rather than selecting a card first, and this is
+ /// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
+ /// mark, and neither head has drawn a group card to select since v5 — see EditGroup's own remarks.
///
[Fact]
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
@@ -5920,6 +6107,226 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
}
+ // ---- Pinned paths (QUICK ACCESS) ----
+
+ [Fact]
+ public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldBeEmpty();
+
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
+ vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
+ }
+
+ [Fact]
+ public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
+ {
+ // Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
+ // to preserve it as faithfully as the domain type it is about to become.
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
+ {
+ vault.EditorNewPin = path;
+ vault.AddEditorPinCommand.Execute(null);
+ }
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ Host(vault, "prod-db").Host.PinnedPaths
+ .ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
+ }
+
+ [Fact]
+ public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+ vault.EditorNewPin = "/etc/nginx";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.RemoveEditorPinCommand.Execute("/var/www/app");
+
+ vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
+ }
+
+ [Fact]
+ public async Task CancellingAHostEdit_DropsThePinningEntirely()
+ {
+ // Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
+ // than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
+ // Tag holds a tag's own name to.
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.CancelEditCommand.Execute(null);
+
+ Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
+ }
+
+ [Fact]
+ public async Task ANewHostsEditor_OpensWithNoPinsStaged()
+ {
+ // NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
+ // reason every other editor field is reset there.
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+ await vault.SaveHostCommand.ExecuteAsync(null);
+
+ vault.NewHostCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ABlankPin_IsRefusedAtTheAddBox()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorNewPin = " ";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldBeEmpty();
+ vault.Status.ShouldContain("blank");
+ }
+
+ [Fact]
+ public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+ vault.EditorNewPin = "/var/www/app";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldHaveSingleItem();
+ vault.Status.ShouldContain("already pinned");
+ }
+
+ [Fact]
+ public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
+ {
+ // The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
+ // that waits for SAVE throws away every other field typed on the form since, where one at the box
+ // that caused it costs nothing else.
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.EditorPinnedPaths.ShouldBeEmpty();
+ vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = Host(vault, "prod-db");
+ vault.EditSelectedHostCommand.Execute(null);
+
+ for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
+ {
+ vault.EditorNewPin = $"/pin/{i}";
+ vault.AddEditorPinCommand.Execute(null);
+ }
+
+ vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
+
+ vault.EditorNewPin = "/one/too/many";
+ vault.AddEditorPinCommand.Execute(null);
+
+ vault.EditorPinnedPaths.Count.ShouldBe(
+ HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
+ vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
+ }
+
[Fact]
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
{
@@ -6014,10 +6421,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
string? username = null,
string? key = null)
{
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, group, StringComparison.Ordinal));
-
- vault.EditGroupCommand.Execute(null);
+ vault.EditGroupCommand.Execute(vault.Groups.Single(
+ row => string.Equals(row.Label, group, StringComparison.Ordinal)));
vault.GroupEditorDefaultPort = port;
vault.GroupEditorDefaultUsername = username ?? string.Empty;
@@ -6033,10 +6438,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
{
- vault.SelectedGroup = vault.Groups.Single(
- row => string.Equals(row.Label, group, StringComparison.Ordinal));
-
- vault.EditGroupCommand.Execute(null);
+ vault.EditGroupCommand.Execute(vault.Groups.Single(
+ row => string.Equals(row.Label, group, StringComparison.Ordinal)));
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));