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