Public Access
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
|
||||
`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
|
||||
admin console is at `http://localhost:18080` (`admin` / `admin`).
|
||||
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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.
|
||||
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 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>
|
||||
/// 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));
|
||||
|
||||
|
||||
@@ -422,6 +422,55 @@ internal sealed record VaultItemRowViewModel(
|
||||
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>
|
||||
/// Gets this machine online, if it can be.
|
||||
/// </summary>
|
||||
@@ -857,6 +906,32 @@ internal sealed partial class VaultViewModel(
|
||||
/// <summary>The credential being edited, or null when creating.</summary>
|
||||
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 ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -1068,7 +1143,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// <returns>How many keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||||
@@ -1094,9 +1169,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// <returns>How many credentials would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// list that fell back to its first row would put a one-click deletion of somebody's password behind a
|
||||
/// button they never aimed.
|
||||
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
||||
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
||||
/// password nobody chose.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1475,26 +1550,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>
|
||||
/// 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
|
||||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteSelectedItemAsync()
|
||||
private void DeleteSelectedItem()
|
||||
{
|
||||
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:
|
||||
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
DeleteKeyCommand.Execute(null);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Credential:
|
||||
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
DeleteCredentialCommand.Execute(null);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -1560,15 +1632,42 @@ internal sealed partial class VaultViewModel(
|
||||
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]
|
||||
private async Task DeleteHostAsync(CancellationToken cancellationToken)
|
||||
private void DeleteHost()
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
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(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -1687,15 +1786,39 @@ internal sealed partial class VaultViewModel(
|
||||
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]
|
||||
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
|
||||
private void DeleteKey()
|
||||
{
|
||||
if (SelectedKey is not { } row)
|
||||
{
|
||||
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(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -1812,15 +1935,32 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the selected credential.</summary>
|
||||
/// <summary>Asks whether the selected credential should go.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteCredentialAsync(CancellationToken cancellationToken)
|
||||
private void DeleteCredential()
|
||||
{
|
||||
if (SelectedCredential is not { } row)
|
||||
{
|
||||
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(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -1836,6 +1976,92 @@ internal sealed partial class VaultViewModel(
|
||||
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>
|
||||
/// Withdraws trust from the selected pin's endpoint.
|
||||
/// </summary>
|
||||
@@ -2577,6 +2803,33 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
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>
|
||||
@@ -2664,6 +2917,11 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||||
OnPropertyChanged(nameof(SelectedItemIsPin));
|
||||
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)
|
||||
{
|
||||
@@ -2714,8 +2972,34 @@ internal sealed partial class VaultViewModel(
|
||||
/// flips back in every path that closes one, so this notification always observes the pair in a
|
||||
/// consistent state.
|
||||
/// </remarks>
|
||||
partial void OnIsEditingChanged(bool value) =>
|
||||
partial void OnIsEditingChanged(bool value)
|
||||
{
|
||||
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) =>
|
||||
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"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
@@ -175,7 +176,7 @@
|
||||
</Border>
|
||||
|
||||
<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">
|
||||
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
||||
@@ -183,6 +184,19 @@
|
||||
</StackPanel>
|
||||
</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>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
@@ -11,7 +13,29 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// </remarks>
|
||||
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>
|
||||
/// Where the keyboard should land when the terminal hands it back.
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- ==== 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}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -209,12 +209,40 @@
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</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.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -249,14 +277,14 @@
|
||||
</StackPanel>
|
||||
</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="2" Classes="label" Text="SIZE" 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" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
@@ -276,7 +304,7 @@
|
||||
</ListBox.ItemTemplate>
|
||||
</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"
|
||||
IsVisible="{Binding !HasRemoteEntries}">
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||
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." />
|
||||
|
||||
<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="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</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 appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
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 ----
|
||||
|
||||
[Fact]
|
||||
@@ -283,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
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>
|
||||
/// 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
|
||||
@@ -375,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
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 ----
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -729,7 +729,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedHostAsync(vault);
|
||||
|
||||
vault.Hosts.ShouldBeEmpty();
|
||||
|
||||
@@ -739,6 +739,219 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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]
|
||||
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
||||
{
|
||||
@@ -1005,7 +1218,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
|
||||
vault.Keys.ShouldBeEmpty();
|
||||
server.LiveRowCount.ShouldBe(0);
|
||||
@@ -1387,7 +1600,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
vault.Keys.ShouldBeEmpty();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
@@ -1412,7 +1625,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
@@ -1541,7 +1754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddCredentialAsync(vault, "prod deploy");
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
server.LiveRowCount.ShouldBe(0);
|
||||
@@ -1573,7 +1786,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1594,7 +1812,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// And a kind with nothing in it is left out rather than reported as zero.
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
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);
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
@@ -1747,7 +1965,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
@@ -1900,7 +2118,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedHostAsync(vault);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
||||
"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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
||||
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
||||
|
||||
Reference in New Issue
Block a user