Name a quick-connect result's vault, and search on it #13

Merged
jaap-jan merged 1 commits from claude/search-items-vault-display-865eb0 into main 2026-08-14 09:13:17 +00:00
5 changed files with 215 additions and 6 deletions
@@ -87,12 +87,27 @@
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<!-- <!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading; connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication. see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
--> -->
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}" <StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" /> <TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -2141,8 +2141,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <remarks> /// <remarks>
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one /// 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 /// that merely contains it, both come before a match found only in the address, and all three come
/// characters of a name people use daily should not put that host third. /// 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 <see cref="Rank"/>.
/// </remarks> /// </remarks>
private void RefreshSearchResults() private void RefreshSearchResults()
{ {
@@ -2175,6 +2176,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasSearchResults)); OnPropertyChanged(nameof(HasSearchResults));
} }
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
/// <remarks>
/// <para>
/// <b>The vault comes last, and only when the row prints it.</b> 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.
/// </para>
/// <para>
/// <see cref="HostRowViewModel.HasVaultBadge"/> 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.
/// </para>
/// </remarks>
private static int Rank(HostRowViewModel host, string query) private static int Rank(HostRowViewModel host, string query)
{ {
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
@@ -2187,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return 1; return 1;
} }
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase) if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
? 2 {
return 2;
}
return host.HasVaultBadge
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 3
: int.MaxValue; : int.MaxValue;
} }
@@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime
}); });
} }
/// <remarks>
/// 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 <c>prod-db</c> are otherwise two identical rows,
/// and Enter takes whichever the ranking happened to put first.
/// <para>
/// 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.
/// </para>
/// </remarks>
[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);
});
}
/// <remarks>
/// 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. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
/// than leave a gap above the kind word.
/// </remarks>
[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);
});
}
/// <remarks> /// <remarks>
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window /// 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 <c>PropertyChanged</c>, which runs before the binding that reveals the /// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
@@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime
.OfType<ListBoxItem>() .OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host)); .First(item => ReferenceEquals(item.DataContext, host));
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
private static List<string> VisibleTexts(Visual row) =>
row.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.IsEffectivelyVisible)
.Select(text => text.Text ?? string.Empty)
.ToList();
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
/// <remarks>
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
/// finds nothing to look at.
/// </remarks>
private static void Relayout(Window window)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
}
private static Point Centre(Visual control, Visual window) => private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), 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"); ?? 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); await vault.LoadAsync(Token);
} }
/// <summary>
/// Adds a second, shared vault to the fixture's session and files one host into it.
/// </summary>
/// <remarks>
/// Straight into the session rather than through <c>VaultsViewModel</c>, 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 <see cref="StubTeamServer"/>.
/// </remarks>
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);
}
} }
@@ -1765,6 +1765,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeTrue();
} }
/// <remarks>
/// 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 <c>MainWindowViewModel.Rank</c>.
/// </remarks>
[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();
}
/// <remarks> /// <remarks>
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a /// 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 /// terminal fills the window would point at a screen that is not showing — and the selected tab already
@@ -766,6 +766,59 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("left alone", Case.Insensitive); vault.Status.ShouldContain("left alone", Case.Insensitive);
} }
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}
/// <summary> /// <summary>
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination. /// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
/// </summary> /// </summary>