Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s

Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
This commit is contained in:
2026-07-31 22:59:33 +02:00
parent 7a3a521c59
commit 4300d917a8
21 changed files with 2003 additions and 112 deletions
@@ -36,7 +36,9 @@
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot live" Width="5" Height="5" VerticalAlignment="Center" />
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
@@ -81,12 +83,33 @@
</StackPanel>
<!--
Collapsed rather than merely covered when there are no tabs. On Windows this control is a native
child window that composites above everything Avalonia draws, which is why the desktop head hides
it explicitly; whether Android's WebView does the same is recorded as unverified in
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
A tab with no pane behind it: one still connecting, or one that was refused. The desktop head draws
the same thing in its own ConnectingCard, and it exists for the same reason — connecting does not
hold the application still any more, so there is a stretch in which a tab is selected and there is
nothing yet to render in it. A phone needs it more than a desktop does: mobile links are slower, and
the alternative is a black rectangle.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding HasTabs, FallbackValue=False}" />
<StackPanel IsVisible="{Binding IsConnectingShowing}" VerticalAlignment="Center" Margin="24"
Spacing="10">
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
</Button>
</StackPanel>
<!--
Collapsed rather than merely covered when there is no pane to show. On Windows this control is a
native child window that composites above everything Avalonia draws, which is why the desktop head
hides it explicitly; whether Android's WebView does the same is recorded as unverified in
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
connecting has no pane, and showing the renderer for it would show the previous session's output
under the name of a machine nothing has connected to yet.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
@@ -96,8 +119,9 @@
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
then release, because holding a modifier while typing is not possible one-thumbed.
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" IsVisible="{Binding HasTabs}">
BorderThickness="0,1,0,0" IsVisible="{Binding IsTerminalShowing}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Height="44" Spacing="4"
Margin="6,5" />