diff --git a/src/DodoSSH.Client.App/Views/QuickConnect.axaml b/src/DodoSSH.Client.App/Views/QuickConnect.axaml index 0d7c43b..a83360d 100644 --- a/src/DodoSSH.Client.App/Views/QuickConnect.axaml +++ b/src/DodoSSH.Client.App/Views/QuickConnect.axaml @@ -87,12 +87,27 @@ TextTrimming="CharacterEllipsis" /> - + + + + diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index 39a297b..cabc395 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -2141,8 +2141,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// /// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one - /// that merely contains it, and both come before a match found only in the address. Typing three - /// characters of a name people use daily should not put that host third. + /// that merely contains it, both come before a match found only in the address, and all three come + /// before one found only in the vault's name. Typing three characters of a name people use daily should + /// not put that host third. See . /// private void RefreshSearchResults() { @@ -2175,6 +2176,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp OnPropertyChanged(nameof(HasSearchResults)); } + /// Where one host places against what was typed, or for no match. + /// + /// + /// The vault comes last, and only when the row prints it. Its name is the widest of the three + /// readings — one word can match every host in a team's vault at once — so a host whose own name or + /// address answers the query has to come first, or typing a machine's name would bury it under everybody + /// else's. + /// + /// + /// rather than the name itself, so this matches exactly what + /// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing + /// displays would answer a query with a list whose reason for existing is invisible — type "personal" + /// there and every host comes back for no reason anybody can see. + /// + /// private static int Rank(HostRowViewModel host, string query) { if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) @@ -2187,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp return 1; } - return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase) - ? 2 + if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)) + { + return 2; + } + + return host.HasVaultBadge + && host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase) + ? 3 : int.MaxValue; } diff --git a/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs index 6222483..64ce124 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs @@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime }); } + /// + /// The palette searches every vault the session holds a key for, so a row has to say which one it came + /// out of. Two machines a team and a person both call prod-db are otherwise two identical rows, + /// and Enter takes whichever the ranking happened to put first. + /// + /// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the + /// active vault's rows come first — and the list virtualises, so the row this is about might never be + /// realised. Narrowing to it also proves the search reaches past the active vault at all. + /// + /// + [Fact] + public async Task AResultSaysWhichVaultItCameOutOf() + { + await SeedSharedHostAsync(); + + await OnThePaletteAsync((palette, window) => + { + shell.SearchText = "prod-db"; + Relayout(window); + + var found = shell.SearchResults.ShouldHaveSingleItem(); + found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault"); + + VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal); + }); + } + + /// + /// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on + /// every row of a session that has only one is the same fact repeated, which is noise rather than a + /// reading. HostRowViewModel.VaultBadge is empty there, and an empty line has to collapse rather + /// than leave a gap above the kind word. + /// + [Fact] + public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn() + { + await OnThePaletteAsync((palette, _) => + { + var first = shell.SearchResults[0]; + + first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone"); + VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal); + }); + } + /// /// The palette is a box somebody is expected to start typing into, and for a while it was not: the window /// focused it from the view model's PropertyChanged, which runs before the binding that reveals the @@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime .OfType() .First(item => ReferenceEquals(item.DataContext, host)); + /// What one row actually draws, in order, ignoring the lines that collapsed. + private static List VisibleTexts(Visual row) => + row.GetVisualDescendants() + .OfType() + .Where(text => text.IsEffectivelyVisible) + .Select(text => text.Text ?? string.Empty) + .ToList(); + + /// Runs the layout pass the application's dispatcher would run after the list changed. + /// + /// Without it the new rows are in the collection but not in the visual tree, so + /// finds nothing to look at. + /// + private static void Relayout(Window window) + { + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + } + private static Point Centre(Visual control, Visual window) => control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the control is not in this window's tree"); @@ -325,4 +389,37 @@ public sealed class QuickConnectTests : IAsyncLifetime await vault.LoadAsync(Token); } + + /// + /// Adds a second, shared vault to the fixture's session and files one host into it. + /// + /// + /// Straight into the session rather than through VaultsViewModel, which is the technique the + /// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this + /// shell has none. The key is generated on this machine either way, so what the session ends up holding + /// is the same thing a real creation leaves behind — see . + /// + private async Task SeedSharedHostAsync() + { + using var teamServer = new StubTeamServer(); + + var shared = await session.CreateTeamVaultAsync( + teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token); + + await vault.LoadAsync(Token); + + vault.SelectedTargetVault = + vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId); + + vault.NewHostCommand.Execute(null); + vault.EditorLabel = "prod-db"; + vault.EditorHostname = "db.internal"; + vault.EditorUsername = "deploy"; + + await vault.SaveHostCommand.ExecuteAsync(null); + + vault.IsEditing.ShouldBeFalse(vault.Status); + + await vault.LoadAsync(Token); + } } diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index faae435..0cfa651 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -1765,6 +1765,28 @@ public sealed class ShellFlowTests : IAsyncLifetime shell.IsTerminalShowing.ShouldBeTrue(); } + /// + /// The palette searches a host's vault name, and only where the row prints it — which a session holding + /// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked + /// behind nothing and explained by nothing on screen. See MainWindowViewModel.Rank. + /// + [Fact] + public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn() + { + var vault = await ReadyToConnectAsync(); + + vault.VaultName.ShouldBe("Personal", "which is the word this test types"); + + shell.ToggleSearchCommand.Execute(null); + + shell.SearchResults.ShouldHaveSingleItem() + .HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart"); + + shell.SearchText = "personal"; + + shell.SearchResults.ShouldBeEmpty(); + } + /// /// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a /// terminal fills the window would point at a screen that is not showing — and the selected tab already diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs index 83d8935..43edf05 100644 --- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs +++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs @@ -766,6 +766,59 @@ public sealed class VaultSharingTests : IAsyncLifetime vault.Status.ShouldContain("left alone", Case.Insensitive); } + /// + /// + /// The palette reaches every vault the session holds a key for, so the vault's name is one of the things + /// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two + /// vaults invites, and until now the only answer was to know one of the machines by name already. + /// + /// + /// The order is the assertion that matters. A vault name is the widest of the three readings — one word + /// matches every host in that vault at once — so the host whose own name carries the word has to come + /// first, or typing a machine's name would bury it under everybody else's. + /// + /// + [Fact] + public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself() + { + await UnlockedAsync(); + + var vaults = shell.Vaults; + + await CreateVaultAsync(vaults, "Platform secrets"); + + var vault = shell.Vault!; + var sharedVaultId = vaults.SelectedVault!.VaultId; + + await vault.LoadAsync(Token); + + vault.NewHostCommand.Execute(null); + vault.EditorLabel = "platform-gateway"; + vault.EditorHostname = "gateway.internal"; + + await vault.SaveHostCommand.ExecuteAsync(null); + + vault.NewHostCommand.Execute(null); + vault.EditorSelectedVault = + vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId); + vault.EditorLabel = "prod-db"; + vault.EditorHostname = "db.internal"; + + await vault.SaveHostCommand.ExecuteAsync(null); + + vault.IsEditing.ShouldBeFalse(vault.Status); + + shell.ToggleSearchCommand.Execute(null); + shell.SearchText = "platform"; + + shell.SearchResults + .Select(row => row.Label) + .ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's"); + + shell.SearchResults[1].VaultBadge.ShouldBe( + "PLATFORM SECRETS", "and the row says what it was found by"); + } + /// /// Two hosts for the run above: one in the personal vault under a group, one already in the destination. ///