Move the SFTP and S3 connection into the right-hand pane

THE CONNECT BAR IS GONE. It was a 44-pixel strip across the top of the file
screen holding a heading, a picker, a password box and a button — chrome
spanning both panes in order to configure one of them, drawn at full width
whether or not anything was ever going to be connected. Underneath it sat a
pane that was empty for exactly the same reason, saying so in a sentence
nobody had to be told twice.

So the pane says it instead. Disconnected, the right-hand half is an invitation
where the listing would be: what the screen is for, what pressing the button
costs, and one thing to press. SELECT HOST opens the picker in place — the same
combo, the same password box, the same CONNECT — and the sentence and the
button go away with it, because by then they have been read or they have not.

Two steps rather than a picker sitting open, and that is not decoration. The
reason the pane is empty is the question, and a combo box in the middle of it
is a form with no question above it. The two steps also keep the panel inside
its budget, which is the pane's height less whatever the queue has taken — 268
pixels with three transfers on it. Neither shape reaches the floor, and the
layout suite measures both.

IsChoosingRemote is cleared by everything that changes what the picker would be
picking: connecting, disconnecting, moving between the SFTP and S3 tabs, and
losing the vault. The last two are the ones that would rot quietly — a picker
surviving a hop to the other tab offers hosts on a screen showing buckets, and
one surviving a lock offers a list that has just been emptied because its rows
carried decrypted secrets. CANCEL takes the typed password with it, which is a
secret nobody asked to keep.

WHERE THE TWO CONNECTED FACTS WENT. The address chip and DISCONNECT are a strip
of their own inside the remote pane, above the listing — not three more cells in
the header beside UP, REFRESH and DELETE. That pane is 381 pixels wide at the
window's minimum and a fourth control in that row would have pushed one of the
three off the edge; the number is written into the markup so the next thing
added to either row is measured against it rather than tried.

The status line did not fit there either. What is left after a 170-pixel address
and a DISCONNECT is about eighty pixels, which turns every sentence into its
first word and an ellipsis, so while a session is open it is in the queue's own
strip at the foot of the screen, which spans the window. The other half of the
time it is inside the invitation, beside the button that provoked it. One home
in each state rather than two homes in one and none in the other.

The header label reads HOST or BUCKET now, which is the only thing on the screen
naming the kind — the bar that printed SFTP or S3 is gone and the tab in the
strip says it either way, whether or not this screen is showing.

The opening status text was "Choose a host and connect to browse its files",
which the invitation now says in a heading, a sentence and a button. It is
"Nothing is open yet.": a state rather than an instruction. That string is
shared with the phone, where it still reads correctly under the picker card
that head shows directly.

Desktop only, and the phone is unchanged rather than merely untouched. Its
FilesScreen is one pane at a time, so the picker *is* what it shows before a
connection exists; it binds none of the new members.

Four tests. Three in the layout suite — the picker open over a full queue, which
is the tall shape and the one that has to be measured with the panes at their
least; the bucket picker, which is a row shorter because an object store has
nothing to type; and a session open with a long enough address to prove the chip
gives way before DISCONNECT does. The fourth is the picker's lifecycle in
ShellFlowTests, over all four things that put it away. 302 tests pass across the
two suites.
This commit is contained in:
2026-08-03 15:50:31 +02:00
parent 1b7df47537
commit 61139bd469
5 changed files with 427 additions and 122 deletions
@@ -5088,6 +5088,67 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
}
/// <remarks>
/// <para>
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
/// is a flag rather than a screen, so what is worth asserting is that it is never left open over a pane
/// it no longer belongs to: connecting closes it, disconnecting closes it, moving between SFTP and S3
/// closes it, and losing the vault closes it.
/// </para>
/// <para>
/// The last two are the ones that would rot quietly. A picker surviving a hop to the other tab offers
/// hosts on a screen showing buckets, and one surviving a lock offers a list that has just been emptied
/// because its rows carried decrypted secrets.
/// </para>
/// </remarks>
[Fact]
public async Task TheFilePickerIsPutAwayByEverythingThatChangesWhatItWouldBePicking()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.IsChoosingRemote.ShouldBeFalse("the pane opens on its invitation");
// Connecting.
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.IsChoosingRemote.ShouldBeFalse("what is open is the listing now");
// Disconnecting, which puts the pane back to the invitation rather than to the form.
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeFalse(shell.Transfers.Status);
shell.Transfers.IsChoosingRemote.ShouldBeFalse();
// Moving to the other destination, which has a different list behind it.
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
shell.Transfers.ShowsBucketPicker.ShouldBeTrue();
shell.Transfers.IsChoosingRemote.ShouldBeFalse("that picker was offering hosts");
// Cancelling, which also takes the typed password with it.
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
shell.Transfers.TypedPassword = "hunter2";
shell.Transfers.CancelChoosingRemoteCommand.Execute(null);
shell.Transfers.IsChoosingRemote.ShouldBeFalse();
shell.Transfers.TypedPassword.ShouldBeEmpty("a secret nobody asked to keep");
// And losing the vault, which empties both lists.
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
await shell.LockCommand.ExecuteAsync(null);
shell.Transfers.Hosts.ShouldBeEmpty();
shell.Transfers.IsChoosingRemote.ShouldBeFalse("a form offering a choice between nothing");
}
private async Task UnlockedAsync()
{
await EnrolledAndConfirmedAsync();