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

+23
View File
@@ -873,6 +873,29 @@
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
</Style>
<!--
◆ THE EMPTY TERMINAL'S PROMPT LINES, which are typed into the way a shell is: no fill, no border and no
padding in any state, so the text sits on the pane exactly where the label before it leaves off, and the
caret is the only sign the line takes input. A box drawn around it would turn a prompt back into a form.
See ConnectPrompt.axaml. After the base rules and .pathbox, for the reason this file keeps restating.
-->
<Style Selector="TextBox.prompt">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
TextBox.prompt:focus /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<!--
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
@@ -0,0 +1,113 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.ConnectPrompt"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Pane}">
<!--
What the terminal's rectangle holds when Terminal is pressed with nothing open: a prompt, because asking
is what a terminal with nothing in it does. It used to be an empty frame, with the tab row's "+" the only
thing on it that did anything.
Two lines, in the order ssh itself asks. The first takes a saved host's name — matched exactly, with the
names that match so far printed under it for Tab or a click to take — or user@host[:port] for a machine
that is not saved. The second appears only when a password is needed, worded as ssh words it, and is
never offered for a host whose keychain binding supplies its own credential.
Drawn by Avalonia in the WebView's own rectangle, which the occlusion rule allows only because the two
are exclusive: this shows while there are no tabs, and the WebView only for a selected tab with a
session. See MainWindowViewModel.IsConnectPromptShowing.
Everything it does goes through the vault's own connect paths — ConnectToHostAsync for a saved host,
ConnectToEndpointAsync for an address — so every refusal those make is made here too, and nothing typed
at this prompt is written to the keychain.
-->
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="18,16" Spacing="3" TextElement.FontFamily="{StaticResource MonoFont}">
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Text="No session open. Connect to a saved host by name, or to any machine as user@host[:port]." />
<!-- ============ what to connect to ============ -->
<Grid ColumnDefinitions="Auto,*" Margin="0,14,0,0">
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="Bold"
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}" />
</Grid>
<!--
The saved hosts that match so far, best first, the first one Tab's. A wrapped row rather than a list,
because this is a shell's completion line and not a picker: a name and an address, nothing to scroll.
-->
<ItemsControl ItemsSource="{Binding PromptMatches}" Margin="0,2,0,0"
IsVisible="{Binding ShowsPromptMatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Button Classes="flat" Focusable="False" Padding="6,2" Margin="0,0,6,0" CornerRadius="5"
Cursor="Hand" ToolTip.Tip="{Binding Address}"
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextDim}"
Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ============ the password, when one is needed ============ -->
<Grid x:Name="PasswordLine" ColumnDefinitions="Auto,*" IsVisible="{Binding IsPromptAskingForPassword}">
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" Foreground="{StaticResource Text}"
Text="{Binding PromptPasswordLabel}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Password" Classes="prompt" Margin="8,0,0,0" PasswordChar="•"
Text="{Binding PromptPassword}" />
</Grid>
<TextBlock Classes="mono" FontSize="12.5" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{StaticResource WarnText}"
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
<TextBlock Classes="mono" FontSize="10.5" Margin="0,10,0,0" Foreground="{StaticResource TextGhost}"
Text="ENTER to connect · TAB to complete · ESC to go back · CTRL+K to search hosts" />
<!--
============ where you have been ============
The machines most recently connected to, newest first — the vault's own connection log, deduplicated
by address, so it survives a restart and arrives on a new machine with the keychain. A click puts the
row on the prompt line and carries on from there exactly as if it had been typed: a saved host
connects, or asks for its password; an address asks for its password.
-->
<StackPanel Margin="0,22,0,0" Spacing="1" IsVisible="{Binding HasRecentConnections}">
<TextBlock Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}" Text="recent"
Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding RecentConnections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<Button Classes="flat" Focusable="False" Padding="6,3" CornerRadius="5" Cursor="Hand"
HorizontalAlignment="Left"
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="14">
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextDim}"
Text="{Binding Address}" />
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
Text="{Binding HostLabel}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,125 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The empty terminal's prompt: what to connect to, and its password when one is needed.</summary>
/// <remarks>
/// The keys and the caret live here, as the palette's do in <see cref="QuickConnect"/> and for its reason: a
/// <see cref="UserControl"/> hosts in a bare window under the headless harness, and the window itself cannot
/// be shown there at all.
/// </remarks>
internal sealed partial class ConnectPrompt : UserControl
{
public ConnectPrompt()
{
InitializeComponent();
// Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is
// in it, and a text box on the route must not get to them first.
AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel);
// The password line appearing, or going, moves the caret with it: Enter on a name that needs a password
// should leave somebody typing the password, and Escape off it should leave them editing the name.
PasswordLine.PropertyChanged += OnLineChanged;
}
/// <summary>The first line's box, for the caret and for the tests.</summary>
internal TextBox TargetBox => Target;
/// <summary>The password line's box, for the caret and for the tests.</summary>
internal TextBox PasswordBox => Password;
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
// Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes
// visible its boxes are not in the visual tree yet, and focus is refused to anything that is not.
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
{
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
}
}
private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == IsVisibleProperty && IsVisible)
{
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
}
}
/// <summary>Puts the caret on whichever line is asking.</summary>
/// <remarks>
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView can still
/// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native
/// call is a caret that receives nothing.
/// </remarks>
private void TakeKeyboard()
{
if (!IsEffectivelyVisible)
{
return;
}
if (TopLevel.GetTopLevel(this) is Window window)
{
NativeKeyboardFocus.ReturnTo(window);
}
if (PasswordLine.IsVisible)
{
Password.Focus();
}
else
{
Target.Focus();
Target.CaretIndex = Target.Text?.Length ?? 0;
}
}
private void OnPromptKey(object? sender, KeyEventArgs e)
{
if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None)
{
return;
}
switch (e.Key)
{
case Key.Enter:
// Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt,
// and a connection's progress and failure in the tab it opens.
_ = shell.SubmitPromptCommand.ExecuteAsync(null);
e.Handled = true;
break;
case Key.Escape:
shell.CancelPromptCommand.Execute(null);
e.Handled = true;
break;
case Key.Tab:
// Only when there is something to complete to; otherwise Tab moves the focus, as it always does.
e.Handled = shell.CompletePrompt();
if (e.Handled)
{
Target.CaretIndex = Target.Text?.Length ?? 0;
}
break;
default:
break;
}
}
}
+12 -3
View File
@@ -285,9 +285,8 @@
<!--
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
this Grid is only drawn on the terminal surface, and the surface with no tab open already
answers for itself in the tab row's own "+" and in the connecting card below.
used to print ("no terminals open · press + or Ctrl+K…") went with it, and the surface with
no tab open is answered by the prompt below instead — see ConnectPrompt.axaml.
-->
<!--
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
@@ -309,6 +308,16 @@
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
<!--
The third: nothing open at all, which is what pressing Terminal on the rail with no
session reaches. A prompt asking what to connect to, where an empty frame used to be.
Exclusive with the WebView for the same reason the card above is — no tab, no session —
and FallbackValue=False for the reason the WebView's own binding gives. See
ConnectPrompt.axaml.
-->
<views:ConnectPrompt x:Name="PromptPane"
IsVisible="{Binding IsConnectPromptShowing, FallbackValue=False}" />
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
browser process tree, so twenty tabs would cost twenty of them.
@@ -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
@@ -4378,9 +4378,21 @@ internal sealed partial class VaultViewModel(
/// </para>
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
SelectedHost is null
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
SelectedHost is null || AsksForAPassword(SelectedHost);
/// <summary>Whether connecting to this host needs a password typed for it.</summary>
/// <remarks>
/// The per-row half of <see cref="SelectedHostAsksForAPassword"/>, and the same reading for the same
/// reasons: the resolved binding rather than the host's own ids, and never for a host Spelunk signs in to.
/// The desktop's terminal prompt asks it of a host named there, which is not the selected one.
/// </remarks>
internal static bool AsksForAPassword(HostRowViewModel row)
{
ArgumentNullException.ThrowIfNull(row);
return !row.Host.SignsInThroughSpelunk
&& row.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
}
/// <summary>
/// What the terminal column says in place of the password box, or nothing when the box is showing.
@@ -11460,11 +11472,41 @@ internal sealed partial class VaultViewModel(
return;
}
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
/// </summary>
/// <remarks>
/// <para>
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
/// choice the prompt never offered. Every refusal is still made here, once.
/// </para>
/// <para>
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
/// </para>
/// </remarks>
/// <param name="row">The host to connect to.</param>
/// <param name="password">The password typed for it, or empty.</param>
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
internal async Task ConnectToHostAsync(
HostRowViewModel row,
string password,
bool mayRememberPassword,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(row);
// Refused rather than quietly falling back to the password box. A host set up for key-only access
// that silently starts offering a password is the failure worth ruling out — the user asked for one
// thing and got another, and the host is the last place that would say so.
if (!TryBuildAuthentication(
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
row.Host, row.Resolved, password, out var authentication, out var refusal))
{
Status = refusal;
return;
@@ -11477,7 +11519,12 @@ internal sealed partial class VaultViewModel(
IsAskingForConnectPassword = false;
await ConnectToAsync(
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
new ConnectionTarget(
row.Label,
row.Host.Hostname,
row.Resolved.Port.Value,
row,
mayRememberPassword),
authentication,
cancellationToken).ConfigureAwait(true);
}
@@ -12331,7 +12378,7 @@ internal sealed partial class VaultViewModel(
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
// screen it is typed on says so.
if (target.Row is { } row)
if (target is { Row: { } row, MayRememberPassword: true })
{
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
}
@@ -12618,6 +12665,12 @@ internal sealed partial class VaultViewModel(
/// <param name="Row">
/// The keychain host this came from, or null for a machine that is not in the keychain.
/// </param>
/// <param name="MayRememberPassword">
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
/// because the tick happened to be left on in a drawer that is not even open.
/// </param>
/// <remarks>
/// <para>
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
@@ -12638,7 +12691,8 @@ internal sealed partial class VaultViewModel(
string Label,
string Hostname,
int Port,
HostRowViewModel? Row = null)
HostRowViewModel? Row = null,
bool MayRememberPassword = true)
{
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
internal Guid? HostId => Row?.EntityId;