Android: type user@host into the hosts search to open a terminal on a machine without saving it
ci / android head (push) Successful in 4m22s
ci / build and test (push) Successful in 4m39s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m20s

The phone's only unsaved way in was the box on the terminal screen, which is drawn only while nothing
is open, so a second machine could not be reached that way while a first one's session was up. The
search well at the top of HOSTS is always there and the design already calls it "Search or
connect...", so it now reads an address the way the desktop palette does: with an @ in it, a NOT
SAVED card above the list offers a password box and CONNECT.

It connects through VaultViewModel.ConnectToEndpointAsync, the same path as the manual box and the
desktop palette, so nothing is written to the keychain. The search box and the password are cleared
once it dials, the password goes whenever the address changes, and an address the manual parser
refuses gets its sentence under the box.

Covered by six new ShellFlowTests cases; manual check 8.20 covers the card on a device.
This commit is contained in:
jaap-jan committed 2026-10-14 17:27:57 +02:00
1 parent 988248fa57
commit 5b2e3f3651
4 files changed
+285 -2

No files matched your search

@@ -72,7 +72,7 @@
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE8B6;" Foreground="{StaticResource TextFaint}"
FontSize="20" Margin="12,0,0,0" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontSize="14" />
@@ -82,6 +82,42 @@
<!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<!--
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
Above the list rather than over it, like the panels below: the hosts that also match what was typed
stay in view, and one of them may be the same machine with a key already bound to it.
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
what keeps the password out of the software keyboard's dictionary, not only off the screen.
-->
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
VerticalAlignment="Center" />
</Grid>
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Height="44" Content="CONNECT"
Command="{Binding ConnectToFilterAddressCommand}" />
</StackPanel>
</Border>
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
<!--
@@ -4278,6 +4278,54 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string manualStatus = string.Empty;
/// <summary>
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
/// it reads the address rather than the screen growing a second field.
/// </para>
/// <para>
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
/// would arrive on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
private ManualEndpoint? filterAddress;
/// <summary>Whether the search box holds an address that can be connected to.</summary>
internal bool HasFilterAddress => FilterAddress is not null;
/// <summary>The typed address as it will be dialled, port and all.</summary>
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
/// <summary>
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
private string filterAddressRefusal = string.Empty;
/// <summary>Whether there is a refusal to show under the search box.</summary>
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
/// <remarks>
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
/// password offered on the other. Cleared when the address changes, so the next machine typed never
/// arrives with the last one's password already in the box.
/// </remarks>
[ObservableProperty]
private string filterAddressPassword = string.Empty;
/// <summary>
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
/// </summary>
@@ -11483,6 +11531,77 @@ internal sealed partial class VaultViewModel(
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Connects to the address typed into the hosts search box, with the password typed under it.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
/// keychain.
/// </para>
/// <para>
/// The search box and the password are cleared before the dial, from copies taken first. The list left
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
/// asked to keep.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ConnectToFilterAddressAsync()
{
if (FilterAddress is not { } endpoint)
{
return;
}
if (FilterAddressPassword.Length == 0)
{
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
return;
}
var password = FilterAddressPassword;
// In this order: clearing the filter clears the address and the password with it — see
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
HostFilter = string.Empty;
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
private void RefreshFilterAddress(string filter)
{
var previous = FilterAddress;
var trimmed = filter.Trim();
if (!trimmed.Contains('@', StringComparison.Ordinal))
{
FilterAddress = null;
FilterAddressRefusal = string.Empty;
}
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
{
FilterAddress = endpoint;
FilterAddressRefusal = string.Empty;
}
else
{
FilterAddress = null;
FilterAddressRefusal = reason;
}
// A different machine, or none, is a different question: the password typed for the last one goes.
if (FilterAddress != previous)
{
FilterAddressPassword = string.Empty;
}
}
/// <summary>
/// Opens a terminal on a typed address with a typed password, saving neither.
/// </summary>
@@ -13504,6 +13623,7 @@ internal sealed partial class VaultViewModel(
/// </remarks>
partial void OnHostFilterChanged(string value)
{
RefreshFilterAddress(value);
RebuildVisibleHosts();
RebuildHostSections();
OnPropertyChanged(nameof(NoHostBoardMessage));