Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it

This commit is contained in:
2026-08-07 15:26:00 +02:00
parent bea0279937
commit 06f9dcfc27
5 changed files with 1095 additions and 554 deletions
@@ -57,6 +57,21 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
/// <summary>What the desktop board's per-section toggle says beside the chevron.</summary>
internal string CollapseLabel => IsExpanded ? "Collapse" : "Expand";
/// <summary>
/// Whether this is the first heading the desktop's flat board drew.
/// </summary>
/// <remarks>
/// The mock draws Collapse all / Expand all on the first heading row's right edge rather than as a
/// control of its own above the board, and a virtualised list has no clean way to ask a container "are
/// you the first realized one" from inside its own template — so the row carries the answer instead,
/// stamped on once by <see cref="VaultViewModel.RebuildHostSections"/>. Always false on the phone's
/// headings, which draw no such control, and on every heading but the first on the desktop's own board.
/// </remarks>
internal bool IsFirstBoardSection { get; init; }
}
/// <summary>One group as it came out of a vault, with the vault it came out of.</summary>
@@ -136,6 +151,49 @@ internal sealed record GroupChoice(Guid? EntityId, string Label)
internal static GroupChoice None { get; } = new(null, "No group");
}
/// <summary>One section of the desktop's flat host board: a heading, and the cards under it.</summary>
/// <remarks>
/// <para>
/// Regrouped out of the same flat rows the phone's <see cref="VaultViewModel.SidebarRows"/> draws one after
/// another — see <see cref="VaultViewModel.FlattenIntoSections"/> — because a section's own
/// <c>WrapPanel</c> of cards needs its members as a list rather than as headings mixed into one stream. The
/// alternative was one <c>ListBox</c> for the whole board with a heading item pretending to be as wide as
/// the row it sits on so the wrap panel breaks a line for it; that is a real technique and it is a fragile
/// one, and a second, per-section <c>ListBox</c> is not. See the note on <c>HostsScreen.axaml</c> for why
/// this shape was chosen over the phone's single flat list.
/// </para>
/// <para>
/// <see cref="Header"/> is null for exactly one case: a keychain with no groups at all draws one section
/// with no heading, which is what makes the feature invisible until it is used — see
/// <see cref="VaultViewModel.RebuildHostSections"/>.
/// </para>
/// </remarks>
internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnlyList<HostRowViewModel> hosts)
{
internal SidebarGroupHeader? Header => header;
internal bool HasHeader => header is not null;
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
}
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
/// <param name="GroupId">The group this entry narrows the board to.</param>
/// <param name="Label">What to show — vault-qualified where the session holds more than one.</param>
/// <param name="IsChecked">Whether this group currently narrows the board.</param>
/// <remarks>
/// Rebuilt whenever the checked set changes rather than mutated, on the same reasoning
/// <see cref="TagChoice"/> gives: a chip is a value, and equality is contents.
/// </remarks>
internal sealed record GroupFilterChoice(Guid GroupId, string Label, bool IsChecked);
/// <summary>An entry in the toolbar's Tag ▾ filter flyout.</summary>
/// <param name="TagId">The tag this entry narrows the board to.</param>
/// <param name="Label">What to show.</param>
/// <param name="IsChecked">Whether this tag currently narrows the board.</param>
/// <remarks>A host passes the filter by wearing <em>any</em> checked tag, not all of them.</remarks>
internal sealed record TagFilterChoice(Guid TagId, string Label, bool IsChecked);
/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
@@ -488,6 +546,84 @@ internal sealed partial class HostRowViewModel(
/// </remarks>
[ObservableProperty]
private bool isChosen;
/// <summary>How many folders are pinned on this host.</summary>
/// <remarks>
/// Reads <see cref="Host"/> rather than being cached, so it needs no refreshing of its own: the row is
/// replaced wholesale whenever the host changes, exactly as <see cref="Summary"/> is, and both read the
/// same decrypted secret this constructor was handed.
/// </remarks>
internal int PinCount => host.Secret.PinnedPaths.Count;
/// <summary>Whether the card draws a pin-count badge at all.</summary>
internal bool HasPins => PinCount > 0;
/// <summary>
/// The two letters a card's monogram avatar draws.
/// </summary>
/// <remarks>
/// The first letters of the first two words in the label, or the first two characters where the label is
/// one word — the mock's own rule. Lowercased because that is how the design draws every monogram, not
/// because a name typed in capitals means anything different from one that was not.
/// </remarks>
internal string Monogram
{
get
{
var words = Label.Split(
MonogramWordSeparators,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var letters = words.Length >= 2
? string.Concat(words[0][0], words[1][0])
: Label.Length >= 2 ? Label[..2] : Label;
return letters.ToLowerInvariant();
}
}
private static readonly char[] MonogramWordSeparators = [' ', '-', '_', '.'];
/// <summary>
/// Which of the four monogram hues this card's avatar is painted in.
/// </summary>
/// <remarks>
/// A stable hash of the label, deliberately not <see cref="string.GetHashCode()"/> — .NET randomises
/// that per process as a defence against hash-flooding, so the same host would draw a different colour
/// every time the application started. FNV-1a costs nothing to hold constant across runs, which is the
/// one property a monogram's colour needs: the same machine has to look like the same machine tomorrow.
/// </remarks>
internal string MonogramHue => MonogramHues[StableHash(Label) % (uint)MonogramHues.Length];
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram, in a fixed order.</summary>
private static readonly string[] MonogramHues = ["violet", "green", "amber", "gray"];
private static uint StableHash(string value)
{
unchecked
{
var hash = 2166136261u;
foreach (var character in value)
{
hash = (hash ^ character) * 16777619u;
}
return hash;
}
}
/// <summary>
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
/// </summary>
/// <remarks>
/// <b>Not computed here.</b> A later wave fills this from the synced connection log on the hosts
/// screen's activation and on <c>SessionEnded</c>, and restrings it on a tick while the screen is
/// visible. This row carries only the string, the way <see cref="IsConnected"/> carries a fact the vault
/// does not own either.
/// </remarks>
[ObservableProperty]
private string lastConnectedText = string.Empty;
}
/// <summary>What a host can authenticate with.</summary>
@@ -1248,8 +1384,24 @@ internal sealed partial class VaultViewModel(
private Dictionary<Guid, TagSecret> tagsById = [];
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
/// <remarks>
/// Shared by <see cref="SidebarRows"/> and <see cref="HostSections"/> deliberately: it is a fact about
/// which shelves are open, not about which head is asking, and the two heads never run in the same
/// process to disagree about it.
/// </remarks>
private readonly HashSet<Guid> collapsedGroups = [];
/// <summary>The groups the toolbar's Group ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
/// <remarks>Empty means every group, which is what "All groups" resets it to.</remarks>
private readonly HashSet<Guid> checkedGroupFilterIds = [];
/// <summary>The tags the toolbar's Tag ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
/// <remarks>
/// A host passes by wearing <em>any</em> of these, not all of them — the same semantics
/// <see cref="TagChoice"/>'s picker uses for what a host wears, just read the other way round.
/// </remarks>
private readonly HashSet<Guid> checkedTagFilterIds = [];
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
@@ -1392,6 +1544,94 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
/// <summary>
/// The desktop's own flattening of <see cref="Hosts"/>: one section per group in label order, "No
/// group" first, narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
/// </summary>
/// <remarks>
/// <para>
/// Built from the same <see cref="FlattenIntoSections"/> pass as <see cref="SidebarRows"/>, regrouped
/// into one entry per heading rather than left as a flat stream of rows — see
/// <see cref="HostSectionViewModel"/> for why, and <see cref="RebuildHostSections"/> for how.
/// </para>
/// <para>
/// <b>A keychain with no groups draws one section with no heading</b>, which is what
/// <see cref="HostSectionViewModel.HasHeader"/> is for: the invariant the hosts screen documents, that a
/// groupless keychain is one flat grid and nothing above it.
/// </para>
/// </remarks>
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
/// <remarks>
/// Computed rather than stored, since it is nothing a control binds directly: it is what a shift-click's
/// run is measured against and what Ctrl+A ticks, both of which want the flat order rather than the
/// sections that draw it. See <see cref="ChooseHostRun"/> and <c>HostsScreen.axaml.cs</c>.
/// </remarks>
internal IEnumerable<HostRowViewModel> HostBoardOrder =>
HostSections.SelectMany(section => section.Hosts);
/// <summary>Whether the board has any card on it at all.</summary>
internal bool HasHostBoardEntries => HostSections.Any(section => section.Hosts.Count > 0);
/// <summary>The count chip beside the "Hosts" title — every host in a shown vault, unfiltered.</summary>
/// <remarks>
/// Deliberately not narrowed by the find box or the two toolbar flyouts: it answers "how big is this
/// keychain", the way the vault screen's own item count does, not "how many cards are on screen right
/// now" — which is already on the row beneath it, in each section's own heading.
/// </remarks>
internal int HostBoardTotalCount => Hosts.Count(row => IsVaultShown(row.VaultId));
/// <summary>Whether the Group ▾ flyout is currently narrowing the board.</summary>
internal bool HasActiveGroupFilter => checkedGroupFilterIds.Count > 0;
/// <summary>Whether the Tag ▾ flyout is currently narrowing the board.</summary>
internal bool HasActiveTagFilter => checkedTagFilterIds.Count > 0;
/// <summary>
/// What the board says when it has nothing on it.
/// </summary>
/// <remarks>
/// One answer per reason it can be empty, on <see cref="NoVisibleHostsMessage"/>'s own reasoning: an
/// empty keychain is an invitation, a vault switched off is a setting to revisit, a filter that has
/// narrowed everything away is a filter to widen, and a search that matches nothing is not an invitation
/// to add a host that may well already be there.
/// </remarks>
internal string NoHostBoardMessage =>
(Hosts.Count, HasHiddenVaults, HasActiveGroupFilter || HasActiveTagFilter, HostFilter.Trim().Length)
switch
{
(0, _, _, _) =>
"No hosts yet. Press + New host to add one, or import the machines already in this "
+ "computer's ~/.ssh/config from Preferences.",
(_, true, false, 0) =>
"Every host here is in a vault you have switched off. Press the vault menu in the tab strip "
+ "to switch one back on.",
(_, _, true, _) =>
"Nothing matches the Group or Tag filter. Press All groups or All tags to widen it.",
_ => "No host matches that. The name, the address and the notes are all searched.",
};
/// <summary>The toolbar's Group ▾ entries: one per group, checkable, narrowing the board when ticked.</summary>
internal ObservableCollection<GroupFilterChoice> GroupFilterChoices { get; } = [];
/// <summary>The toolbar's Tag ▾ entries: one per tag, checkable, narrowing the board when ticked.</summary>
internal ObservableCollection<TagFilterChoice> TagFilterChoices { get; } = [];
/// <summary>
/// What the board's Collapse all / Expand all control says, on the first heading it draws.
/// </summary>
/// <remarks>
/// Collapses when anything is open and expands only once everything already is, which is the ordinary
/// meaning of the pair: a mix of open and folded shelves is "more to fold away" until none are left.
/// </remarks>
internal string CollapseAllLabel =>
HostSections.Select(section => section.Header)
.OfType<SidebarGroupHeader>()
.Any(header => header.IsExpanded)
? "Collapse all"
: "Expand all";
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
/// <remarks>
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
@@ -3711,6 +3951,48 @@ internal sealed partial class VaultViewModel(
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedHost?.EntityId;
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
// The group rows carry a host count, so the boards' headings on both heads are built from them.
RebuildGroups();
RebuildVisibleHosts();
// The desktop's own board and its two toolbar flyouts. After the group rows for the reason above,
// and after tagsById — filled by ReloadTagsAsync, which ReloadAsync always runs first — since the
// tag flyout reads it rather than the active vault's own Tags.
RebuildGroupFilterChoices();
RebuildTagFilterChoices();
RebuildHostSections();
return unreadable;
}
/// <summary>Reads every readable vault's hosts and resolves each into a row, without touching <see cref="Hosts"/>.</summary>
/// <remarks>
/// Split out of <see cref="ReloadHostsAsync"/> purely for length — this project's analyser caps a method
/// at 60 lines, and the two halves this makes are "read and resolve" and "sort, select and rebuild",
/// which is a seam that was already there.
/// </remarks>
private async Task<(List<HostRowViewModel> Rows, int Unreadable)> FetchHostRowsAsync(
CancellationToken cancellationToken)
{
var unreadable = 0;
var rows = new List<HostRowViewModel>();
@@ -3746,28 +4028,7 @@ internal sealed partial class VaultViewModel(
}));
}
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
// the group rows.
RebuildGroups();
RebuildVisibleHosts();
return unreadable;
return (rows, unreadable);
}
/// <summary>Which host a freshly filled <see cref="Hosts"/> leaves selected.</summary>
@@ -4469,33 +4730,162 @@ internal sealed partial class VaultViewModel(
// the same question: every group it has as a heading, every host filed under one of them, and no way
// to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so
// a list narrowed to the outermost level would be a list showing only the hosts nobody had filed.
//
// Ungrouped last, which is the order this list has drawn since groups existed — see
// FlattenIntoSections for why the desktop's own board no longer has to agree.
var shown = Hosts.Where(MatchesFilters).ToArray();
foreach (var row in FlattenIntoSections(shown, ungroupedFirst: false, ungroupedLabel: "UNGROUPED"))
{
SidebarRows.Add(row);
}
}
/// <summary>
/// Refills <see cref="HostSections"/>: the desktop's own flattening of the same hosts, "No group" first
/// and narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
/// </summary>
/// <remarks>
/// <para>
/// Reuses <see cref="FlattenIntoSections"/> and then regroups its flat rows into one
/// <see cref="HostSectionViewModel"/> per heading — see that type for why a per-section
/// <c>ListBox</c> wants its members as a list rather than as headings mixed into one stream.
/// </para>
/// <para>
/// <b>The no-groups invariant is drawn here rather than left to the regrouping loop below.</b> With no
/// groups at all <see cref="FlattenIntoSections"/> hands back the hosts and nothing else, and one section
/// with a null header is what the board's own XAML reads as "draw the cards and nothing above them" —
/// the same rule <see cref="RebuildSidebarRows"/> has always followed for the phone's list.
/// </para>
/// </remarks>
private void RebuildHostSections()
{
HostSections.Clear();
foreach (var built in BuildHostSections())
{
HostSections.Add(built);
}
// Computed rather than stored, and none of the four has a change notification of its own — the same
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
// same reason: a control bound to one of these has no other way to learn it should ask again.
OnPropertyChanged(nameof(HasHostBoardEntries));
OnPropertyChanged(nameof(HostBoardTotalCount));
OnPropertyChanged(nameof(NoHostBoardMessage));
OnPropertyChanged(nameof(CollapseAllLabel));
}
private List<HostSectionViewModel> BuildHostSections()
{
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
if (flat.Count == 0)
{
return [];
}
if (Groups.Count == 0)
{
foreach (var host in shown)
{
SidebarRows.Add(host);
}
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
}
return;
var sections = new List<HostSectionViewModel>();
SidebarGroupHeader? header = null;
var members = new List<HostRowViewModel>();
var firstHeading = true;
foreach (var row in flat)
{
if (row is SidebarGroupHeader next)
{
if (header is not null)
{
sections.Add(new HostSectionViewModel(header, members));
}
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
// all — see IsFirstBoardSection and the XAML.
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
firstHeading = false;
members = [];
}
else if (row is HostRowViewModel host)
{
members.Add(host);
}
}
if (header is not null)
{
sections.Add(new HostSectionViewModel(header, members));
}
return sections;
}
/// <summary>
/// Every group as a heading and its members underneath, in label order — the phone's whole list, and the
/// desktop's board besides it.
/// </summary>
/// <param name="shown">The hosts that survived whatever filters the caller applies before this runs.</param>
/// <param name="ungroupedFirst">
/// Whether the heading for hosts filed under nothing comes before every group or after them. The phone
/// puts it last, which is the order this list has drawn since groups existed; the mock puts the
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
/// </param>
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
/// <remarks>
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
/// headings and on one that draws sections for them. A host whose group has been deleted falls under the
/// ungrouped heading rather than disappearing, for the reason <see cref="HostSecret.GroupId"/> is
/// allowed to dangle. An empty group still gets its heading, since it is a shelf the user made; the
/// ungrouped heading is dropped instead when nothing is on it, since that is only ever the box a search
/// left empty.
/// </remarks>
private List<ISidebarRow> FlattenIntoSections(
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
{
var rows = new List<ISidebarRow>();
if (Groups.Count == 0)
{
rows.AddRange(shown);
return rows;
}
var known = Groups.Select(group => group.EntityId).ToHashSet();
foreach (var group in Groups)
{
AddSidebarSection(shown, group, host => host.Host.GroupId == group.EntityId);
}
AddSidebarSection(
void AddUngrouped() => AddFlatSection(
rows,
shown,
null,
host => host.Host.GroupId is not { } id || !known.Contains(id),
ungroupedLabel,
onlyWhenOccupied: true);
if (ungroupedFirst)
{
AddUngrouped();
}
foreach (var group in Groups)
{
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
}
if (!ungroupedFirst)
{
AddUngrouped();
}
return rows;
}
/// <summary>Adds one heading to the sidebar, and the hosts under it when it is not folded away.</summary>
/// <summary>Appends one heading to a flat row list, and its members when it is not folded away.</summary>
/// <param name="rows">The list being built, shared by every section a caller adds in one pass.</param>
/// <param name="shown">The hosts that survived the filters, which every section draws its members from.</param>
/// <param name="group">
/// The group this heading is for, or null for the ungrouped one. The row rather than its id and label,
@@ -4503,11 +4893,14 @@ internal sealed partial class VaultViewModel(
/// it is every vault's unfiled hosts at once.
/// </param>
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
private void AddSidebarSection(
private void AddFlatSection(
List<ISidebarRow> rows,
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs,
string ungroupedLabel,
bool onlyWhenOccupied = false)
{
var members = shown.Where(belongs).ToArray();
@@ -4519,9 +4912,9 @@ internal sealed partial class VaultViewModel(
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
SidebarRows.Add(new SidebarGroupHeader(
rows.Add(new SidebarGroupHeader(
group?.EntityId,
group?.Label ?? "UNGROUPED",
group?.Label ?? ungroupedLabel,
members.Length,
expanded)
{
@@ -4533,18 +4926,135 @@ internal sealed partial class VaultViewModel(
return;
}
foreach (var member in members)
rows.AddRange(members);
}
/// <summary>Whether a host passes the toolbar's Group ▾ and Tag ▾ flyouts, on top of the ordinary filters.</summary>
/// <remarks>
/// Empty means every group or every tag, which is what "All groups" and "All tags" reset the checked
/// sets to — an empty filter narrows nothing, on the same reading <see cref="MatchesFilters"/> gives an
/// empty find box. A host passes the tag half by wearing <em>any</em> checked tag, not all of them.
/// </remarks>
private bool MatchesHostBoardFilters(HostRowViewModel row) =>
MatchesFilters(row)
&& (checkedGroupFilterIds.Count == 0
|| (row.Host.GroupId is { } groupId && checkedGroupFilterIds.Contains(groupId)))
&& (checkedTagFilterIds.Count == 0 || row.Host.TagIds.Any(checkedTagFilterIds.Contains));
/// <summary>Refills the toolbar's Group ▾ entries from <see cref="Groups"/> and the checked set.</summary>
private void RebuildGroupFilterChoices()
{
GroupFilterChoices.Clear();
var several = session.ReadableVaults.Take(2).Count() > 1;
foreach (var group in Groups)
{
SidebarRows.Add(member);
GroupFilterChoices.Add(new GroupFilterChoice(
group.EntityId,
several && group.HasVaultBadge ? $"{group.Label} ({group.VaultName})" : group.Label,
checkedGroupFilterIds.Contains(group.EntityId)));
}
}
/// <summary>Refills the toolbar's Tag ▾ entries from every readable vault's tags and the checked set.</summary>
/// <remarks>
/// Read from <see cref="tagsById"/> rather than <see cref="Tags"/>, which is the active vault's alone —
/// see the remark on <see cref="ReloadTagsAsync"/>. A filter that could only narrow to the active vault's
/// tags would leave a team's own tags unreachable from this flyout while still drawn as chips on cards.
/// </remarks>
private void RebuildTagFilterChoices()
{
TagFilterChoices.Clear();
foreach (var tag in tagsById.OrderBy(pair => pair.Value.Label, StringComparer.CurrentCulture))
{
TagFilterChoices.Add(new TagFilterChoice(
tag.Key, tag.Value.Label, checkedTagFilterIds.Contains(tag.Key)));
}
}
/// <summary>Ticks or unticks one group in the toolbar's Group ▾ flyout.</summary>
[RelayCommand]
private void ToggleGroupFilter(GroupFilterChoice? choice)
{
if (choice is null)
{
return;
}
if (!checkedGroupFilterIds.Remove(choice.GroupId))
{
checkedGroupFilterIds.Add(choice.GroupId);
}
RebuildGroupFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveGroupFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>"All groups" — the reset at the foot of the Group ▾ flyout's checked entries.</summary>
[RelayCommand]
private void ResetGroupFilter()
{
if (checkedGroupFilterIds.Count == 0)
{
return;
}
checkedGroupFilterIds.Clear();
RebuildGroupFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveGroupFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Ticks or unticks one tag in the toolbar's Tag ▾ flyout.</summary>
[RelayCommand]
private void ToggleTagFilter(TagFilterChoice? choice)
{
if (choice is null)
{
return;
}
if (!checkedTagFilterIds.Remove(choice.TagId))
{
checkedTagFilterIds.Add(choice.TagId);
}
RebuildTagFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveTagFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>"All tags" — the reset at the foot of the Tag ▾ flyout's checked entries.</summary>
[RelayCommand]
private void ResetTagFilter()
{
if (checkedTagFilterIds.Count == 0)
{
return;
}
checkedTagFilterIds.Clear();
RebuildTagFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveTagFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Folds one group's hosts away, or brings them back.</summary>
/// <remarks>
/// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
/// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
/// forgotten a minute after it was set. The ungrouped heading uses <see cref="Guid.Empty"/>, which is not
/// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
///
/// Rebuilds both <see cref="SidebarRows"/> and <see cref="HostSections"/>: the set they fold against is
/// shared, and whichever head is actually drawing needs its own collection rebuilt regardless.
/// </remarks>
[RelayCommand]
private void ToggleGroup(SidebarGroupHeader? header)
@@ -4562,7 +5072,46 @@ internal sealed partial class VaultViewModel(
}
RebuildSidebarRows();
RebuildHostSections();
SelectedSidebarRow = SelectedHost;
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>Collapse all / Expand all, on the desktop board's first heading.</summary>
/// <remarks>
/// Collapses everything when anything is open, and expands everything only once all of it already is —
/// see <see cref="CollapseAllLabel"/>. A no-op on a groupless keychain, which draws no headings to fold.
/// </remarks>
[RelayCommand]
private void ToggleAllGroups()
{
var headers = HostSections.Select(section => section.Header).OfType<SidebarGroupHeader>().ToList();
if (headers.Count == 0)
{
return;
}
var collapsing = headers.Any(header => header.IsExpanded);
foreach (var header in headers)
{
var key = header.GroupId ?? Guid.Empty;
if (collapsing)
{
collapsedGroups.Add(key);
}
else
{
collapsedGroups.Remove(key);
}
}
RebuildSidebarRows();
RebuildHostSections();
SelectedSidebarRow = SelectedHost;
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>
@@ -5474,14 +6023,16 @@ internal sealed partial class VaultViewModel(
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
/// <remarks>
/// <para>
/// The order is the grid's own — <see cref="VisibleHosts"/>, which is the collection the cards are drawn
/// from — so "between" means what the eye says it means, with whatever filter is in the box and whatever
/// group is open already applied. Taking it from <see cref="Hosts"/> instead would tick machines that are
/// not on the screen, which is the version of this mistake that ends in a deletion.
/// The order is the board's own — <see cref="HostBoardOrder"/>, which is <see cref="HostSections"/>
/// flattened back out in the order the cards are drawn — so "between" means what the eye says it means,
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every section
/// a fold has not hidden. Taking it from <see cref="Hosts"/> instead would tick machines that are not on
/// the screen, which is the version of this mistake that ends in a deletion.
/// </para>
/// <para>
/// Either end missing from that collection ticks nothing rather than guessing. That is a shift-click
/// arriving after the run's other end has been filtered away, and the honest answer to it is no run.
/// Either end missing from that order ticks nothing rather than guessing. That is a shift-click arriving
/// after the run's other end has been filtered away, or folded inside a collapsed section, and the
/// honest answer to it is no run.
/// </para>
/// </remarks>
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
@@ -5491,8 +6042,9 @@ internal sealed partial class VaultViewModel(
return;
}
var from = VisibleHosts.IndexOf(anchor);
var until = VisibleHosts.IndexOf(to);
var order = HostBoardOrder.ToList();
var from = order.IndexOf(anchor);
var until = order.IndexOf(to);
if (from < 0 || until < 0)
{
@@ -5502,7 +6054,7 @@ internal sealed partial class VaultViewModel(
var first = Math.Min(from, until);
var last = Math.Max(from, until);
ChooseHosts(VisibleHosts.Skip(first).Take(last - first + 1).ToList(), replacing);
ChooseHosts(order.Skip(first).Take(last - first + 1).ToList(), replacing);
}
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
@@ -11581,9 +12133,15 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
/// hosts already in memory, with no decryption and nothing on disk behind it.
/// hosts already in memory, with no decryption and nothing on disk behind it. Both boards rebuild — the
/// phone's flat list and the desktop's sectioned one — since the box is the one filter they share.
/// </remarks>
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
partial void OnHostFilterChanged(string value)
{
RebuildVisibleHosts();
RebuildHostSections();
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
/// <remarks>