Public Access
Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user