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.