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
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
@@ -54,6 +55,16 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal string Address => host.Address;
/// <summary>What <c>HostName</c> said, on its own — the v5c table's own column, beside <see cref="User"/>
/// and <see cref="Port"/> rather than folded into <see cref="Address"/>.</summary>
internal string Hostname => host.Hostname;
/// <summary>What <c>User</c> said, or an em dash where the entry named none.</summary>
internal string User => host.Username is { Length: > 0 } user ? user : "—";
/// <summary>What <c>Port</c> said, defaulting to 22 the same way <see cref="ImportedHost"/> does.</summary>
internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
/// <summary>Whether a host with this address is already in the keychain.</summary>
internal bool AlreadyPresent { get; }
@@ -61,6 +72,23 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal bool HasBadge => AlreadyPresent;
/// <summary>
/// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually
/// carries — nothing this screen cannot back up. Skipped patterns (a wildcard <c>Host</c> block) never
/// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host
/// warnings, from <see cref="HasWarnings"/>, are the amber case instead — a flattened <c>ProxyJump</c> or
/// a dropped directive is exactly the kind of thing "quieter than the file" that <c>ImportScreen.axaml</c>'s
/// own remark says has to be told before it looks like data loss.
/// </summary>
internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
/// <summary>The warned case wins over "already here" — a warning is the more actionable of the two facts.</summary>
internal bool IsMeaningWarned => HasWarnings;
internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
/// <remarks>
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
@@ -138,7 +166,10 @@ internal sealed partial class ImportRowViewModel : ObservableObject
/// <see cref="KeyReport"/>.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
internal sealed partial class ImportViewModel(
VaultViewModel vault,
SshConfigLocator locator,
Action? onCancel = null) : ObservableObject
{
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
@@ -202,6 +233,66 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
/// <summary>Whether every row is ticked — what the v5c table's header tick-all box shows.</summary>
internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
/// <summary>
/// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
/// </summary>
/// <remarks>
/// Two real facts and nothing invented — <see cref="ConfigPath"/> and <see cref="HasScanned"/>. The
/// fuller narrative belongs to <see cref="Status"/>, which this does not replace: what happened on a scan
/// or an import is a sentence, not a fact this header line has room to state honestly in a handful of
/// words.
/// </remarks>
internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
/// <summary>
/// The key-material card's own always-visible sentence, ahead of the tick.
/// </summary>
/// <remarks>
/// The count is hosts naming a key file, not raw <c>IdentityFile</c> lines — a fact <see cref="Rows"/>
/// actually carries, where a literal line count would not survive a host that names more than one and is
/// only ever bound to the first. The rest of the sentence is <c>ImportViewModel</c>'s own long-standing
/// claim, restated in the design's words after checking it against <c>SshConfigLocator</c>: this type is
/// the only place in the application that reads a private key out of a directory nobody pointed at file
/// by file, and <see cref="ImportAsync"/> is the only place that ever calls
/// <see cref="SshConfigLocator.ReadIdentity"/> — never <see cref="ScanAsync"/> — so nothing is read until
/// IMPORT is pressed.
/// </remarks>
internal string KeyMaterialIntro
{
get
{
var count = Rows.Count(row => row.HasKeyFile);
var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath;
var noun = count == 1 ? "host names" : "hosts name";
return $"The scan found {count} {noun} a key file in {directory}. This is the only control in "
+ "DodoSSH that opens key material from a directory you did not point at file by file — "
+ "nothing is read until Import is pressed.";
}
}
/// <summary>The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.</summary>
/// <remarks>
/// Fixed rather than offered as a picker: the import goes through the same
/// <c>session.ActiveVaultId</c> every other bulk write does, and there is no per-import target choice to
/// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown.
/// </remarks>
internal string VaultName => vault.VaultName;
/// <summary>The v5c footer's own sentence: how many are ticked, out of how many, and where they land.</summary>
internal string SelectionSummary
{
get
{
var noun = Rows.Count == 1 ? "entry" : "entries";
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
}
}
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
@@ -389,6 +480,16 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal void NoteSelectionChanged() => RaiseListState();
/// <summary>The footer's own Cancel button: back to the Preferences page, nothing stored.</summary>
/// <remarks>
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the same reasoning
/// <c>VaultViewModel</c>'s own <c>copyToClipboard</c> is one: this type has no business knowing settings
/// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes
/// the button a no-op rather than a crash.
/// </remarks>
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
/// <remarks>
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
@@ -441,5 +542,11 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
OnPropertyChanged(nameof(HasKeyReport));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportLabel));
OnPropertyChanged(nameof(AllTicked));
OnPropertyChanged(nameof(KeyMaterialIntro));
OnPropertyChanged(nameof(SelectionSummary));
}
/// <remarks>The header's own status line is a function of <see cref="HasScanned"/> alone.</remarks>
partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
}
@@ -106,10 +106,19 @@ internal sealed class KnownHostRowViewModel(
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Action? onBack;
internal KnownHostsViewModel(VaultViewModel vault)
/// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
/// <param name="onBack">
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
/// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
/// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
/// directly makes the button a no-op rather than a crash.
/// </param>
internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
{
this.vault = vault;
this.onBack = onBack;
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
// copy of a trust decision is the one kind of staleness that matters here.
@@ -155,6 +164,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
internal bool HasPins => Shown.Any();
/// <summary>
/// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
/// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
/// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
/// whatever somebody last typed into the filter.
/// </summary>
internal int Count => Shown.Count();
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
@@ -218,6 +235,22 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
[RelayCommand]
private async Task CopyFingerprintAsync()
{
if (Selected is null)
{
return;
}
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
[RelayCommand]
private void Back() => onBack?.Invoke();
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
@@ -247,6 +280,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(Count));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
@@ -206,6 +206,54 @@ internal enum ShellSurface
Terminal = 1,
}
/// <summary>
/// Which page the settings mode is showing, while <see cref="MainWindowViewModel.ActiveSettingsPage"/> is
/// not null.
/// </summary>
/// <remarks>
/// <para>
/// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page
/// area with its own — see <c>SettingsView.axaml</c> and the settings-mode remark on
/// <see cref="MainWindowViewModel.ActiveSettingsPage"/>. This is a second, orthogonal notion of "where am I"
/// from <see cref="ShellScreen"/>, not a replacement for it: <see cref="Preferences"/> and <see cref="Vaults"/>
/// still set <see cref="MainWindowViewModel.Screen"/> to the matching <see cref="ShellScreen"/> member, so
/// every existing binding and test that asks "is the screen Preferences" keeps its answer. <see cref="General"/>,
/// <see cref="Account"/> and <see cref="Security"/> have no <see cref="ShellScreen"/> counterpart — nothing
/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
/// </para>
/// <para>
/// <b>v5c-2: Groups and Tags joined.</b> The design's rail lists them beside Security and Preferences; v5c-1
/// omitted both from <c>SettingsView.axaml</c> rather than building a placeholder for either, and this wave
/// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a
/// <see cref="ShellScreen"/> counterpart: managing groups and tags has never been its own screen before this,
/// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding
/// for either to keep in step with.
/// </para>
/// </remarks>
internal enum SettingsPage
{
/// <summary>Updates, and the refused items from the design's General page, as an essay.</summary>
General = 0,
/// <summary>The vaults themselves and the people in them — the existing <see cref="ShellScreen.Vaults"/> screen.</summary>
Vaults = 1,
/// <summary>The signed-in profile, the sign-in fact, and signing out of this machine.</summary>
Account = 2,
/// <summary>The end-to-end explainer, Windows Hello, and approved host keys.</summary>
Security = 3,
/// <summary>This machine's terminal and keychain settings — the existing <see cref="ShellScreen.Preferences"/> screen.</summary>
Preferences = 4,
/// <summary>Every group, and the hosts filed under each — the existing group commands, given their own page.</summary>
Groups = 5,
/// <summary>Every tag, and how many hosts wear each — the existing tag commands, given their own page.</summary>
Tags = 6,
}
/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
@@ -368,6 +416,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private bool disposed;
/// <summary>
/// Where <see cref="LeaveSettings"/> goes back to — captured once, on the turn settings mode is
/// entered, and not touched again until it is left.
/// </summary>
/// <remarks>
/// Not re-captured on every <see cref="EnterSettings"/> call, which is what makes switching pages inside
/// settings mode (Preferences, then Security, then Account) still come back to the one screen the user
/// was actually on beforehand rather than to whichever settings page they last visited.
/// </remarks>
private ShellScreen settingsReturnScreen;
/// <inheritdoc cref="settingsReturnScreen" />
private ShellSurface settingsReturnSurface;
/// <summary>
/// Establishes a connection to a server.
/// </summary>
@@ -660,6 +722,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? email;
/// <summary>
/// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
/// settings page's SIGN-IN row.
/// </summary>
/// <remarks>
/// v5c: <c>MeResponse.Issuer</c> was already being cached into <c>StoredUnlockMaterial.Issuer</c> by
/// <see cref="AccountProvisioner"/>, for no reader — nothing before this wave surfaced it. Set from the
/// same two places <see cref="AccountName"/> and <see cref="Email"/> are, in <see cref="AdoptIdentity"/>,
/// so the three can never drift out of step with which account is actually signed in.
/// </remarks>
[ObservableProperty]
private string? issuer;
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
/// <remarks>
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
@@ -700,10 +775,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
/// did not.
/// </remarks>
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
Issuer = issuer;
}
[ObservableProperty]
@@ -980,9 +1056,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
@@ -1186,10 +1259,175 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void ShowScreen(ShellScreen target)
{
// v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either
// of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant
// to land in settings mode rather than on the bare screen the design retired. Redirecting here,
// rather than at every caller, is what makes that true without hunting down every existing call.
if (target is ShellScreen.Preferences)
{
EnterSettings(SettingsPage.Preferences);
return;
}
if (target is ShellScreen.Vaults)
{
EnterSettings(SettingsPage.Vaults);
return;
}
// v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on
// Preferences, and what changes underneath it is the content column and the titlebar's own back
// label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport.
if (target is ShellScreen.Import)
{
OpenImport();
return;
}
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
// the way in — the caller named a destination, and that destination wins over "go back".
ActiveSettingsPage = null;
Screen = target;
Surface = ShellSurface.Page;
}
/// <summary>
/// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
/// see <c>SettingsView.axaml</c>. Not null exactly while that chrome, rather than the ordinary titlebar
/// and nav rail, is what <c>MainWindow.axaml</c> draws.
/// </summary>
/// <remarks>
/// A second notion of "where am I" from <see cref="Screen"/> rather than a replacement for it — see the
/// remark on <see cref="SettingsPage"/>. Two of its five members, <see cref="SettingsPage.Preferences"/>
/// and <see cref="SettingsPage.Vaults"/>, keep <see cref="Screen"/> in step with the matching
/// <see cref="ShellScreen"/> member so every binding and test written against that screen before this
/// mode existed keeps working; the other three have nothing to keep in step with.
/// </remarks>
[ObservableProperty]
private SettingsPage? activeSettingsPage;
/// <summary>Whether the settings chrome, rather than the ordinary one, is what the window is drawing.</summary>
internal bool IsSettingsMode => ActiveSettingsPage is not null;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;
/// <summary>
/// Whether the importer is showing over the Preferences page, inside settings mode.
/// </summary>
/// <remarks>
/// A flag layered on top of <see cref="ActiveSettingsPage"/> rather than a <see cref="SettingsPage"/>
/// member of its own — Import.dc.html draws <c>SettingsNav</c> lit on Preferences the whole time the
/// importer is up, which this makes true for free: <see cref="ActiveSettingsPage"/> never leaves
/// <see cref="SettingsPage.Preferences"/>, so <see cref="IsSettingsPreferencesPage"/> and the nav row it
/// drives stay exactly as they were. What moves is only the content column, via
/// <see cref="IsSettingsPreferencesContentShowing"/>, and the titlebar's own back label — see
/// <c>SettingsTitleBar.axaml</c>.
/// </remarks>
[ObservableProperty]
private bool isImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
/// content column shows.
/// </summary>
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
partial void OnActiveSettingsPageChanged(SettingsPage? value)
{
OnPropertyChanged(nameof(IsSettingsMode));
OnPropertyChanged(nameof(IsSettingsGeneralPage));
OnPropertyChanged(nameof(IsSettingsVaultsPage));
OnPropertyChanged(nameof(IsSettingsAccountPage));
OnPropertyChanged(nameof(IsSettingsSecurityPage));
OnPropertyChanged(nameof(IsSettingsPreferencesPage));
OnPropertyChanged(nameof(IsSettingsGroupsPage));
OnPropertyChanged(nameof(IsSettingsTagsPage));
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
partial void OnIsImportOpenChanged(bool value) =>
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
/// <remarks>
/// The return screen is captured only on the way in from outside settings mode — see
/// <see cref="settingsReturnScreen"/> — so switching between settings pages, which calls this
/// repeatedly, cannot overwrite it with another settings page.
/// <para>
/// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is
/// a request for that page, not for whatever was drawn over it the last time settings mode was up.
/// </para>
/// </remarks>
[RelayCommand]
private void EnterSettings(SettingsPage page)
{
if (ActiveSettingsPage is null)
{
settingsReturnScreen = Screen;
settingsReturnSurface = Surface;
}
ActiveSettingsPage = page;
IsImportOpen = false;
Screen = page switch
{
SettingsPage.Preferences => ShellScreen.Preferences,
SettingsPage.Vaults => ShellScreen.Vaults,
_ => Screen,
};
Surface = ShellSurface.Page;
}
/// <summary>
/// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.Import"/> for every other caller.
/// </summary>
private void OpenImport()
{
EnterSettings(SettingsPage.Preferences);
IsImportOpen = true;
}
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
[RelayCommand]
private void CloseImport() => IsImportOpen = false;
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
[RelayCommand]
private void LeaveSettings()
{
if (ActiveSettingsPage is null)
{
return;
}
ActiveSettingsPage = null;
IsImportOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
/// <summary>Switches to the terminal surface.</summary>
/// <remarks>
/// <para>
@@ -1911,7 +2149,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
@@ -1972,7 +2210,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
@@ -2659,21 +2897,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
/// <summary>
/// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever
/// the window is showing.
/// </summary>
/// <remarks>
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c>
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// inline on the Account settings page while the vault is unlocked — see <c>SettingsAccountPage.axaml</c>
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is
/// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// <para>
/// v5c: went to <c>ShellScreen.Preferences</c> before this wave, because that bare screen was the only
/// place the confirmation card could be seen. It moved to the Account settings page with the card — see
/// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row
/// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home.
/// </para>
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
ShowScreen(ShellScreen.Preferences);
EnterSettings(SettingsPage.Account);
SignOut();
}
@@ -2744,6 +2991,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
AccountName = null;
Email = null;
Issuer = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
@@ -3017,8 +3265,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
// v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below
// — KnownHostsViewModel has no business knowing ShellScreen exists.
KnownHostsScreen = newValue is null
? null
: new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain));
// v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page
// beneath it without ImportViewModel knowing anything about settings mode — the same reasoning
// VaultViewModel's copyToClipboard delegate is built on.
ImportScreen = newValue is null
? null
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
@@ -4086,7 +4345,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsVaultsScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsMoreScreen));
@@ -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>
@@ -107,6 +107,9 @@ internal sealed record VaultRowViewModel(
};
internal bool HasState => State.Length > 0;
/// <summary>The single letter the settings page's card draws on this vault's tile.</summary>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
}
/// <summary>One member of a vault, as a row in the members table.</summary>
@@ -153,6 +156,32 @@ internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool Is
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
/// <summary>
/// The two letters the members panel draws on this row's avatar.
/// </summary>
/// <remarks>
/// The same rule <c>HostRowViewModel.Monogram</c> draws a card's monogram by — the first letters of the
/// first two words in the name, or the first two characters where it is one word — except uppercase,
/// which is how the design draws a person's initials rather than a host's.
/// </remarks>
internal string Initials
{
get
{
var words = Name.Split(
InitialsWordSeparators,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var letters = words.Length >= 2
? string.Concat(words[0][0], words[1][0])
: Name.Length >= 2 ? Name[..2] : Name;
return letters.ToUpperInvariant();
}
}
private static readonly char[] InitialsWordSeparators = [' ', '-', '_', '.'];
/// <summary>Whether this member's role can be changed at all.</summary>
/// <remarks>
/// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
@@ -312,6 +341,33 @@ internal sealed partial class VaultsViewModel(
[ObservableProperty]
private bool isBusy;
/// <summary>
/// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
/// </summary>
/// <remarks>
/// Desktop-only presentation state, not a fact about any vault: it says which rectangle is on screen and
/// nothing else. Selecting a vault is what actually reads its members, in
/// <see cref="OnSelectedVaultChanged"/>; opening this panel over one already selected re-reads nothing.
/// </remarks>
[ObservableProperty]
private bool isMembersPanelOpen;
/// <summary>Opens the members panel, selecting the vault it is about first if it is not selected already.</summary>
[RelayCommand]
private void OpenMembersPanel(VaultRowViewModel? vault)
{
if (vault is not null && !ReferenceEquals(vault, SelectedVault))
{
SelectedVault = vault;
}
IsMembersPanelOpen = true;
}
/// <summary>Closes the members panel.</summary>
[RelayCommand]
private void CloseMembersPanel() => IsMembersPanelOpen = false;
// ---- Creating a vault ----
[ObservableProperty]
@@ -824,6 +880,38 @@ internal sealed partial class VaultsViewModel(
Status = string.Empty;
}
/// <summary>Opens the rename form for a specific vault, from the settings page's per-card edit icon.</summary>
/// <remarks>
/// A thin wrapper around <see cref="RenameVault"/> rather than a second implementation: the settings
/// page draws one card per vault with no list selection to lean on the way this screen used to have, so
/// this selects the row first and then asks the command that already knows how to open the form.
/// </remarks>
/// <param name="vault">The vault to rename.</param>
[RelayCommand]
private void RenameVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
RenameVaultCommand.Execute(null);
}
/// <summary>Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.</summary>
/// <inheritdoc cref="RenameVaultRow" path="/remarks" />
/// <param name="vault">The vault to ask about deleting.</param>
[RelayCommand]
private void DeleteVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
DeleteVaultCommand.Execute(null);
}
/// <summary>
/// Saves the renamed vault.
/// </summary>
@@ -1538,6 +1626,13 @@ internal sealed partial class VaultsViewModel(
PendingAction = null;
IsRenamingVault = false;
// The members panel is drawn over one vault's people; a selection that clears altogether — the
// vault it was showing got deleted, or the list emptied — leaves nothing for it to be about.
if (value is null)
{
IsMembersPanelOpen = false;
}
if (isReselecting)
{
return;