Give the application a settings area built from what really exists

This commit is contained in:
2026-08-08 14:17:19 +02:00
parent 422d5ca10e
commit c8507b44fe
42 changed files with 3810 additions and 1083 deletions
@@ -123,6 +123,9 @@ internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
/// <summary>What the row says under the name.</summary>
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
/// <summary>The single letter the settings page's card draws on this group's tile.</summary>
internal string Initial => Label.Length > 0 ? Label[..1].ToUpperInvariant() : "?";
}
/// <summary>An entry in the host editor's group picker.</summary>
@@ -4667,8 +4670,20 @@ internal sealed partial class VaultViewModel(
}
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasTagItems));
}
/// <summary>
/// Whether there is at least one tag to manage.
/// </summary>
/// <remarks>
/// v5c-2: the settings Tags page's empty state. Named apart from <c>HostRowViewModel.HasTags</c>, which
/// answers a different question — whether one host wears any — rather than reusing that name at this
/// level and relying on which <c>x:DataType</c> a binding happens to be inside to tell the two apart.
/// </remarks>
internal bool HasTagItems => Tags.Count > 0;
/// <summary>
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
/// authenticate.
@@ -4763,6 +4778,28 @@ internal sealed partial class VaultViewModel(
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
OnPropertyChanged(nameof(HasGroups));
OnPropertyChanged(nameof(UngroupedHostCount));
}
/// <summary>
/// How many hosts, across every shown vault, carry no group that still exists.
/// </summary>
/// <remarks>
/// v5c: the settings Groups page's "No group" footer row. The same dangling-reference reading
/// <see cref="FlattenIntoSections"/> gives the sidebar's own UNGROUPED heading — a host naming a group
/// this vault no longer has counts as ungrouped rather than vanishing — but counted over every shown
/// vault's hosts rather than over a find-box-filtered subset, because a settings page has no find box
/// and a count that shrank while somebody typed in one would be answering the wrong question.
/// </remarks>
internal int UngroupedHostCount
{
get
{
var known = Groups.Select(group => group.EntityId).ToHashSet();
return Hosts.Count(row =>
IsVaultShown(row.VaultId) && (row.Host.GroupId is not { } id || !known.Contains(id)));
}
}
/// <summary>
@@ -9881,6 +9918,35 @@ internal sealed partial class VaultViewModel(
Status = $"Renaming {row.Label}.";
}
/// <summary>
/// Opens the tag editor for a specific row, from the settings page's per-row edit icon.
/// </summary>
/// <remarks>
/// A thin wrapper around <see cref="EditTag"/> rather than a second implementation of what it does. The
/// keychain screen's own table drives <see cref="EditTag"/> off a <c>ListBox</c> selection; the settings
/// page draws one card per tag with no such selection to lean on, so this puts the row on
/// <see cref="SelectedTag"/> first and then asks the command that already knows how to open it — the
/// same trick <see cref="EditGroupFromHeading"/> plays for a group, except that command took the row as
/// an argument from the start and this one did not, because nothing needed it to until now.
/// </remarks>
/// <param name="row">The tag to edit.</param>
[RelayCommand]
private void EditTagRow(TagRowViewModel? row)
{
SelectedTag = row;
EditTagCommand.Execute(null);
}
/// <summary>Arms the delete confirmation for a specific row, from the settings page's per-row delete icon.</summary>
/// <inheritdoc cref="EditTagRow" path="/remarks" />
/// <param name="row">The tag to ask about deleting.</param>
[RelayCommand]
private void DeleteTagRow(TagRowViewModel? row)
{
SelectedTag = row;
DeleteTagCommand.Execute(null);
}
/// <summary>Abandons the tag editor.</summary>
[RelayCommand]
private void CancelTagEdit()
@@ -10341,6 +10407,35 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Puts the selected pin's fingerprint on the clipboard.
/// </summary>
/// <remarks>
/// The twin of <see cref="CopyPublicKeyAsync"/> and the opposite call about secrecy: a host key
/// fingerprint is not one. Operators publish theirs on purpose, and the whole workflow this screen exists
/// for is comparing a pinned one against what was published — which is a copy-and-paste somebody should
/// not have to retype by hand out of a box that refuses to trim it.
/// </remarks>
[RelayCommand]
private async Task CopyPinFingerprintAsync()
{
if (SelectedKnownHost is not { } row)
{
Status = "Choose a pinned key first.";
return;
}
if (copyToClipboard is null)
{
Status = "This machine has no clipboard.";
return;
}
await copyToClipboard(row.Fingerprint).ConfigureAwait(true);
Status = $"Copied the fingerprint for {row.Host}.";
}
/// <summary>
/// Opens a terminal on the selected host.
/// </summary>