Public Access
Name a quick-connect result's vault, and search on it #13
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user