Public Access
Give the application a settings area built from what really exists
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user