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
@@ -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));