Public Access
Ask before deleting, and connect a host by double-clicking it
DELETE on a host, an SSH key, a stored password or a file on the host now puts a question where the button was, and only answering it deletes anything. It is a state rather than a dialog, which is the arrangement signing out already had and for the same reason: this is the moment that has to be able to say what is about to go before it goes. What the question says is counted rather than generic, because a confirmation that only asks whether you are sure is a click to train people out of. A key names the hosts that authenticate with it and says they will refuse to connect afterwards rather than falling back to a typed password, which is what the connect path actually does. A host discloses a terminal open on it, because deleting the host does not close the session. Every vault deletion says how far it travels and whether this machine can push the tombstone yet or is queuing it. Deleting on the host carries the strongest warning of the four on purpose: everything else here is a tombstone against a copy the server still holds, and a file on somebody's machine is bytes with nothing behind them — so that one names the full path, since a bare name identifies nothing. The armed request carries the item's entity id, so nothing that moves the selection between the question and the answer can redirect it, and answering about something that has since gone says so instead of doing nothing quietly. Disarming compares ids rather than rows, which is the subtle half: a reload replaces every row object, so the naive rule would have let the pass that runs every minute take the card away from somebody halfway through reading it. Forgetting a pinned host key is deliberately still unguarded. It costs one fingerprint check on the next connection and it is the safe direction to be wrong in — the dangerous button there is the one that adds trust, and that one is already a prompt at connect time. Discarding a stopped transfer is likewise unguarded: it removes a resumable part file and leaves the source alone. Double-clicking a host in the sidebar connects to it, wired as a gesture in the control exactly as the transfers screen opens a directory. CONNECT stays, since it is the button with the password box beside it. Ten existing delete call sites now go through arm-and-confirm helpers, and eight new flow tests cover asking first, cancelling, the counted warning, disarming on a selection change and on an editor opening, surviving a sync, and the stale-item guard. Three layout tests measure the new shapes — the sidebar card is the one card in the application a user cannot scroll — and one of them also asserts the card renders its text, because a card whose compiled bindings did not resolve would lay out perfectly as empty rows. The double-click test performs the real gesture and proves it reached the connect command through a refusal that never touches a network. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 853 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -174,6 +174,40 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the host that has been asked about and not yet agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
|
||||
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
|
||||
/// has no wastebasket to put them in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It carries the full path rather than only the name, because the name is the half that does not identify
|
||||
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
|
||||
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">What the row was called.</param>
|
||||
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
|
||||
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
|
||||
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
|
||||
{
|
||||
/// <summary>The question, naming the kind because the two behave differently.</summary>
|
||||
internal string Question => IsDirectory
|
||||
? $"Delete the directory '{Name}' on the host?"
|
||||
: $"Delete '{Name}' on the host?";
|
||||
|
||||
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
|
||||
internal string Consequence => IsDirectory
|
||||
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
|
||||
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
|
||||
+ "copy and there is no undo."
|
||||
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
|
||||
+ "touched, and there is no undo.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
@@ -284,6 +318,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
|
||||
[ObservableProperty]
|
||||
private RemoteDeletionRequest? pendingRemoteDeletion;
|
||||
|
||||
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||
|
||||
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||
/// <remarks>
|
||||
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
||||
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
||||
/// move UP and REFRESH out from under the pointer.
|
||||
/// </remarks>
|
||||
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -672,15 +720,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// Asks whether the chosen remote file, or empty directory, should go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
||||
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
||||
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
||||
/// copy of.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
private void DeleteRemote()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
@@ -688,18 +737,44 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes what was agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
|
||||
/// which is what makes the question a promise: nothing between asking and answering can point it
|
||||
/// somewhere else.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingRemoteDeletion is not { } request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
$"Deleting {request.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
Status = $"Deleted {request.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -919,11 +994,28 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
/// <remarks>
|
||||
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
|
||||
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
|
||||
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
|
||||
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
|
||||
/// </remarks>
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
}
|
||||
|
||||
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user