Give the window its v5b chrome and each session surface its own shell

This commit is contained in:
2026-08-08 00:49:59 +02:00
parent 1b76c51fbb
commit 43c939b697
30 changed files with 4433 additions and 1772 deletions
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
internal string Label => key.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
internal string Description => key.Secret switch
{
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
internal string Label => credential.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: the account, never the password.</summary>
/// <remarks>
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
@@ -1101,8 +1119,66 @@ internal sealed record VaultItemRowViewModel(
{
/// <summary>Whether this row has anything to say about its sync state.</summary>
internal bool HasBadge => Badge.Length > 0;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <remarks>
/// Additive: every other kind of row in this application already carries this fact (see
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
/// nothing does or the kind has no such fact.
/// </summary>
/// <remarks>
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
/// showing a region or an endpoint under a column that promises "who uses this".
/// </remarks>
internal string UsedBySummary { get; init; } = string.Empty;
/// <summary>Whether this row has anything to say about who uses it.</summary>
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
/// <summary>
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
/// </summary>
/// <remarks>
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
/// carries no algorithm field (see docs/design-import-gaps.md).
/// </remarks>
internal string IconGlyph => Kind switch
{
VaultItemKind.Key => "",
VaultItemKind.Credential => "",
VaultItemKind.Tag => "",
VaultItemKind.ObjectStore => "",
_ => string.Empty,
};
}
/// <summary>
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
/// </summary>
/// <param name="Label">The host's name.</param>
/// <param name="IsConnected">
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
/// </param>
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
internal enum DeletionTarget
{
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
/// <summary>
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
/// </summary>
/// <remarks>
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
/// that does nothing, on the same "narrows the list and nothing else" reasoning
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
/// so what matches is never a surprise to whoever typed it.
/// </remarks>
[ObservableProperty]
private string itemFilter = string.Empty;
/// <summary>
/// The selected row of the vault table.
/// </summary>
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
/// </summary>
/// <remarks>
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
/// no binding concept to scan at all.
/// </remarks>
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
{
0 => string.Empty,
1 => "in use · 1 host",
var n => $"in use · {n} hosts",
};
/// <summary>Whether the "in use" chip has anything to say.</summary>
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
/// <summary>Whether the selected row is one with an editor behind it.</summary>
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<SshKeyRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.SshKeys
.ListAsync(vault.VaultId, cancellationToken)
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Keys.Clear();
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<CredentialRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.Credentials
.ListAsync(vault.VaultId, cancellationToken)
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Credentials.Clear();
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
"TAG",
tag.Description,
tag.Badge,
tag.HasUnsyncedChanges));
tag.HasUnsyncedChanges)
{
// A tag's own "used by" is the host count it already carries — the same fact
// Description prints, so the two never disagree.
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
});
}
}
@@ -12047,6 +12180,61 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
/// </summary>
/// <remarks>
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
/// </remarks>
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
{
var names = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind && binding.EntityId == entityId)
.Select(row => row.Label)
.ToList();
return names.Count switch
{
0 => string.Empty,
1 => names[0],
2 => $"{names[0]}, {names[1]}",
_ => $"{names[0]} +{names.Count - 1}",
};
}
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
/// the filter has no opinion about which kind a row is.
/// </remarks>
private void ApplyItemFilter()
{
var needle = ItemFilter.Trim();
if (needle.Length == 0)
{
return;
}
for (var i = VaultItems.Count - 1; i >= 0; i--)
{
var row = VaultItems[i];
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
{
VaultItems.RemoveAt(i);
}
}
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
"SSH KEY",
key.Description,
key.Badge,
key.HasUnsyncedChanges));
key.HasUnsyncedChanges)
{
VaultBadge = key.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
});
}
}
@@ -12093,12 +12285,17 @@ internal sealed partial class VaultViewModel(
"PASSWORD",
credential.Description,
credential.Badge,
credential.HasUnsyncedChanges));
credential.HasUnsyncedChanges)
{
VaultBadge = credential.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
});
}
}
AddTagRows();
AddBucketRows();
ApplyItemFilter();
// The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedDetailHeading));
OnPropertyChanged(nameof(ShowsItemActions));
RebuildSelectedItemUsage(value);
// Every kind this table can delete, because one selection covers all of their lists.
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
@@ -12168,6 +12367,39 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
/// </summary>
/// <remarks>
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
/// </remarks>
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
{
SelectedItemUsedByHosts.Clear();
ResolvedBindingKind? kind = value?.Kind switch
{
VaultItemKind.Key => ResolvedBindingKind.SshKey,
VaultItemKind.Credential => ResolvedBindingKind.Credential,
_ => null,
};
if (kind is { } resolvedKind && value is not null)
{
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
{
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
}
}
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
OnPropertyChanged(nameof(SelectedItemInUseSummary));
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
}
/// <remarks>
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
partial void OnUnreadableItemsChanged(int value)
{
OnPropertyChanged(nameof(HasUnreadableItems));