Compare commits

...
Author SHA1 Message Date
jaap-jan 0cecf1afc4 Android: ask what to connect to on Connections, the same prompt the desktop's empty terminal draws
ci / build and test (push) Successful in 4m14s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 22s
ci / android head (push) Successful in 5m6s
ci / desktop nightly (push) Successful in 3m2s
The phone's Connections screen with nothing open had a box that took an address alone, which left a
saved machine several screens away. It now draws the desktop's prompt over the same view-model state:
"connect to:" takes a saved host's name or user@host[:port], and a password line follows only when
one is needed. Names, addresses and refusals are read exactly as on the desktop, and nothing typed
here is saved.

What a phone changes: the hosts matching so far are thumb-sized buttons that take a host outright,
since there is no Tab; CONNECT is always drawn, since the IME's return key may only close the
keyboard; BACK, and the system back gesture, step off the password line rather than leaving the
screen; and the password's label sits on its own line. The keyboard follows the question onto the
password line, but is not raised when the screen opens, where it would cover the recent list.

Recent rows now go onto the prompt too. Covered by a new ShellFlowTests case for a tapped match on
top of the shared prompt cases; manual check 11.14.
2026-10-16 14:07:13 +02:00
jaap-jan 089de72834 Merge pull request 'Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead' (#23) from claude/elastic-mccarthy-6b4ae7 into main
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / deploy ssh.dodotech.cloud (push) Canceled after 0s
ci / build and test (push) Canceled after 4m25s
Reviewed-on: #23
2026-10-16 12:04:41 +00:00
jaap-jan f43436218a Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead
ci / build and test (pull_request) Successful in 2m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m42s
WhenTheSessionEnds_ItsForwardsSayTheyWentWithIt failed once under a full run. Ended clears IsLive
and then tells each forward its session is over, in one call. The app's dispatcher never splits that,
but the harness's post runs it on the fake shell's thread, so the test's poll on !IsLive could return
between the two and find the forwards still listening.

The test now also waits until every forward says "Stopped with the session.", the last thing
SessionEnded sets. A 300 ms sleep injected between the two steps fails the old test and passes the new
one; the full App.Tests suite passed three runs in a row.
2026-10-16 13:17:01 +02:00
8 changed files with 233 additions and 47 deletions

No files matched your search

+17
View File
@@ -2335,6 +2335,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
has no right button to miss.
### 11.14 Connections with nothing open asks what to connect to
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
cases, shared with the desktop; this is the rectangles and the keyboard.
---
## Phase 12 — Shared vaults: the operations that span two accounts
@@ -843,6 +843,30 @@
<Setter Property="TextInputOptions.ContentType" Value="Password" />
</Style>
<!--
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
on the password line and sets nothing in common with.
-->
<Style Selector="TextBox.prompt">
<Setter Property="MinHeight" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="14" />
</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>
<!--
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
@@ -0,0 +1,98 @@
<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.Android.Views"
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
x:DataType="vm:MainWindowViewModel">
<!--
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
THREE THINGS A PHONE CHANGES.
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
and the system back gesture, which PhoneShell routes to the same command.
A CONNECT button, because the software keyboard's own return key may commit or may only close the
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
for every keyboard that does send it.
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
-->
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="12" Padding="14,12">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
<!-- ============ what to connect to ============ -->
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
AutomationProperties.Name="Connect to"
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</Grid>
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ============ the password, when one is needed ============ -->
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
Text="{Binding PromptPasswordLabel}" />
<!--
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
-->
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
Text="{Binding PromptPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
<!--
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
-->
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
Command="{Binding SubmitPromptCommand}" />
</Grid>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace DodoSSH.Client.Android.Views;
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
/// <remarks>
/// <para>
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
/// type nothing and look as though it should.
/// </para>
/// <para>
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
/// </para>
/// <para>
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
/// fields are null at run time.
/// </para>
/// </remarks>
internal sealed partial class ConnectPrompt : UserControl
{
public ConnectPrompt()
{
AvaloniaXamlLoader.Load(this);
var line = this.FindControl<StackPanel>("PasswordLine")!;
var box = this.FindControl<TextBox>("Password")!;
line.PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && line.IsVisible)
{
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
// its line becomes visible, and focusing a control with none raises no keyboard.
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
}
};
}
}
@@ -674,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
return;
}
// The Connections prompt's password line, which back steps off onto the name it asked about — the
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
// surface would throw the name away along with the question.
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
{
current.CancelPromptCommand.Execute(null);
e.Handled = true;
return;
}
if (!current.IsShowingPages)
{
current.ShowScreenCommand.Execute(current.Screen);
@@ -345,9 +345,11 @@
running this surface is where a connection is made rather than a page apologising for having
nothing on it. Two things are offered, in the order somebody reaches for them.
A box, because the case this application had no answer for is the address somebody has just been
given. Everything else here starts from a keychain item, which is right for a machine used twice and
is several screens of work for one used once.
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
host's name or user@host for a machine that is not saved, and asks for a password only when one is
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
machine several screens away from here.
And the machines already connected to, under it, because that is the commoner half by far: the
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
@@ -359,44 +361,7 @@
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
<!--
The manual target. One box in the grammar people already have — user@host, or user@host:port —
rather than three, because three boxes on a phone is three taps between three keyboards for
something that arrives as one string.
-->
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
PlaceholderText="user@host or user@host:port">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<!--
A typed password and nothing else. Offering the keychain's keys here would be a second binding
resolution beside the one the connect path already has, and a key is the reason to save the
machine as a host rather than dial it from a box.
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
this is not prose, which is the difference between dots on screen and dots on screen with the
password in the IME's dictionary. See Theme/Phone.axaml.
-->
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
<!--
Beside the box rather than on the shell's status line. What fails here is about what was typed —
a missing account, a port that is not a number — and a sentence about a text box belongs under
the text box.
-->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding Vault.ManualStatus}"
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<views:ConnectPrompt />
<TextBlock Classes="body"
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
@@ -410,14 +375,13 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<!--
Tapping one does not connect, and that is deliberate rather than a missing step. A row
that names a keychain host goes to that host with its connect bar — which is where its
key, or its password box, or its refusal lives — and one that names only an address goes
back into the box above, without the password, which was never stored. Either way what
happens next is visible before anything is dialled. See ConnectToRecentCommand.
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
connects, or asks for its password there; an address asks for its password, which was
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
MainWindowViewModel.ChoosePromptRecentCommand.
-->
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
await EventuallyAsync(
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
"the session's end should have reached its forwards");
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
@@ -1363,6 +1363,27 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
}
/// <remarks>
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
/// </remarks>
[Fact]
public async Task ATappedMatchOnThePrompt_TakesThatHost()
{
var vault = await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
shell.PromptText.ShouldBe("prod-db");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
ssh.Requests.ShouldBeEmpty();
}
/// <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.