Give the phone a selection instead of a card under the list

A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
This commit is contained in:
2026-08-06 09:15:37 +02:00
parent 174ef7c420
commit c882fa0cd3
11 changed files with 2669 additions and 722 deletions
@@ -1133,17 +1133,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <remarks>
/// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
/// keychain host goes to that host on the hosts screen, with the panel about it opened — the desktop's
/// drawer, the phone's connect bar — carrying whatever authentication the keychain resolves for it and a
/// password box only if it needs one. Connecting from here instead would be a third connect path that
/// had to answer all of that again.
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
/// path that had to answer all of that again.
/// </para>
/// <para>
/// ◆ <b>It opens that panel rather than merely selecting the row, and on the phone it has to.</b>
/// Choosing a host there no longer raises the bar — a tap on the list connects instead, see
/// <c>VaultViewModel.ShowsConnectBar</c> — so arriving with the host selected and nothing else would be
/// arriving at a screen with nothing to press. Asking to go back to a machine is exactly the deliberate
/// act that flag exists to distinguish from browsing.
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
/// </para>
/// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
@@ -1171,7 +1171,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
vault.OpenHostPaneCommand.Execute(known);
vault.AskAboutHostCommand.Execute(known);
ShowScreen(ShellScreen.Hosts);
return;
}
@@ -2757,6 +2757,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
// resolves: the vault holds the handler, not the other way round.
oldValue.VaultsChanged -= OnVaultsAdmitted;
oldValue.FilesRequested -= OnVaultFilesRequested;
if (attempts.Count == 0)
{
@@ -2779,6 +2780,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// end of.
newValue.VaultsChanged += OnVaultsAdmitted;
// ◆ The phone's action bar asking for a host's files rather than a shell on it. An event because
// the screen it leads to is this object's and the transfers view model behind it is a sibling of
// the vault rather than a part of it; which machine is the vault's business, because a host is a
// decrypted item. See OnVaultFilesRequested.
newValue.FilesRequested += OnVaultFilesRequested;
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
// disconnected — so without this the status dots go out once a minute underneath terminals that
// are still open. The rows belong to the vault and the connection state belongs to the shell,
@@ -3213,6 +3220,62 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Surface = ShellSurface.Page;
}
/// <summary>
/// Takes the hosts screen to the files screen, on the host it asked about.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>"Connect via SFTP", from the phone's action bar.</b> It was reachable before only by going to the
/// files screen and choosing the machine again out of a picker — which meant naming a host twice, the
/// second time on a screen that had no idea one had already been chosen.
/// </para>
/// <para>
/// It goes through <see cref="ShowFiles"/> rather than setting the screen itself, so the one refusal
/// there — a bucket already open — is made once and made here too. The host is chosen after that call,
/// because arriving is what clears the picker.
/// </para>
/// <para>
/// <b>It navigates and then connects, and the two are separate on purpose.</b> A host wanting a typed
/// password cannot be dialled from a list, so that case opens the picker with the machine already chosen
/// and the box beside it — the same branch the tap on the hosts screen makes, and for the same reason.
/// Everything else connects, and its failures land on the files screen's own status line, which is where
/// somebody who has just arrived there is looking.
/// </para>
/// <para>
/// The row is re-found in the transfers screen's own list rather than used directly. That list is a copy
/// rebuilt from the vault's — see <c>TransfersViewModel.Hosts</c> — and its picker binds to rows in it,
/// so handing it the vault's object would select nothing.
/// </para>
/// </remarks>
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
{
ShowFiles(RemoteKind.Host);
if (Screen is not ShellScreen.Transfers)
{
// The refusal above stood: a bucket is open, and its message is on screen. Choosing a host under
// it would leave the picker pointing at a machine nothing is going to dial.
return;
}
Transfers.SelectedHost =
Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
if (Transfers.SelectedHost is null)
{
return;
}
if (Transfers.SelectedHostAsksForAPassword)
{
Transfers.BeginChoosingRemoteCommand.Execute(null);
Transfers.Status = $"{e.Host.Label} asks for a password. Type it here, then CONNECT.";
return;
}
_ = Transfers.ConnectCommand.ExecuteAsync(null);
}
/// <inheritdoc cref="OnScreenChanged" />
/// <remarks>
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a