Terminal: ask what to connect to when Terminal is pressed with nothing open
ci / build and test (push) Successful in 4m13s
ci / android head (push) Successful in 4m24s
ci / api image (push) Successful in 13s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m6s

The terminal surface with no session used to be an empty frame, with the tab row's "+" the only
thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a
saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a
click, or user@host[:port] for a machine that is not saved. A password line follows only when one
is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below.

It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two
are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a
session.

Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect
now goes through too, so every refusal is still made once. Each attempt now carries whether it may
act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in
a closed drawer would save a password typed at a prompt that never offered to keep anything.

Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
This commit is contained in:
jaap-jan committed 2026-10-16 12:17:34 +02:00
1 parent 5b2e3f3651
commit 3c8c0980c2
9 files changed
+1154 -33

No files matched your search

@@ -1223,6 +1223,228 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
}
// ---- The empty terminal's prompt ----
/// <remarks>
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
/// </remarks>
[Fact]
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
shell.ShowTerminalCommand.Execute(null);
shell.IsConnectPromptShowing.ShouldBeTrue();
shell.PromptText = "deploy@build.internal";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.HasTabs.ShouldBeTrue();
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
}
/// <remarks>
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
/// prompt asks for it in ssh's own words and dials the host with it.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "PROD-DB";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
shell.PromptText.ShouldBeEmpty();
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
shell.IsPromptAskingForPassword.ShouldBeFalse();
}
/// <remarks>
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
/// at a prompt that never offered to save anything.
/// </remarks>
[Fact]
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.RemembersConnectPassword = true;
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod-db";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem();
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
}
/// <remarks>
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
/// is left exactly as it was.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "deploy@build.internal:2222";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
vault.Credentials.ShouldBeEmpty();
}
/// <remarks>
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
/// names the nearest host, and Tab takes it.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("Tab");
ssh.Requests.ShouldBeEmpty();
shell.CompletePrompt().ShouldBeTrue();
shell.PromptText.ShouldBe("prod-db");
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
}
/// <remarks>
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
/// it refuses rather than guessing and points at the palette, which does say.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesANameTwoHostsShare()
{
var vault = await ReadyToConnectAsync();
await AddHostAsync(vault, "prod-db");
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod-db";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("More than one");
shell.IsPromptAskingForPassword.ShouldBeFalse();
}
/// <remarks>
/// Escape walks back one line at a time, as it does in the palette: off the password onto the name with
/// the name intact, and then the name cleared.
/// </remarks>
[Fact]
public async Task EscapeAtThePrompt_StepsBackALineAtATime()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "root@box";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "hunter2";
shell.CancelPromptCommand.Execute(null);
shell.IsPromptAskingForPassword.ShouldBeFalse();
shell.PromptText.ShouldBe("root@box");
shell.PromptPassword.ShouldBeEmpty();
shell.CancelPromptCommand.Execute(null);
shell.PromptText.ShouldBeEmpty();
}
/// <remarks>
/// An empty password is refused on the prompt rather than sent, for the reason the manual box gives: the
/// server's "authentication failed" would be a sentence about the machine when the problem is the box.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAnEmptyPassword()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "root@box";
await shell.SubmitPromptCommand.ExecuteAsync(null);
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("password");
shell.IsPromptAskingForPassword.ShouldBeTrue();
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
/// A recent connection that names no keychain host is the address that was dialled, so choosing it asks
/// for that address's password — the log never held the password, which is the point of the path.
/// </remarks>
[Fact]
public async Task ARecentAddressOnThePrompt_AsksForItsPassword()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
await shell.ChoosePromptRecentCommand.ExecuteAsync(
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
shell.PromptText.ShouldBe("deploy@build.internal:2222");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
}
/// <remarks>
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
/// away, or the next machine typed would be dialled with somebody else's password.