Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it
ci / build and test (push) Successful in 5m48s
ci / api image (push) Successful in 47s
ci / deploy ssh.dodotech.cloud (push) Failing after 2s
ci / desktop nightly (push) Successful in 3m26s
ci / android head (push) Successful in 10m2s

The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual
box on its terminal screen all along. The palette's query now reads as an address when it has an @
in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter
connects. Escape on the password step goes back to the address.

It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the
tab, the host-key question and the connection-log entry are the same on both heads and nothing is
written to the keychain. Password only, as on the phone: a key is the reason to save the host.

The typed row takes the highlight unless a saved host dials exactly that address, so typing a new
address cannot land on a saved machine whose address merely starts the same way.

Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
This commit is contained in:
jaap-jan committed 2026-10-14 17:02:26 +02:00
1 parent 902761d37b
commit 988248fa57
8 files changed
+840 -99

No files matched your search

@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
Token);
}
// ---- A machine that is not saved ----
/// <remarks>
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
/// </remarks>
[Fact]
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5:2222";
Relayout(window);
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
});
}
/// <remarks>
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
/// makes the row comparable with a saved host's address, which always carries one.
/// </remarks>
[Fact]
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "root@box";
shell.AdHocAddress.ShouldBe("root@box:22");
});
}
/// <remarks>
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
/// machine that was never saved.
/// </remarks>
[Fact]
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "nothing-is-called-this";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("user@host", StringComparison.Ordinal),
"the hint names the other thing the box takes");
});
}
/// <remarks>
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
/// </remarks>
[Fact]
public async Task AnAddressThatCannotBeReadSaysWhy()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@box:99999";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("port", StringComparison.Ordinal),
"the refusal is the manual parser's own sentence");
});
}
/// <remarks>
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
/// </remarks>
[Fact]
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
shell.IsAskingForAdHocPassword.ShouldBeTrue();
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
});
}
/// <remarks>
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
/// to keep.
/// </remarks>
[Fact]
public async Task EscapeFromThePasswordGoesBackToTheAddress()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
shell.IsAskingForAdHocPassword.ShouldBeFalse();
shell.SearchText.ShouldBe("root@10.0.0.5");
shell.AdHocPassword.ShouldBeEmpty();
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
});
}
/// <remarks>
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
/// password with an empty one answers "authentication failed", which is a sentence about the machine
/// when the problem is the box.
/// </remarks>
[Fact]
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeTrue();
shell.HasAdHocRefusal.ShouldBeTrue();
started.ShouldBeEmpty();
});
}
/// <remarks>
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
/// handling; what the palette promises is the attempt and the closing.
/// </remarks>
[Fact]
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
var hostsBefore = vault.Hosts.Count;
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5:2222";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
var attempt = started.ShouldHaveSingleItem();
attempt.Address.ShouldBe("root@10.0.0.5:2222");
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
});
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
}
/// <remarks>
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
/// it was set up to be reached. The typed row is still offered, one arrow away.
/// </remarks>
[Fact]
public async Task AnAddressASavedHostDialsHighlightsThatHost()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "deploy@host-3.internal";
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
});
}
/// <remarks>
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
/// </remarks>
[Fact]
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
// Every seeded host's address starts with this, and none of them is exactly it.
shell.SearchText = "deploy@host";
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
shell.IsAdHocSelected.ShouldBeTrue();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue();
shell.SelectedSearchResult.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
palette.QueryBox.IsFocused.ShouldBeTrue();
});
}
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
[Fact]
public async Task ClickingTheAddressAsksForItsPassword()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5";
Relayout(window);
var row = palette.AdHocTargetRow;
window.MouseDown(Centre(row, window), MouseButton.Left);
window.MouseUp(Centre(row, window), MouseButton.Left);
shell.IsAskingForAdHocPassword.ShouldBeTrue();
shell.IsSearching.ShouldBeTrue();
});
}
// ---- Helpers ----
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>