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:
@@ -132,8 +132,9 @@ dotnet run --project src/DodoSSH.Client.App
|
|||||||
|
|
||||||
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
||||||
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
||||||
skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's
|
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
|
||||||
admin console is at `http://localhost:18080` (`admin` / `admin`).
|
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
|
||||||
|
it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`).
|
||||||
|
|
||||||
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
|
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
|
||||||
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
|
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
|
||||||
@@ -146,6 +147,14 @@ the next sync. If a server is legitimately rebuilt and offers a new key, the con
|
|||||||
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
|
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
|
||||||
somewhere you have to go on purpose.
|
somewhere you have to go on purpose.
|
||||||
|
|
||||||
|
**Deleting asks first, and the question is worth reading.** DELETE on a host, an SSH key or a stored password
|
||||||
|
puts a question where the buttons were, and what it says is counted rather than generic: how many hosts
|
||||||
|
authenticate with the key about to go — they refuse to connect afterwards rather than falling back to a typed
|
||||||
|
password — whether a terminal is open on the host about to go, and whether this machine can push the deletion
|
||||||
|
yet or is queuing it. There is no undo, which is the other thing it says. Withdrawing host key trust is the
|
||||||
|
deliberate exception: it costs one fingerprint check on the next connection, and the dangerous button there is
|
||||||
|
the one that *adds* trust.
|
||||||
|
|
||||||
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
|
**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
|
||||||
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
|
key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
|
||||||
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
|
key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
|
||||||
@@ -186,6 +195,9 @@ beside its destination and is renamed into place at the end, so an interrupted t
|
|||||||
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
||||||
build artefact onto a server and then running it. A destination that already exists is refused outright
|
build artefact onto a server and then running it. A destination that already exists is refused outright
|
||||||
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
||||||
|
DELETE asks first and names the full path, and it carries the strongest warning in the application on
|
||||||
|
purpose: everything else DodoSSH deletes is a tombstone against a copy the server still holds, and a file on
|
||||||
|
somebody's host is bytes with nothing behind them.
|
||||||
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
||||||
|
|
||||||
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -284,6 +318,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
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 ----
|
// ---- The local pane ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -672,15 +720,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes the chosen remote file, or an empty directory.
|
/// Asks whether the chosen remote file, or empty directory, should go.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
||||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
||||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
||||||
|
/// copy of.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
private void DeleteRemote()
|
||||||
{
|
{
|
||||||
if (SelectedRemoteEntry is not { } row)
|
if (SelectedRemoteEntry is not { } row)
|
||||||
{
|
{
|
||||||
@@ -688,18 +737,44 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
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(
|
await RunAsync(
|
||||||
$"Deleting {row.Name}…",
|
$"Deleting {request.Name}…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = $"Deleted {row.Name}.";
|
Status = $"Deleted {request.Name}.";
|
||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
@@ -919,11 +994,28 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
OnPropertyChanged(nameof(CanUpload));
|
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));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
|
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||||
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||||
OnPropertyChanged(nameof(CanUpload));
|
OnPropertyChanged(nameof(CanUpload));
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,55 @@ internal sealed record VaultItemRowViewModel(
|
|||||||
internal bool HasBadge => Badge.Length > 0;
|
internal bool HasBadge => Badge.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||||||
|
internal enum DeletionTarget
|
||||||
|
{
|
||||||
|
/// <summary>A host, from the sidebar beside the terminal.</summary>
|
||||||
|
Host,
|
||||||
|
|
||||||
|
/// <summary>An SSH key, from the vault screen.</summary>
|
||||||
|
Key,
|
||||||
|
|
||||||
|
/// <summary>A stored password, from the vault screen.</summary>
|
||||||
|
Credential,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deletion that has been asked for and not yet agreed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
|
||||||
|
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
|
||||||
|
/// below are <em>computed</em>: how many hosts authenticate with the key about to go, whether a terminal is
|
||||||
|
/// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that
|
||||||
|
/// only said "are you sure?" would be a click to train people out of.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It carries the item's id rather than pointing at the selection, so that whatever moves the selection
|
||||||
|
/// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an
|
||||||
|
/// agreement about one item into the deletion of another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Target">Which list to delete from.</param>
|
||||||
|
/// <param name="EntityId">The item the question is about.</param>
|
||||||
|
/// <param name="Question">The question itself, naming the item.</param>
|
||||||
|
/// <param name="Consequence">Where it goes, and how far.</param>
|
||||||
|
/// <param name="Usage">
|
||||||
|
/// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or
|
||||||
|
/// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares.
|
||||||
|
/// </param>
|
||||||
|
internal sealed record DeletionRequest(
|
||||||
|
DeletionTarget Target,
|
||||||
|
Guid EntityId,
|
||||||
|
string Question,
|
||||||
|
string Consequence,
|
||||||
|
string Usage)
|
||||||
|
{
|
||||||
|
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
|
||||||
|
internal bool HasUsage => Usage.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets this machine online, if it can be.
|
/// Gets this machine online, if it can be.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -857,6 +906,32 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <summary>The credential being edited, or null when creating.</summary>
|
/// <summary>The credential being edited, or null when creating.</summary>
|
||||||
private Guid? editingCredentialId;
|
private Guid? editingCredentialId;
|
||||||
|
|
||||||
|
// ---- Deleting ----
|
||||||
|
|
||||||
|
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
|
||||||
|
/// looking at two cards, and this application only ever has one selected item per screen to aim a
|
||||||
|
/// question at.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private DeletionRequest? pendingDeletion;
|
||||||
|
|
||||||
|
internal bool IsConfirmingDeletion => PendingDeletion is not null;
|
||||||
|
|
||||||
|
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own property because the markup cannot express <c>!IsEditing && !IsConfirmingDeletion</c>,
|
||||||
|
/// and because both halves are the same rule: the question about deleting a host takes the place of the
|
||||||
|
/// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
|
||||||
|
/// on screen.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsHostActions => !IsEditing && !IsConfirmingDeletion;
|
||||||
|
|
||||||
|
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||||
|
/// <inheritdoc cref="ShowsHostActions" />
|
||||||
|
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
||||||
|
|
||||||
// ---- Connecting ----
|
// ---- Connecting ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1068,7 +1143,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <returns>How many keys would not decrypt.</returns>
|
/// <returns>How many keys would not decrypt.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||||||
/// <see cref="DeleteKeyAsync" /> acts on, and a list that picked a row on every background sync would aim
|
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
|
||||||
/// that button at a key nobody chose.
|
/// that button at a key nobody chose.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||||||
@@ -1094,9 +1169,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <returns>How many credentials would not decrypt.</returns>
|
/// <returns>How many credentials would not decrypt.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
||||||
/// as the key list and matters more here. <see cref="DeleteCredentialAsync" /> acts on the selection, so a
|
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
||||||
/// list that fell back to its first row would put a one-click deletion of somebody's password behind a
|
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
||||||
/// button they never aimed.
|
/// password nobody chose.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1470,26 +1545,23 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes whatever the selected row is.</summary>
|
/// <summary>Asks about deleting whatever the selected row is.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
||||||
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
||||||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteSelectedItemAsync()
|
private void DeleteSelectedItem()
|
||||||
{
|
{
|
||||||
switch (SelectedVaultItem?.Kind)
|
switch (SelectedVaultItem?.Kind)
|
||||||
{
|
{
|
||||||
// Null rather than a token, and deliberately: a [RelayCommand] over a method whose only
|
|
||||||
// parameter is a CancellationToken generates ExecuteAsync(object? parameter) that ignores the
|
|
||||||
// argument and supplies a token from its own source. Passing one would read as plumbing.
|
|
||||||
case VaultItemKind.Key:
|
case VaultItemKind.Key:
|
||||||
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
|
DeleteKeyCommand.Execute(null);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VaultItemKind.Credential:
|
case VaultItemKind.Credential:
|
||||||
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
|
DeleteCredentialCommand.Execute(null);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1555,15 +1627,42 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected host.</summary>
|
/// <summary>Asks whether the selected host should go.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||||||
|
/// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
|
||||||
|
/// deleting a machine they are still working on should know that is what they have done.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteHostAsync(CancellationToken cancellationToken)
|
private void DeleteHost()
|
||||||
{
|
{
|
||||||
if (SelectedHost is not { } row)
|
if (SelectedHost is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Host,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the host '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The host and everything saved about it"),
|
||||||
|
row.IsConnected
|
||||||
|
? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
|
||||||
|
+ "nothing will reopen it afterwards."
|
||||||
|
: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
|
||||||
|
private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
// Gone between the question and the answer — a sync that pulled somebody else's deletion is the
|
||||||
|
// realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
|
||||||
|
Status = "That host is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1682,15 +1781,39 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected key.</summary>
|
/// <summary>Asks whether the selected key should go.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
|
||||||
|
/// why the question says so. What it does not say is that the key is gone from the machines it was
|
||||||
|
/// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
|
||||||
|
/// server is not something this application has ever written to.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
|
private void DeleteKey()
|
||||||
{
|
{
|
||||||
if (SelectedKey is not { } row)
|
if (SelectedKey is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Key,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the SSH key '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
|
||||||
|
+ " If this key is not on disk anywhere else, this is the only copy.",
|
||||||
|
HostsBoundTo(host => host.SshKeyId, row.EntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
||||||
|
private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
Status = "That key is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1807,15 +1930,32 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues a tombstone for the selected credential.</summary>
|
/// <summary>Asks whether the selected credential should go.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteCredentialAsync(CancellationToken cancellationToken)
|
private void DeleteCredential()
|
||||||
{
|
{
|
||||||
if (SelectedCredential is not { } row)
|
if (SelectedCredential is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Credential,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the password '{row.Label}'?",
|
||||||
|
HowFarADeletionGoes("The password and the account saved with it"),
|
||||||
|
HostsBoundTo(host => host.CredentialId, row.EntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
||||||
|
private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
Status = "That password is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
"Deleting…",
|
"Deleting…",
|
||||||
async () =>
|
async () =>
|
||||||
@@ -1831,6 +1971,92 @@ internal sealed partial class VaultViewModel(
|
|||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Carries out the deletion that was asked about.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a
|
||||||
|
/// second press during a slow round trip has nothing left to agree to.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (PendingDeletion is not { } request)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingDeletion = null;
|
||||||
|
|
||||||
|
switch (request.Target)
|
||||||
|
{
|
||||||
|
case DeletionTarget.Host:
|
||||||
|
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeletionTarget.Key:
|
||||||
|
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeletionTarget.Credential:
|
||||||
|
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelDelete() => PendingDeletion = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a deleted item goes, and how far.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
|
||||||
|
/// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
|
||||||
|
/// credential because it leaked should be told which of those two they have just done.
|
||||||
|
/// </remarks>
|
||||||
|
private string HowFarADeletionGoes(string what) => connection() is null
|
||||||
|
? $"{what} goes from this machine now, and from your other machines once this one is online again. "
|
||||||
|
+ "There is no undo."
|
||||||
|
: $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
|
||||||
|
+ "There is no undo.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Counted rather than warned about in general terms. The number is the difference between a sentence
|
||||||
|
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
|
||||||
|
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a
|
||||||
|
/// typed password — see <see cref="TryBuildAuthentication" />.
|
||||||
|
/// </remarks>
|
||||||
|
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId)
|
||||||
|
{
|
||||||
|
var bound = Hosts
|
||||||
|
.Where(row => binding(row.Host) == entityId)
|
||||||
|
.Select(row => row.Label)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (bound.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three names and a count past that, because this is read in a 244-pixel column and a vault with
|
||||||
|
// twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
|
||||||
|
var named = bound.Length <= 3
|
||||||
|
? string.Join(", ", bound)
|
||||||
|
: $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";
|
||||||
|
|
||||||
|
return bound.Length == 1
|
||||||
|
? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
|
||||||
|
+ "password."
|
||||||
|
: $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
|
||||||
|
+ "fall back to a typed password.";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Withdraws trust from the selected pin's endpoint.
|
/// Withdraws trust from the selected pin's endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2564,6 +2790,33 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||||
|
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||||
|
OnPropertyChanged(nameof(ShowsHostActions));
|
||||||
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the question away when the selection it was asked about has moved on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared by entity id rather than by row, and that is the whole point of the method. A reload
|
||||||
|
/// replaces every row object in the list, so a background pass a minute after the question would
|
||||||
|
/// otherwise take the card away from under somebody still reading it — while a click onto a different
|
||||||
|
/// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
|
||||||
|
/// nobody is looking at any more.
|
||||||
|
/// </remarks>
|
||||||
|
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
|
||||||
|
{
|
||||||
|
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
|
||||||
|
{
|
||||||
|
PendingDeletion = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2651,6 +2904,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||||||
OnPropertyChanged(nameof(SelectedItemIsPin));
|
OnPropertyChanged(nameof(SelectedItemIsPin));
|
||||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||||
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
|
|
||||||
|
// Both kinds this table can delete, because one selection covers both lists.
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||||
|
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||||
|
|
||||||
switch (value?.Kind)
|
switch (value?.Kind)
|
||||||
{
|
{
|
||||||
@@ -2701,8 +2959,34 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// flips back in every path that closes one, so this notification always observes the pair in a
|
/// flips back in every path that closes one, so this notification always observes the pair in a
|
||||||
/// consistent state.
|
/// consistent state.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
partial void OnIsEditingChanged(bool value) =>
|
partial void OnIsEditingChanged(bool value)
|
||||||
|
{
|
||||||
OnPropertyChanged(nameof(CanForgetHostKey));
|
OnPropertyChanged(nameof(CanForgetHostKey));
|
||||||
|
OnPropertyChanged(nameof(ShowsHostActions));
|
||||||
|
|
||||||
|
DisarmOnceAnEditorIsOpen(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||||
|
|
||||||
|
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the question away when an editor opens over the pane it was asked in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
|
||||||
|
/// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
|
||||||
|
/// rule for both, rather than a guard on the three commands that would have to be remembered by the
|
||||||
|
/// fourth.
|
||||||
|
/// </remarks>
|
||||||
|
private void DisarmOnceAnEditorIsOpen(bool opened)
|
||||||
|
{
|
||||||
|
if (opened)
|
||||||
|
{
|
||||||
|
PendingDeletion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
|
||||||
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question in front of deleting something in the vault.
|
||||||
|
|
||||||
|
One control used in two places — the host sidebar, where it takes the place of the row of buttons that
|
||||||
|
opened it, and the vault screen's detail pane, where it takes the place of EDIT and DELETE. The two
|
||||||
|
moments are different and what has to be said is not, which is why this is a shared control rather than
|
||||||
|
two blocks that would drift apart. The sign-out confirmation is the same arrangement, for the same
|
||||||
|
reason; see SignOutCard.
|
||||||
|
|
||||||
|
A bare StackPanel and not a card, because the two hosts frame it themselves: the sidebar puts it in the
|
||||||
|
strip along its bottom edge, and the vault screen in a column that scrolls.
|
||||||
|
|
||||||
|
Everything it says is something the view model can answer. The question names the item, the consequence
|
||||||
|
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
|
||||||
|
that actually authenticate with the thing about to go — see VaultViewModel.HostsBoundTo. A confirmation
|
||||||
|
that only asked "are you sure?" would be a click to train people out of.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
|
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Question}" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Consequence}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What else in this vault leans on it. In a box of its own because it is the line that changes the
|
||||||
|
answer: everything above is true of every deletion, and this is about the one being made.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="8,6"
|
||||||
|
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
|
||||||
|
<TextBlock Foreground="{StaticResource Info}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingDeletion.Usage}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The question in front of deleting a host, a key or a password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its data context is the <c>VaultViewModel</c>, in both of the places it is shown, so every binding in the
|
||||||
|
/// markup is a property of the vault. See <see cref="HostSidebar"/> and <see cref="VaultScreen"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
|
{
|
||||||
|
public ConfirmDeleteCard() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
||||||
x:DataType="vm:VaultViewModel">
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||||
IsVisible="{Binding !IsEditing}">
|
IsVisible="{Binding ShowsHostActions}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
||||||
@@ -183,6 +184,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
|
||||||
|
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
|
||||||
|
block below the first would push its own buttons off the window — the same reasoning that swaps the
|
||||||
|
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
|
||||||
|
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||||
|
IsVisible="{Binding IsConfirmingDeletion}">
|
||||||
|
<views:ConfirmDeleteCard />
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using DodoSSH.Client.App.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -11,7 +13,29 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostSidebar : UserControl
|
internal sealed partial class HostSidebar : UserControl
|
||||||
{
|
{
|
||||||
public HostSidebar() => InitializeComponent();
|
public HostSidebar()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||||
|
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
||||||
|
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
|
||||||
|
// password box beside it, and a host that asks for a password still needs it typed first.
|
||||||
|
HostList.DoubleTapped += OnHostActivated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||||
|
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
|
||||||
|
/// returning a task nothing observes.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is VaultViewModel vault)
|
||||||
|
{
|
||||||
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where the keyboard should land when the terminal hands it back.
|
/// Where the keyboard should land when the terminal hands it back.
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ==== The host ==== -->
|
<!-- ==== The host ==== -->
|
||||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
BorderThickness="0,0,0,1">
|
BorderThickness="0,0,0,1">
|
||||||
@@ -209,12 +209,40 @@
|
|||||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding IsConnected}" />
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
<!--
|
||||||
|
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
|
||||||
|
than the file, because a name is the half that does not identify anything.
|
||||||
|
|
||||||
|
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||||
|
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}"
|
||||||
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource Danger}"
|
||||||
|
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||||||
|
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@@ -249,14 +277,14 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
@@ -276,7 +304,7 @@
|
|||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding !HasRemoteEntries}">
|
IsVisible="{Binding !HasRemoteEntries}">
|
||||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||||
x:DataType="vm:VaultViewModel">
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
@@ -220,11 +221,23 @@
|
|||||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||||
IsVisible="{Binding SelectedItemIsEditable}">
|
IsVisible="{Binding ShowsItemActions}">
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||||
|
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||||
|
what is stored are all still on screen above it, which is most of what somebody checks before
|
||||||
|
answering. See ConfirmDeleteCard.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,14,0,0"
|
||||||
|
IsVisible="{Binding IsConfirmingDeletion}">
|
||||||
|
<views:ConfirmDeleteCard />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using DodoSSH.Client.App.ViewModels;
|
using DodoSSH.Client.App.ViewModels;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
@@ -223,6 +226,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
|
||||||
|
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
|
||||||
|
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
|
||||||
|
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
|
||||||
|
/// bottom edge here would leave the question unanswerable in either direction.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHostSidebarFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
vault.SelectedHost.IsConnected = true;
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
|
||||||
|
|
||||||
|
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
||||||
|
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
|
||||||
|
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
|
||||||
|
/// performs the gesture.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Proved through a connection that is refused before any network is involved. The host is left bound
|
||||||
|
/// to a key that has been deleted, which <c>TryBuildAuthentication</c> turns into a sentence on the
|
||||||
|
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
|
||||||
|
/// timing out to make it flaky.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DoubleClickingAHostConnectsToIt()
|
||||||
|
{
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.SelectedHost = null;
|
||||||
|
vault.Status = string.Empty;
|
||||||
|
|
||||||
|
await OnTheSidebarAsync((sidebar, window) =>
|
||||||
|
{
|
||||||
|
var row = sidebar.HostList.GetVisualDescendants()
|
||||||
|
.OfType<ListBoxItem>()
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var centre = row.TranslatePoint(
|
||||||
|
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
||||||
|
?? throw new InvalidOperationException("the row is not in this window's tree");
|
||||||
|
|
||||||
|
window.MouseDown(centre, MouseButton.Left);
|
||||||
|
window.MouseUp(centre, MouseButton.Left);
|
||||||
|
window.MouseDown(centre, MouseButton.Left);
|
||||||
|
window.MouseUp(centre, MouseButton.Left);
|
||||||
|
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||||
|
vault.Status.ShouldContain(
|
||||||
|
"not in this vault any more",
|
||||||
|
Case.Insensitive,
|
||||||
|
"the double-click has to reach the connect command");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The vault screen ----
|
// ---- The vault screen ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -283,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
||||||
|
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
||||||
|
/// application.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheVaultScreenFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
foreach (var host in vault.Hosts.Take(4).ToList())
|
||||||
|
{
|
||||||
|
vault.SelectedHost = host;
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
|
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.Section = VaultSection.Keys;
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
|
||||||
|
|
||||||
|
vault.DeleteSelectedItemCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage
|
||||||
|
.ShouldBeTrue("four bound hosts are what makes this the long shape");
|
||||||
|
|
||||||
|
await OnTheVaultAsync((screen, window) =>
|
||||||
|
{
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
|
|
||||||
|
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
|
||||||
|
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
|
||||||
|
// wrong data type are a logged message rather than an exception.
|
||||||
|
var card = screen.GetVisualDescendants().OfType<ConfirmDeleteCard>().ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
var said = string.Join(
|
||||||
|
" ",
|
||||||
|
card.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text));
|
||||||
|
|
||||||
|
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
|
||||||
|
said.ShouldContain("4 hosts authenticate with it");
|
||||||
|
said.ShouldContain("no undo");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
||||||
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
||||||
@@ -375,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||||
|
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||||
|
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||||
|
{
|
||||||
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||||
|
"2026-07-30",
|
||||||
|
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||||
|
IsDirectory: true);
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The chrome ----
|
// ---- The chrome ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddHostAsync(vault, "prod-db");
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
await DeleteSelectedHostAsync(vault);
|
||||||
|
|
||||||
vault.Hosts.ShouldBeEmpty();
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
|
||||||
@@ -739,6 +739,219 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.PendingChanges.ShouldBe(0);
|
vault.PendingChanges.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The question in front of a deletion ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A
|
||||||
|
/// card that appeared after the item had already gone would be a receipt, not a question.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
|
||||||
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
question.Question.ShouldContain("prod-db", Case.Insensitive);
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||||
|
vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces");
|
||||||
|
|
||||||
|
vault.Hosts.ShouldHaveSingleItem();
|
||||||
|
server.LiveRowCount.ShouldBe(1);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
vault.PendingDeletion.ShouldBeNull("the question goes when it is answered");
|
||||||
|
vault.ShowsHostActions.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellingADeletion_LeavesTheItemWhereItWas()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
// And the answer that would have deleted it has nothing left to act on.
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Credentials.ShouldHaveSingleItem();
|
||||||
|
server.LiveRowCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// What the question is for. A key that two hosts authenticate with is not the same deletion as one
|
||||||
|
/// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is
|
||||||
|
/// asserted from the connect path's side in
|
||||||
|
/// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
var keyId = vault.Keys[0].EntityId;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
await AddHostAsync(vault, "prod-web");
|
||||||
|
await BindKeyAsync(vault, Host(vault, "prod-db"), keyId);
|
||||||
|
await BindKeyAsync(vault, Host(vault, "prod-web"), keyId);
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
question.HasUsage.ShouldBeTrue();
|
||||||
|
question.Usage.ShouldContain("2 hosts");
|
||||||
|
question.Usage.ShouldContain("prod-db");
|
||||||
|
question.Usage.ShouldContain("prod-web");
|
||||||
|
|
||||||
|
// And the sentence above it says how far the deletion travels, which needs no host at all.
|
||||||
|
question.Consequence.ShouldContain("no undo", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared
|
||||||
|
/// every time would say nothing the second time.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "spare");
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The failure this guards against is a question answered about something else: arm the deletion, click
|
||||||
|
/// another row, press the button that is still on screen. The armed item is what the answer acts on, and
|
||||||
|
/// choosing a different one takes the question away rather than re-aiming it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChoosingSomethingElse_TakesTheQuestionAway()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
await AddCredentialAsync(vault, "staging deploy");
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.Section = VaultSection.Credentials;
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems[1];
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Credentials.Count.ShouldBe(2, "nothing was agreed to");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
|
||||||
|
/// any change of the selected <em>row</em> would let the pass that runs every minute take the card away
|
||||||
|
/// from somebody halfway through reading it. The entity id is what the rule compares.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
|
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
var armed = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await vault.SyncCommand.ExecuteAsync(null);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
vault.PendingDeletion.ShouldBe(armed);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Hosts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Opening an editor is the other way the pane the question is in stops being about the question: the
|
||||||
|
/// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over
|
||||||
|
/// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningAnEditor_TakesTheQuestionAway()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
|
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
vault.NewCredentialCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.IsEditingCredential.ShouldBeTrue();
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An answer to a question about something that has since gone — the realistic way being a pass that
|
||||||
|
/// pulled somebody else's deletion. The reload that brings that news normally moves the selection and
|
||||||
|
/// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement
|
||||||
|
/// from being a silent no-op under a card that has just been pressed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnsweringAboutSomethingAlreadyGone_SaysSo()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
vault.SelectedKey = vault.Keys[0];
|
||||||
|
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Underneath the question, as another machine's deletion would arrive.
|
||||||
|
await vault.Session.SshKeys.DeleteAsync(
|
||||||
|
vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Status.ShouldContain("no longer here");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
||||||
{
|
{
|
||||||
@@ -1005,7 +1218,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddKeyAsync(vault, "deploy");
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
|
|
||||||
vault.Keys.ShouldBeEmpty();
|
vault.Keys.ShouldBeEmpty();
|
||||||
server.LiveRowCount.ShouldBe(0);
|
server.LiveRowCount.ShouldBe(0);
|
||||||
@@ -1387,7 +1600,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
vault.Keys.ShouldBeEmpty();
|
vault.Keys.ShouldBeEmpty();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
@@ -1412,7 +1625,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
||||||
|
|
||||||
vault.SelectedKey = vault.Keys[0];
|
vault.SelectedKey = vault.Keys[0];
|
||||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
await DeleteSelectedKeyAsync(vault);
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
@@ -1541,7 +1754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddCredentialAsync(vault, "prod deploy");
|
await AddCredentialAsync(vault, "prod deploy");
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
|
|
||||||
vault.Credentials.ShouldBeEmpty();
|
vault.Credentials.ShouldBeEmpty();
|
||||||
server.LiveRowCount.ShouldBe(0);
|
server.LiveRowCount.ShouldBe(0);
|
||||||
@@ -1573,7 +1786,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
vault.SelectedCredential.ShouldBeNull();
|
vault.SelectedCredential.ShouldBeNull();
|
||||||
|
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
// Explicitly rather than through the helper: with nothing selected there is nothing to ask about,
|
||||||
|
// and the absence of a question is what proves the button found nothing to aim at.
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
vault.PendingDeletion.ShouldBeNull();
|
||||||
|
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.Credentials.ShouldHaveSingleItem();
|
vault.Credentials.ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
@@ -1594,7 +1812,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
// And a kind with nothing in it is left out rather than reported as zero.
|
// And a kind with nothing in it is left out rather than reported as zero.
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
await vault.LoadAsync(Token);
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
||||||
@@ -1725,7 +1943,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
vault.Credentials.ShouldBeEmpty();
|
vault.Credentials.ShouldBeEmpty();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
@@ -1747,7 +1965,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
||||||
|
|
||||||
vault.SelectedCredential = vault.Credentials[0];
|
vault.SelectedCredential = vault.Credentials[0];
|
||||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
await DeleteSelectedCredentialAsync(vault);
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
@@ -1900,7 +2118,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
vault.SelectedHost = vault.Hosts[0];
|
||||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
await DeleteSelectedHostAsync(vault);
|
||||||
|
|
||||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
||||||
"the pin outlives the host, and the list has to admit it");
|
"the pin outlives the host, and the list has to admit it");
|
||||||
@@ -2673,6 +2891,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
|
||||||
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||||
|
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||||
|
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
|
||||||
|
/// about something else go through these three helpers, so the two-step is spelled out in one place
|
||||||
|
/// rather than in ten.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteHostCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||||
|
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteKeyCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||||
|
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
vault.DeleteCredentialCommand.Execute(null);
|
||||||
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
||||||
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
||||||
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
||||||
|
|||||||
Reference in New Issue
Block a user