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.
///