Public Access
This commit is contained in:
@@ -174,6 +174,40 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the host that has been asked about and not yet agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
|
||||
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
|
||||
/// has no wastebasket to put them in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It carries the full path rather than only the name, because the name is the half that does not identify
|
||||
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
|
||||
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">What the row was called.</param>
|
||||
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
|
||||
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
|
||||
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
|
||||
{
|
||||
/// <summary>The question, naming the kind because the two behave differently.</summary>
|
||||
internal string Question => IsDirectory
|
||||
? $"Delete the directory '{Name}' on the host?"
|
||||
: $"Delete '{Name}' on the host?";
|
||||
|
||||
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
|
||||
internal string Consequence => IsDirectory
|
||||
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
|
||||
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
|
||||
+ "copy and there is no undo."
|
||||
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
|
||||
+ "touched, and there is no undo.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
@@ -284,6 +318,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
|
||||
[ObservableProperty]
|
||||
private RemoteDeletionRequest? pendingRemoteDeletion;
|
||||
|
||||
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||
|
||||
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||
/// <remarks>
|
||||
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
||||
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
||||
/// move UP and REFRESH out from under the pointer.
|
||||
/// </remarks>
|
||||
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -672,15 +720,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// Asks whether the chosen remote file, or empty directory, should go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
||||
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
||||
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
||||
/// copy of.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
private void DeleteRemote()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
@@ -688,18 +737,44 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes what was agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
|
||||
/// which is what makes the question a promise: nothing between asking and answering can point it
|
||||
/// somewhere else.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingRemoteDeletion is not { } request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
$"Deleting {request.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
Status = $"Deleted {request.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -919,11 +994,28 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
/// <remarks>
|
||||
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
|
||||
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
|
||||
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
|
||||
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
|
||||
/// </remarks>
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
}
|
||||
|
||||
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user