using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// One pinned host key, as a row in the list.
///
///
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
/// leaves its pin, and so does changing a host's address. Both are correct as trust decisions: the
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
/// What was wrong was that nothing ever showed them.
///
///
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
/// of pinning one is to compare it with what they published.
///
///
internal sealed class KnownHostRowViewModel(
VaultItem pin,
bool isDialledByAHost,
Guid vaultId,
string vaultName)
{
/// Which vault this pin lives in. See .
internal Guid VaultId => vaultId;
/// The vault's display name.
internal string VaultName => vaultName;
internal Guid EntityId => pin.EntityId;
internal KnownHostSecret Pin => pin.Secret;
internal string Host => pin.Secret.Host;
internal int Port => pin.Secret.Port;
internal string Algorithm => pin.Secret.Algorithm;
/// The endpoint and algorithm, which is what a pin actually identifies.
internal string Label => pin.Secret.Label;
/// The fingerprint, in full.
///
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
/// this whole mechanism exists to replace.
///
internal string Fingerprint => pin.Secret.Fingerprint;
///
/// Whether any host in this vault actually dials the endpoint this pin is for.
///
///
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
/// worth deleting on the user's behalf.
///
internal bool IsDialledByAHost { get; } = isDialledByAHost;
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
internal string Badge => IsDialledByAHost
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
: "no host uses this";
///
/// When this pin was approved, as far as anything here can tell.
///
///
/// Derived from the entity id, which this client mints with — see
/// . No vault item carries a timestamp, so the alternative was no column at
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
/// renders as a dash rather than as a guess.
///
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "—";
}
///
/// The host keys this keychain has approved, and how to withdraw one.
///
///
///
/// A wrapper over the vault rather than a view model of its own. Everything about a pin — reading
/// them, forgetting one, pushing the change — already lives on , wired into its
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
/// it produces, neither of which the vault has any use for.
///
///
/// The filter matches fingerprints, deliberately. The workflow this screen exists for is "the
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
/// answer a question nobody is asking.
///
///
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Action? onBack;
/// Where the pins, the reload and the withdrawal all actually live.
///
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
/// MainWindowViewModel, on the same reasoning ImportViewModel's own onCancel is one:
/// this type has no business knowing ShellScreen exists. Null in a layout test that builds this
/// directly makes the button a no-op rather than a crash.
///
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.
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
Rebuild();
}
/// The pins this filter admits, in the order the vault produced them.
///
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
/// — host, then port, then algorithm — is the one worth keeping.
///
internal ObservableCollection VisiblePins { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
/// The row the list has selected, mirrored onto the vault so its command can act on it.
///
/// Pushed down rather than duplicated: ForgetPinCommand reads VaultViewModel.SelectedKnownHost
/// and there is no reason for it to learn about this screen.
///
[ObservableProperty]
private KnownHostRowViewModel? selected;
///
/// The pins from vaults this machine is showing, before the filter box narrows them.
///
///
/// Every count and every sentence on this screen is taken from here rather than from
/// vault.KnownHostPins, so none of them can describe a pin the list is not drawing — a summary
/// saying "3 that no host dials" over two rows would send somebody looking for a third.
///
/// The vault's own list stays whole and this is a projection of it, which is the rule stated on
/// VaultViewModel.IsVaultShown: the trust the SSH handshake consults is read straight out of
/// VaultKnownHostStore and has never come through either list.
///
///
private IEnumerable Shown =>
vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));
internal bool HasPins => Shown.Any();
///
/// 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 reads off
/// rather than : it is a fact about the list, not about
/// whatever somebody last typed into the filter.
///
internal int Count => Shown.Count();
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
/// What the whole list amounts to, in one line.
///
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
/// might want to act on, and counting them is cheaper than reading a badge column.
///
internal string Summary
{
get
{
var total = Shown.Count();
if (total == 0)
{
return string.Empty;
}
var unused = Shown.Count(pin => !pin.IsDialledByAHost);
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
return unused == 0
? pins
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
}
}
///
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
/// would be a screen telling somebody they have never approved a host key while the keys they approved
/// sit in a vault they switched off in a menu.
///
internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
{
(true, _) => "No approved host key matches that.",
(false, > 0) =>
"Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
+ "the tab strip to switch one back on.",
_ =>
"Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
+ "to check — approving it puts it here.",
};
/// Withdraws trust in the selected pin.
///
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
/// server are the other ones.
///
[RelayCommand]
private async Task ForgetSelectedAsync()
{
if (Selected is null)
{
return;
}
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// Puts the selected pin's fingerprint on the clipboard. Forwarded, like .
[RelayCommand]
private async Task CopyFingerprintAsync()
{
if (Selected is null)
{
return;
}
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// The header's own back arrow: to the Keychain screen this list was pulled out of.
[RelayCommand]
private void Back() => onBack?.Invoke();
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(KnownHostRowViewModel? value)
{
vault.SelectedKnownHost = value;
OnPropertyChanged(nameof(HasSelection));
}
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
VisiblePins.Clear();
foreach (var pin in Shown.Where(Matches))
{
VisiblePins.Add(pin);
}
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(Count));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
}
private bool Matches(KnownHostRowViewModel pin)
{
if (string.IsNullOrWhiteSpace(Filter))
{
return true;
}
var needle = Filter.Trim();
return Contains(pin.Host, needle)
|| Contains(pin.Algorithm, needle)
|| Contains(pin.Fingerprint, needle)
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
}
private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}