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 @@ + - + - + + + + + + @@ -232,6 +278,7 @@ IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" /> + 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));