Merge branch 'claude/sftp-s3-connection-ui-b0730f'
ci / android head (push) Failing after 5s
ci / build and test (push) Successful in 1m23s
ci / api image (push) Successful in 24s

This commit is contained in:
2026-08-03 15:51:19 +02:00
5 changed files with 427 additions and 122 deletions
+4 -3
View File
@@ -193,9 +193,10 @@ launch, because no device key is registered.
### Moving files
**FILES** in the nav rail is a two-pane browser: this machine on the left, the host on the right, and a
queue underneath. Choose a host, press **CONNECT**, then select a file in either pane and press the arrow
pointing the way you want it to go.
**SFTP** and **S3** in the tab strip are a two-pane browser: this machine on the left, the remote on the
right, and a queue underneath. The right-hand pane opens on an invitation rather than a listing — press
**SELECT HOST**, choose one, press **CONNECT** — and once something is open, select a file in either pane
and press the arrow pointing the way you want it to go.
Two things about it are worth expecting rather than discovering.
+217 -115
View File
@@ -14,6 +14,21 @@
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
about a file on the machine this client is developed on.
── v3 ────────────────────────────────────────────────────────────────────────────────────────────────
◆ THE CONNECT BAR IS GONE, and everything it held is inside the right-hand pane now.
It was a 44-pixel strip across the top of the screen holding a heading, a picker, a password box and a
button — chrome spanning both panes to configure one of them, and 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 this screen is for, what pressing the button will cost, and the button. Connected, the
invitation is replaced by the listing and the connection states itself in a strip above it, beside the
control that closes it — which is the arrangement the two facts were always about. The heading the bar
carried is not reprinted anywhere: the tab in the strip says SFTP or S3, and it says it whether or not
this screen is showing.
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
layer has not got. See docs/design-import-gaps.md.
-->
@@ -71,105 +86,10 @@
</Style>
</UserControl.Styles>
<Grid RowDefinitions="44,*,Auto">
<!-- ============ The host, and the connection ============ -->
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
<!--
The screen names whichever remote it is offering, because since v2 it is reached as two
destinations rather than one: SFTP and S3 are separate tabs in the strip — they were rail entries
until v3 — and this control draws both. A single "FILES" heading over a bucket picker would name
neither of them.
-->
<TextBlock Grid.Column="0" Classes="mono" Text="SFTP" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="0" Classes="mono" Text="S3" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" IsVisible="{Binding ShowsBucketPicker}" />
<!--
The HOST / BUCKET pair that used to sit here is gone: which sort of remote this screen offers is
now the tab you chose, and a toggle that silently moved you to the other one would leave the lit
tab naming a screen you are no longer on. What sets it is ShowFiles on the shell, which is what
the tab calls.
-->
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsBucketPicker}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
holding a different value. This connection authenticates separately, so a password typed to open a
terminal was never offered here.
-->
<TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}"
IsEnabled="{Binding !IsConnected}" />
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
Command="{Binding ConnectCommand}"
IsVisible="{Binding !IsConnected}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
Command="{Binding DisconnectCommand}"
IsVisible="{Binding IsConnected}" />
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" />
</Border>
<!--
Column 6, not 5, which is where it used to be — sharing a cell with the connected chip, so the two
drew over each other for as long as anything was open. That was survivable while the status was
mostly read before connecting; it is not now, because refusing to switch between SFTP and S3 while
a session is live reports itself here, which is precisely when the chip is on screen.
-->
<TextBlock Grid.Column="6" Classes="hint" Text="{Binding Status}" FontSize="11.5"
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</Grid>
</Border>
<Grid RowDefinitions="*,Auto">
<!-- ============ The two panes ============ -->
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
<Grid Grid.Row="0" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== -->
<!--
@@ -292,13 +212,26 @@
</Border>
<!-- ==== The host ==== -->
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
<!--
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
rather than above both because it is about this pane and nothing else.
-->
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center" />
<!--
Which kind of remote this pane is for, in the place the local pane names itself. It is the
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
difference between a directory tree and a flat namespace with inferred folders in it.
-->
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" />
@@ -310,6 +243,32 @@
</Grid>
</Border>
<!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
A row of its own rather than three more cells in the header above, and the reason is arithmetic
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
off the edge. The layout suite would have caught it — which is the point of stating the number
here, so the next thing added to either row is measured against it rather than tried.
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
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. It is at the foot of the screen instead — see the
queue's own strip, which has the width for one.
-->
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConnected}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
</Border>
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Grid>
</Border>
<!--
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
still on screen and still selected while it is being answered — and it names the full path rather
@@ -319,7 +278,7 @@
application deletes is a tombstone against a copy the server still has, and a file on somebody's
host is bytes with nothing behind them.
-->
<Border Grid.Row="1" Padding="12,10" Background="{StaticResource DangerWash}"
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConfirmingRemoteDeletion}">
<StackPanel Spacing="7">
@@ -338,7 +297,7 @@
</StackPanel>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -373,14 +332,14 @@
</StackPanel>
</Grid>
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
</Grid>
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
@@ -401,28 +360,155 @@
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasRemoteEntries}">
<!--
A directory with nothing in it. It no longer has to say anything about not being connected: that
state is the invitation below, which covers this pane whole.
-->
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
<!--
============ THE INVITATION, which is what this screen opens on ============
Everything the connect bar used to hold, in the half of the screen it was always about. It covers
the pane from below the header to the bottom, opaquely: the breadcrumb, the column headings and
the empty listing are all still laid out underneath, and all three are furniture for a listing
that does not exist yet.
Two steps, not one. The first is an invitation with a button — a name for what this screen does, a
sentence about what it costs, and one thing to press — and the picker is the second. A combo box
sitting open in the middle of an empty pane would be a form with no question above it; the reason
the pane is empty is the question, and that is what the first step says.
◆ The two steps also keep this inside its budget, which is the pane's height less whatever the
queue has taken — 268 pixels with three transfers on it. The sentence and the button go away when
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
reaches the floor. Anything added here has to hold that; the layout suite measures both.
-->
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
IsVisible="{Binding !IsConnected}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!--
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
application ships no icon set — see the note on colour at the top of this file — so a glyph in
a rounded square is what an icon is here, and ▤ is already the one that means "a place things
are kept".
-->
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1">
<TextBlock Text="▤" FontSize="18" Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
Text="Connect to a host" IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
Text="Open a bucket" IsVisible="{Binding ShowsBucketPicker}" />
<!--
The sentence, and the button under it. Both go away once the picker is up — by then they have
been read or they have not — which is why they are one collapsing group rather than two
elements each answering the same question.
The sentence is the one the empty pane used to carry, and it is kept rather than dropped
because it is the one thing about this screen that surprises people: browsing files is a
second login, and it shows up in the host's auth log as one. The bucket's counterpart says
the opposite thing for the opposite reason — there is no session to open at all, so nothing
is dialled until a listing is asked for.
-->
<StackPanel Spacing="10" IsVisible="{Binding !IsChoosingRemote}">
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
IsVisible="{Binding !IsConnected}" />
IsVisible="{Binding ShowsHostPicker}"
Text="Browsing a host's files opens its own SFTP connection, so the host records a second login — it is not the channel a terminal uses." />
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
Text="Nothing in this directory."
IsVisible="{Binding IsConnected}" />
IsVisible="{Binding ShowsBucketPicker}"
Text="A bucket has nothing to connect to: the keys and the endpoint are tested by the first listing, not by opening a session." />
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT HOST"
IsVisible="{Binding ShowsHostPicker}"
Command="{Binding BeginChoosingRemoteCommand}" />
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT BUCKET"
IsVisible="{Binding ShowsBucketPicker}"
Command="{Binding BeginChoosingRemoteCommand}" />
</StackPanel>
<!-- ==== The picker, once it has been asked for ==== -->
<StackPanel Spacing="8" IsVisible="{Binding IsChoosingRemote}">
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsVisible="{Binding ShowsBucketPicker}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a
different box holding a different value. This connection authenticates separately, so a
password typed to open a terminal was never offered here.
-->
<TextBox PasswordChar="•" Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
<Button Classes="accent" Content="{Binding ConnectLabel}"
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding CancelChoosingRemoteCommand}" />
</StackPanel>
</StackPanel>
<!--
The status line, in the state where the connected strip above is not on screen. Wrapped rather
than trimmed, unlike its counterpart up there: what lands here is a refusal with a reason in
it — a server's own sentence about why a connection did not open — and an ellipsis through the
middle of that is a message that has been shown without being said.
-->
<TextBlock Classes="hint" FontSize="11.5" TextAlignment="Center" Text="{Binding Status}" />
</StackPanel>
</Border>
<!--
Two highlights rather than one, because refusing is worth showing. Something dragged over a
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
@@ -435,7 +521,7 @@
</Grid>
<!-- ============ The queue ============ -->
<Border Grid.Row="2" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196">
<Grid RowDefinitions="Auto,*">
@@ -445,6 +531,22 @@
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="one at a time · nothing lands at its final name until it is complete" />
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
It is here rather than beside DISCONNECT because this row spans the window and that one spans
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
width. Only while something is open — the other half of the time it is inside the invitation
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
Right-aligned in a free column, so it reads as this row's other end rather than as a third
clause of the sentence to its left.
-->
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
</Border>
@@ -511,7 +613,7 @@
they arrive here on their own because this is a separate connection — a host trusted for a shell is
trusted for this too, but a host nobody has connected to at all is met here first.
-->
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
IsVisible="{Binding HasPendingHostKey}">
<Border Classes="card">
<StackPanel Spacing="12">
@@ -528,7 +630,7 @@
</Border>
</Border>
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
IsVisible="{Binding HasHostKeyMismatch}">
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
<StackPanel Spacing="12">
@@ -357,6 +357,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <summary>Whether the picker is showing buckets.</summary>
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
/// <summary>
/// Whether the picker is open, as opposed to the invitation that offers it.
/// </summary>
/// <remarks>
/// <para>
/// The desktop's connect bar is gone — a strip of controls across the top of a screen that is not
/// connected to anything, asking a question the empty right-hand pane was already asking silently. What
/// replaced it is the pane itself: an invitation where the listing would be, and this flag is the step
/// between "connect to a host" and the picker that does it.
/// </para>
/// <para>
/// Two steps rather than a picker sitting open, because the pane is the whole answer to "why is this
/// half of the screen empty" and a combo box does not say that. The phone does not use this: its screen
/// is one pane at a time, so the picker <em>is</em> what it shows before a connection exists.
/// </para>
/// <para>
/// It is cleared by everything that changes what the picker would be picking — connecting, disconnecting,
/// switching between SFTP and S3, and losing the vault — so an open form is never left over a pane that
/// has moved on. See the property-changed hooks at the foot of this file.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isChoosingRemote;
/// <summary>
/// What the button that opens the remote says.
/// </summary>
@@ -375,8 +399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private string typedPassword = string.Empty;
/// <remarks>
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
/// it was the only thing on the screen saying so — the connect bar it sat in had a picker and a button
/// and no prose at all. The invitation that replaced the bar says it in a heading, a sentence and a
/// button, so a status line repeating it underneath was the same instruction three times.
/// </remarks>
[ObservableProperty]
private string status = "Choose a host and connect to browse its files.";
private string status = "Nothing is open yet.";
[ObservableProperty]
private bool isBusy;
@@ -563,6 +594,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
SelectedHost = null;
SelectedBucket = null;
TypedPassword = string.Empty;
// The picker with it. Its two lists have just been emptied, so leaving it open would show a form
// offering a choice between nothing.
IsChoosingRemote = false;
}
// ShowRemoteCommand was here, and it went with the toggle that invoked it. Which kind of remote this
@@ -570,6 +605,25 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// MainWindowViewModel.ShowFiles, which sets Remote directly because it has a refusal to make first.
// Keeping the command would have left one nothing could invoke.
/// <summary>Opens the picker, from the invitation in the empty remote pane.</summary>
[RelayCommand]
private void BeginChoosingRemote() => IsChoosingRemote = true;
/// <summary>
/// Puts the picker away without connecting.
/// </summary>
/// <remarks>
/// The typed password goes with it. It is a secret nobody asked to keep, and leaving it in the box would
/// mean the next person to open the picker — for a different host, possibly — starts with somebody
/// else's password already typed in.
/// </remarks>
[RelayCommand]
private void CancelChoosingRemote()
{
IsChoosingRemote = false;
TypedPassword = string.Empty;
}
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
@@ -1390,6 +1444,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
OnPropertyChanged(nameof(ShowsBucketPicker));
OnPropertyChanged(nameof(ConnectLabel));
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
// Arriving at the other destination shows its invitation rather than a picker somebody left open on
// this one — and the picker it would have left open is the wrong one, since the two kinds have
// different lists behind them.
IsChoosingRemote = false;
}
partial void OnIsConnectedChanged(bool value)
@@ -1397,6 +1456,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(CanUpload));
OnPropertyChanged(nameof(CanDeleteRemote));
// Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
// back to its invitation rather than to the form, so what the pane shows after a disconnect is the
// same thing it showed before anything was ever connected.
IsChoosingRemote = false;
}
/// <remarks>
@@ -926,6 +926,83 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// ◆ The tallest the invitation gets, and the shape the connect bar's removal has to survive. Everything
/// that used to be a strip across the top of the screen is now stacked in the right-hand pane — a
/// picker, a password box and two buttons, under a heading — and that pane is only as tall as whatever
/// the queue leaves it.
/// </para>
/// <para>
/// So the queue is filled first, which is what takes it to its 196-pixel maximum and leaves the panes
/// their least. Without those rows this measures the invitation with nearly twice the room it is
/// guaranteed, which is the version of this test that would pass whatever was added to the panel.
/// </para>
/// <para>
/// No host is selected on purpose: a picker with nothing chosen shows the password box, because a host
/// that names neither a key nor a credential is one that will ask for a password. That is the shape with
/// the extra row in it.
/// </para>
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithTheHostPickerOpenOverAFullQueue()
{
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
TransferState.Running, bytesPerSecond: 6_500_000);
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
TransferState.Cancelled);
Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed);
transfers.BeginChoosingRemoteCommand.Execute(null);
transfers.IsChoosingRemote.ShouldBeTrue();
transfers.SelectedHostAsksForAPassword
.ShouldBeTrue("the password box is what makes this the tall shape");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the picker open"));
}
/// <remarks>
/// The bucket half of the picker, which differs by one row: no password box, because an object store
/// carries its keys in the vault and has nothing to type. Measured because the two are separate markup
/// rather than one picker with its items swapped — see the note on <c>RemoteKind</c>.
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithTheBucketPickerOpen()
{
transfers.Remote = RemoteKind.Bucket;
transfers.BeginChoosingRemoteCommand.Execute(null);
transfers.ShowsBucketPicker.ShouldBeTrue();
transfers.SelectedHostAsksForAPassword.ShouldBeFalse("a bucket never asks for one");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the bucket picker open"));
}
/// <remarks>
/// <para>
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
/// that already carries UP, REFRESH and DELETE.
/// </para>
/// <para>
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
/// </para>
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithASessionOpen()
{
transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.Status = "Connected to releases-eu.";
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a session open"));
}
/// <remarks>
/// The trust card covers the whole screen, and it is the one thing here a user cannot get past without
/// pressing something — so a button of its own that fell outside the window would leave the screen
@@ -5314,6 +5314,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();