Public Access
Terminal: ask what to connect to when Terminal is pressed with nothing open
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:
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
|
||||
|
||||
Reference in new issue
Block a user