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
@@ -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>