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

@@ -1521,10 +1521,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// to the same value.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// <b>Not gated on there being a tab, because the last clause already is.</b> A selected tab with a
/// session is what this needs, so the surface with nothing open — which the rail's Terminal entry reaches
/// deliberately — has the WebView collapsed, and that is the rectangle <see cref="IsConnectPromptShowing"/>
/// draws its prompt in.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
@@ -1852,16 +1852,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>Switches to the terminal surface.</summary>
/// <remarks>
/// <para>
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
/// button, and a button needs a command.
/// The other half of <see cref="ShowScreenCommand"/>: the phone's bottom bar and the desktop rail's
/// Terminal entry both name the terminal beside the pages, so the destination needs a button, and a
/// button needs a command.
/// </para>
/// <para>
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — the
/// phone's terminal screen draws its manual box there, and the desktop its prompt; see
/// <see cref="IsConnectPromptShowing"/> — which is worth being able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
@@ -2365,7 +2363,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool HasTabs => Tabs.Count > 0;
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
private void RaiseTabState()
{
OnPropertyChanged(nameof(HasTabs));
OnPropertyChanged(nameof(IsConnectPromptShowing));
}
/// <summary>
/// Closes one terminal, ending its shell.
@@ -2436,6 +2438,333 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
}
// ---- The empty terminal's prompt ----
/// <summary>
/// Whether the terminal's rectangle is asking what to connect to: the terminal surface, with nothing open.
/// </summary>
/// <remarks>
/// <para>
/// <b>The desktop's answer to pressing Terminal with no session.</b> The surface used to draw an empty
/// frame there — the tab row's "+" was the only thing on it that did anything. A terminal with nothing in
/// it should do what a terminal does, which is ask: so the frame holds a prompt that takes a saved host's
/// name, or <c>user@host</c> for a machine that is not saved, and asks for a password when one is needed.
/// </para>
/// <para>
/// Exclusive with the renderer by construction: the WebView shows only for a selected tab with a session,
/// and this only while there are no tabs at all. That is what lets Avalonia draw it in the WebView's
/// rectangle, the way <c>ConnectingCard</c> already is. See <see cref="IsTerminalShowing"/>.
/// </para>
/// </remarks>
internal bool IsConnectPromptShowing => IsTerminalSurface && !HasTabs;
/// <summary>What was typed at the prompt: a saved host's name, or <c>user@host[:port]</c>.</summary>
[ObservableProperty]
private string promptText = string.Empty;
/// <summary>The password typed at the prompt's second line.</summary>
/// <remarks>
/// Cleared the moment it is used or abandoned. It is never written into the vault's own password box
/// either: that box carries a remember-this tick the prompt never offered — see
/// <c>VaultViewModel.ConnectToHostAsync</c>.
/// </remarks>
[ObservableProperty]
private string promptPassword = string.Empty;
/// <summary>Whether the prompt has moved on to asking for a password.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPromptAskingForTarget))]
[NotifyPropertyChangedFor(nameof(ShowsPromptMatches))]
private bool isPromptAskingForPassword;
/// <summary>Whether the prompt is still asking what to connect to.</summary>
internal bool IsPromptAskingForTarget => !IsPromptAskingForPassword;
/// <summary>The second line's prompt, in <c>ssh</c>'s own words: whose password it is.</summary>
[ObservableProperty]
private string promptPasswordLabel = string.Empty;
/// <summary>Why the prompt refused what was typed, or empty.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPromptRefusal))]
private string promptRefusal = string.Empty;
/// <summary>Whether there is a refusal to print under the prompt.</summary>
internal bool HasPromptRefusal => PromptRefusal.Length > 0;
/// <summary>
/// Saved hosts whose name or address matches what has been typed so far, best first; Tab takes the first.
/// </summary>
/// <remarks>
/// Empty for a query with an <c>@</c> in it, which is an address rather than a name, and for an empty
/// one: a list of every host under a prompt nobody has typed into yet is the hosts screen, drawn worse.
/// </remarks>
internal ObservableCollection<HostRowViewModel> PromptMatches { get; } = [];
/// <summary>Whether there are matches to print under the prompt.</summary>
internal bool HasPromptMatches => PromptMatches.Count > 0;
/// <summary>Whether the matches are drawn: only while the name is being asked for, not under its password.</summary>
internal bool ShowsPromptMatches => HasPromptMatches && !IsPromptAskingForPassword;
/// <summary>The saved host the password line is for, when it is for one.</summary>
private HostRowViewModel? promptHost;
/// <summary>The typed address the password line is for, when it is for one.</summary>
private VaultViewModel.ManualEndpoint? promptEndpoint;
/// <summary>
/// Enter at the prompt: takes what was typed, or — on the password line — connects.
/// </summary>
/// <remarks>
/// <para>
/// A name is matched exactly, ignoring case, because Enter on a name is a decision and not a search: the
/// matches printed under the prompt are how a partial name becomes a whole one, by Tab or by a click.
/// Two hosts with the same name in two vaults are refused rather than guessed between, and pointed at
/// the palette, which shows which vault each came from.
/// </para>
/// <para>
/// Concurrent executions allowed, for the reason <see cref="ConnectToAdHocTargetAsync"/> gives.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task SubmitPromptAsync()
{
if (Vault is not { } vault)
{
return;
}
if (IsPromptAskingForPassword)
{
await ConnectFromPromptAsync(vault).ConfigureAwait(true);
return;
}
var typed = PromptText.Trim();
if (typed.Length == 0)
{
PromptRefusal = "Type the name of a saved host, or user@host for a machine that is not saved.";
return;
}
if (typed.Contains('@', StringComparison.Ordinal))
{
if (!VaultViewModel.TryParseManualTarget(typed, out var endpoint, out var reason))
{
PromptRefusal = reason;
return;
}
AskForPromptPassword(endpoint);
return;
}
var named = vault.Hosts
.Where(host => string.Equals(host.Label, typed, StringComparison.OrdinalIgnoreCase))
.Take(2)
.ToList();
switch (named.Count)
{
case 0:
PromptRefusal = PromptMatches.Count > 0
? $"No saved host is called \"{typed}\". Press Tab for {PromptMatches[0].Label}, or type user@host."
: $"No saved host is called \"{typed}\". Type user@host to reach a machine that is not saved.";
return;
case > 1:
PromptRefusal =
$"More than one saved host is called \"{typed}\". Press Ctrl+K to choose between them.";
return;
default:
await TakePromptHostAsync(vault, named[0]).ConfigureAwait(true);
return;
}
}
/// <summary>A click on one of the matches printed under the prompt.</summary>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ChoosePromptMatchAsync(HostRowViewModel? host)
{
if (Vault is not { } vault || host is null)
{
return;
}
PromptText = host.Label;
await TakePromptHostAsync(vault, host).ConfigureAwait(true);
}
/// <summary>
/// A click on one of the recent connections printed under the prompt.
/// </summary>
/// <remarks>
/// A row naming a host still in the keychain goes to that host, with whatever the keychain knows about
/// it; one that names none — or a host deleted since — is the address that was dialled, and asks for its
/// password the way a typed one does. The same split <see cref="ConnectToRecent"/> makes, and for its
/// reason, except that here the answer is a line of the prompt rather than another screen.
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
{
if (Vault is not { } vault || row is null)
{
return;
}
if (row.HostId is { } hostId
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
PromptText = known.Label;
await TakePromptHostAsync(vault, known).ConfigureAwait(true);
return;
}
PromptText = row.Address;
if (VaultViewModel.TryParseManualTarget(row.Address, out var endpoint, out var reason))
{
AskForPromptPassword(endpoint);
}
else
{
PromptRefusal = reason;
}
}
/// <summary>
/// Escape at the prompt: off the password line back to the name, or clears the name.
/// </summary>
[RelayCommand]
private void CancelPrompt()
{
if (IsPromptAskingForPassword)
{
IsPromptAskingForPassword = false;
PromptPassword = string.Empty;
PromptRefusal = string.Empty;
promptHost = null;
promptEndpoint = null;
return;
}
PromptText = string.Empty;
PromptRefusal = string.Empty;
}
/// <summary>Tab at the prompt: completes to the best match, if there is one.</summary>
/// <returns>Whether anything was completed, so the key can move the focus as usual when it was not.</returns>
internal bool CompletePrompt()
{
if (IsPromptAskingForPassword || PromptMatches.Count == 0)
{
return false;
}
PromptText = PromptMatches[0].Label;
return true;
}
/// <summary>Connects to a saved host, or asks for its password first if it needs one.</summary>
private async Task TakePromptHostAsync(VaultViewModel vault, HostRowViewModel host)
{
if (VaultViewModel.AsksForAPassword(host))
{
promptHost = host;
promptEndpoint = null;
PromptPasswordLabel = $"{host.Address}'s password:";
PromptPassword = string.Empty;
PromptRefusal = string.Empty;
IsPromptAskingForPassword = true;
return;
}
ResetPrompt();
// No password to offer and nothing to remember: the binding supplies the credential. Any refusal —
// a key deleted on another machine, a host with no username — lands on the status line, as it does
// for the palette.
await vault.ConnectToHostAsync(host, string.Empty, mayRememberPassword: false, CancellationToken.None)
.ConfigureAwait(true);
}
/// <summary>Moves the prompt on to the password for a typed address.</summary>
private void AskForPromptPassword(VaultViewModel.ManualEndpoint endpoint)
{
promptHost = null;
promptEndpoint = endpoint;
PromptPasswordLabel = $"{endpoint.Address}'s password:";
PromptPassword = string.Empty;
PromptRefusal = string.Empty;
IsPromptAskingForPassword = true;
}
/// <summary>The password line's Enter: dials whichever of the two the line is for.</summary>
private async Task ConnectFromPromptAsync(VaultViewModel vault)
{
if (PromptPassword.Length == 0)
{
PromptRefusal = "Type the password, or press Esc to go back.";
return;
}
var password = PromptPassword;
var host = promptHost;
var endpoint = promptEndpoint;
// Before the dial, so the password does not wait in the box for the length of a handshake. The tab the
// connection opens replaces the prompt anyway; this is what is there the next time nothing is open.
ResetPrompt();
if (host is not null)
{
await vault.ConnectToHostAsync(host, password, mayRememberPassword: false, CancellationToken.None)
.ConfigureAwait(true);
}
else if (endpoint is not null)
{
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
}
/// <summary>Puts the prompt back to an empty first line.</summary>
private void ResetPrompt()
{
IsPromptAskingForPassword = false;
PromptPassword = string.Empty;
PromptText = string.Empty;
PromptRefusal = string.Empty;
promptHost = null;
promptEndpoint = null;
}
/// <remarks>
/// A refusal is about what was typed, so typing again clears it; the matches follow every keystroke.
/// </remarks>
partial void OnPromptTextChanged(string value)
{
PromptRefusal = string.Empty;
PromptMatches.Clear();
var query = value.Trim();
if (Vault is { } vault && query.Length > 0 && !query.Contains('@', StringComparison.Ordinal))
{
foreach (var host in HostsMatching(vault, query).Take(5))
{
PromptMatches.Add(host);
}
}
OnPropertyChanged(nameof(HasPromptMatches));
OnPropertyChanged(nameof(ShowsPromptMatches));
}
// ---- Quick connect ----
/// <summary>Whether the quick-connect palette is open over the window.</summary>
@@ -2827,16 +3156,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
foreach (var host in HostsMatching(vault, query).Take(8))
{
SearchResults.Add(host);
}
@@ -2866,6 +3186,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasNothingToOffer));
}
/// <summary>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
/// <remarks>
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
/// machines in the same order.
/// </remarks>
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
query.Length == 0
? vault.Hosts
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
/// <remarks>
/// <para>
@@ -5906,6 +6241,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsTerminalShowing));
OnPropertyChanged(nameof(IsConnectingShowing));
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
OnPropertyChanged(nameof(IsConnectPromptShowing));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a