Public Access
Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it
This commit is contained in:
@@ -9,113 +9,75 @@ using DodoSSH.Client.Shell.ViewModels;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
|
||||
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
|
||||
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
|
||||
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
||||
/// ◆ <b>v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG.</b> The desktop
|
||||
/// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a
|
||||
/// double-click, with a trail above it saying where you were — the way a directory pane holds one directory.
|
||||
/// That model is retired outright rather than folded into the new board: every group is now a heading, in
|
||||
/// label order, all of them at once, exactly the flattening the phone's <c>SidebarRows</c> has always drawn.
|
||||
/// A host is filed under a group through the group's own picker in its editor, or through the "Change
|
||||
/// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one
|
||||
/// destination, a group card, and that card is what left. See <c>VaultViewModel.HostSections</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
|
||||
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
|
||||
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
|
||||
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
|
||||
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
|
||||
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
|
||||
/// different.
|
||||
/// ◆ <b>ONE SECTION, ONE <c>ListBox</c>.</b> The two shapes worth weighing were a single mixed list of
|
||||
/// header rows and host rows in one <c>WrapPanel</c>, or an <c>ItemsControl</c> of sections each holding its
|
||||
/// own card list. The first is what the phone's single flat list already is, and reusing it here would have
|
||||
/// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before
|
||||
/// and after it — a real technique, and a fragile one to get right at every width this window can be resized
|
||||
/// to. The second costs a real thing: there is no longer one <c>ListBox</c> owning one <c>SelectedItem</c>,
|
||||
/// so multi-select and the marquee have to be taught to reach across however many section lists are on
|
||||
/// screen. That is what most of this file now does. It was chosen anyway, because a <c>WrapPanel</c> that
|
||||
/// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead
|
||||
/// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup —
|
||||
/// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a
|
||||
/// custom panel this application does not otherwise need.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
|
||||
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
|
||||
/// beside the grid inherits the same one.
|
||||
/// ◆ <b>SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT.</b> Each section's
|
||||
/// <c>ListBox</c> binds <c>SelectedItem</c> from <c>VaultViewModel.SelectedSidebarRow</c> so the right card
|
||||
/// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt
|
||||
/// lists would have each list's own <c>Reset</c> (one per filter keystroke, one per background sync) racing
|
||||
/// to null the one shared property, which is the same hazard <c>VaultViewModel.RebuildVisibleHosts</c>
|
||||
/// already documents for a single list and multiplies it by the section count. So a card being pressed is
|
||||
/// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be —
|
||||
/// see <see cref="OnPointerPressed"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.</b>
|
||||
/// <c>SelectedHost</c> is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is
|
||||
/// what Ctrl, Shift and the band build on top of it, as <c>IsChosen</c> on the row. Every handler below that
|
||||
/// reads a modifier is about keeping the two from being confused for one another, on the same terms this
|
||||
/// screen has always used — see <c>VaultViewModel.ChooseHosts</c> and Android's <c>HostsScreen</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is. The window hands it
|
||||
/// over; see <see cref="MainWindow"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// How hosts travel from the cards they were picked up on to the group card they are dropped on.
|
||||
/// </summary>
|
||||
/// <summary>How far the pointer has to travel before a press on the empty space becomes a band.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
|
||||
/// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
|
||||
/// drop needs: they know which vault each edit has to return to, which an id on its own does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
|
||||
/// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
|
||||
/// a gesture that quietly does a fraction of what it looks like it does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
|
||||
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
|
||||
/// those into a drag gesture the user never asked for. The band on the empty space between the cards
|
||||
/// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
|
||||
/// flashed up on every one of those would be a gesture reported where none happened.
|
||||
/// A threshold, because a press on the space around the cards is nearly always a click that drops the
|
||||
/// chosen set — see <see cref="OnBandPressed"/>. Starting a band on the press itself would turn every one
|
||||
/// of those into a rectangle nobody asked for.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 5;
|
||||
|
||||
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
|
||||
/// <remarks>
|
||||
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
|
||||
/// carrying something the user is looking at — a band the width of a hairline would only be found by
|
||||
/// accident, and only by somebody who did not need it.
|
||||
/// </remarks>
|
||||
private const double EdgeBand = 48;
|
||||
|
||||
/// <summary>How far one drag event inside that band moves the grid.</summary>
|
||||
/// <remarks>
|
||||
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
|
||||
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
|
||||
/// </remarks>
|
||||
private const double EdgeStep = 24;
|
||||
|
||||
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
|
||||
/// <remarks>
|
||||
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
|
||||
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
|
||||
/// over to the operating system.
|
||||
/// </remarks>
|
||||
private PointerPressedEventArgs? press;
|
||||
|
||||
/// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
|
||||
private IReadOnlyList<HostRowViewModel> pickedUp = [];
|
||||
|
||||
private Point origin;
|
||||
|
||||
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
||||
private ListBoxItem? marked;
|
||||
|
||||
/// <summary>
|
||||
/// Where a Shift-click measures its run from: the last card pressed without one.
|
||||
/// </summary>
|
||||
/// <summary>Where a Shift-click measures its run from: the last card pressed without one.</summary>
|
||||
/// <remarks>
|
||||
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
|
||||
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched board falls back
|
||||
/// to the selected card for.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? anchor;
|
||||
|
||||
/// <summary>
|
||||
/// The ticked card a plain press landed on, which the release collapses the set onto.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
|
||||
/// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
|
||||
/// was an ordinary click and means what a click always means here — this one card, and nothing else. If
|
||||
/// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? collapse;
|
||||
|
||||
/// <summary>Whether a band is being dragged out over the grid right now.</summary>
|
||||
/// <summary>Whether a band is being dragged out over the board right now.</summary>
|
||||
private bool banding;
|
||||
|
||||
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
|
||||
@@ -133,53 +95,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
||||
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
|
||||
// password box above it, and a host that asks for a password still needs it typed first.
|
||||
HostGrid.DoubleTapped += OnHostActivated;
|
||||
// the transfers screen opens a directory too. Attached to the board rather than to any one section's
|
||||
// list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles.
|
||||
Board.DoubleTapped += OnHostActivated;
|
||||
|
||||
// And a group card opens the group, on the same gesture, for the same reason: going inside
|
||||
// something by double-clicking it is what the transfers screen's directories do and what every file
|
||||
// manager does. One click used to open a group, which made the card that names a group and the
|
||||
// control that narrows the grid to it the same press — so there was no way to select a group in
|
||||
// order to rename it without also losing sight of every host outside it.
|
||||
GroupGrid.DoubleTapped += OnGroupActivated;
|
||||
|
||||
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
||||
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
||||
// what is selected, and the menu is about to open against it. It is also what lets a modifier click
|
||||
// stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
|
||||
// moving its own selection onto it.
|
||||
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
// And the group cards answer a right click the same way, for the same reason: their menu's commands
|
||||
// read the vault's group selection, and without this they would act on whichever card was selected
|
||||
// before — or, with none, on the group the trail ends with, which is not on screen at all.
|
||||
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
HostGrid.PointerMoved += OnPointerMoved;
|
||||
HostGrid.PointerReleased += OnPointerReleased;
|
||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
||||
// Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered
|
||||
// the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by
|
||||
// the time either reaches this control the list has already decided what it thinks is selected.
|
||||
Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
WireTheBand();
|
||||
|
||||
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
|
||||
// same control: the target was a heading among the cards, and with the headings gone the group cards
|
||||
// are the only thing on this screen that names a group. A card dropped onto another card is refused
|
||||
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
|
||||
// would be, and a gesture whose result you cannot see before you let go is one that files machines
|
||||
// somewhere the user did not intend.
|
||||
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
|
||||
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
|
||||
DragDrop.AddDropHandler(GroupGrid, OnDrop);
|
||||
|
||||
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
|
||||
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
|
||||
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
|
||||
// platform is concerned. This is where all of them but a group card are turned down — and where a
|
||||
// drag held at the top or bottom edge pulls the grid towards the target.
|
||||
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
||||
}
|
||||
|
||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||
@@ -190,17 +116,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void WireTheBand()
|
||||
{
|
||||
// Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
|
||||
// Esc and Ctrl+A, on the board rather than on the window: both are ordinary editing keys that mean
|
||||
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
|
||||
// in it. The grid takes focus on a press, so the keys work from the moment anything has been
|
||||
// in it. The board takes focus on a press, so the keys work from the moment anything has been
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
HostGrid.KeyDown += OnGridKey;
|
||||
Board.KeyDown += OnGridKey;
|
||||
|
||||
// ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
|
||||
// mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
|
||||
// the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
|
||||
// reason the list's own press is, and it runs first, so it has to recognise a press on a card and
|
||||
// leave it alone.
|
||||
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||
// the board's own press is, and it runs first, so it has to recognise a press on a card and leave it
|
||||
// alone.
|
||||
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
|
||||
Scroll.PointerMoved += OnBandMoved;
|
||||
Scroll.PointerReleased += OnBandReleased;
|
||||
@@ -224,15 +150,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// <para>
|
||||
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
||||
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
||||
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
|
||||
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
|
||||
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
|
||||
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
|
||||
/// answer then, and it is a good one: it is where they were typing.
|
||||
/// something that is not there would swallow it. <c>Board</c> is focusable and holds every section, so it
|
||||
/// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and
|
||||
/// the find box is the answer then — it is where they were typing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget =>
|
||||
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
|
||||
Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||
@@ -241,31 +165,15 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
// Only over a card. A double-tap on the space around them must not connect to whichever host was
|
||||
// selected before — which is what an unguarded handler would do, on a machine the user is not even
|
||||
// pointing at.
|
||||
// Only over a card. A double-tap on the space around them, or on a section heading, must not connect
|
||||
// to whichever host was selected before — which is what an unguarded handler would do, on a machine
|
||||
// the user is not even pointing at.
|
||||
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
||||
{
|
||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the group card that was double-clicked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
|
||||
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
|
||||
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
|
||||
/// </remarks>
|
||||
private void OnGroupActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
|
||||
{
|
||||
vault.OpenGroupCommand.Execute(group);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the menu at whatever was right-clicked.
|
||||
/// </summary>
|
||||
@@ -283,8 +191,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// behind the menu.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
|
||||
/// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
|
||||
/// Cancelled outright over the space around the cards, or on a section heading. Neither is a host, and a
|
||||
/// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on
|
||||
/// something else entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
@@ -302,68 +211,39 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
if (!vault.IsChoosingHosts)
|
||||
{
|
||||
vault.SelectedSidebarRow = row;
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the group menu at whatever was right-clicked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
|
||||
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
|
||||
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
|
||||
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
|
||||
/// them now, so aiming it is the whole of aiming them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
|
||||
/// the fallback is exactly what would make the menu look like it worked there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedGroup = row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks cards, or remembers a press that may become a drag.
|
||||
/// Ticks cards, or moves the selection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
||||
/// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
|
||||
/// that also selected would light the card it had just unticked, and the drawer would open on a machine
|
||||
/// the user was removing from a set. Handled on the way down is the only place that can be said —
|
||||
/// by the time the press bubbles the control has already answered it.
|
||||
/// what stops whichever section's <c>ListBox</c> the press landed in from moving its own selection onto
|
||||
/// the card: a Ctrl-click that also selected would light the card it had just unticked, and the drawer
|
||||
/// would open on a machine the user was removing from a set. Handled on the way down is the only place
|
||||
/// that can be said — by the time the press bubbles the control has already answered it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
|
||||
/// file manager has and the reason the two selections on this screen can never disagree: after an
|
||||
/// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
|
||||
/// to the release, because the press may be the start of a drag of the whole set; see
|
||||
/// <see cref="collapse"/>.
|
||||
/// <b>A plain press moves the selection and drops the chosen set</b>, which is every file manager's rule
|
||||
/// and the reason the two selections on this screen can never disagree: after an ordinary click there is
|
||||
/// exactly one card in play. Not marked handled — the section list's own native selection is left to
|
||||
/// follow along, which is harmless now that it is only <c>OneWay</c> bound: whatever it shows locally is
|
||||
/// overwritten the moment <see cref="VaultViewModel.SelectedHost"/>'s change reaches every section again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
|
||||
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
|
||||
/// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// aimed at, where the board sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// things that can then be done to a set each refuse for themselves, and say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
Forget();
|
||||
|
||||
if (Vault is not { } vault
|
||||
|| !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
|| !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
@@ -375,7 +255,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
|
||||
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
@@ -386,7 +266,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
@@ -394,82 +274,23 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
anchor = row;
|
||||
|
||||
// Read before anything is cleared, since clearing is one of the two things it decides.
|
||||
if (row.IsChosen)
|
||||
{
|
||||
collapse = row;
|
||||
pickedUp = vault.ChosenHosts;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
pickedUp = [row];
|
||||
}
|
||||
|
||||
press = e;
|
||||
origin = e.GetPosition(HostGrid);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The drag is started from the remembered press once the pointer has travelled far enough — see
|
||||
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
|
||||
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (press is not { } pressed || pickedUp.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
Forget();
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = e.GetPosition(HostGrid) - origin;
|
||||
|
||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var carried = pickedUp;
|
||||
|
||||
Forget();
|
||||
|
||||
_ = DragAsync(pressed, carried);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
|
||||
/// what every other click on a card means. See <see cref="collapse"/>.
|
||||
/// </remarks>
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (collapse is not null && Vault is { } vault)
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
Forget();
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
|
||||
|
||||
/// <summary>
|
||||
/// Esc drops the set, and Ctrl+A is every card on the screen.
|
||||
/// Esc drops the set, and Ctrl+A is every card on the board.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
|
||||
/// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
|
||||
/// including the thirty-two not being shown would be the worst possible input to Delete.
|
||||
/// Every card <em>on the board</em>: <see cref="VaultViewModel.HostBoardOrder"/>, which is every section
|
||||
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a
|
||||
/// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing would
|
||||
/// be the worst possible input to Delete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
|
||||
@@ -492,10 +313,12 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||
{
|
||||
vault.ChooseHosts(vault.VisibleHosts, replacing: true);
|
||||
anchor = vault.VisibleHosts[0];
|
||||
var order = vault.HostBoardOrder.ToList();
|
||||
|
||||
vault.ChooseHosts(order, replacing: true);
|
||||
anchor = order[0];
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -505,16 +328,16 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
|
||||
/// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
|
||||
/// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
|
||||
/// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
|
||||
/// above it moves the ceiling with no code here changing.
|
||||
/// ◆ <b>Only where there is nothing to press.</b> The scroller holds every section heading, every card
|
||||
/// list and whichever panel the set's menu raised, and a rectangle dragged out from inside any of those
|
||||
/// would be a gesture aimed at hosts started on something that is not one. <see cref="TakesThePress"/> is
|
||||
/// the guard, and it is ancestor-based rather than naming one control, which is what lets it work
|
||||
/// regardless of how many section lists happen to be realized.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
|
||||
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the board with
|
||||
/// nothing left to take it down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -533,19 +356,12 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
banding = true;
|
||||
bandFrom = at;
|
||||
bandFrom = e.GetPosition(Scroll);
|
||||
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
e.Pointer.Capture(Scroll);
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -628,15 +444,16 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
|
||||
/// <remarks>
|
||||
/// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
|
||||
/// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
|
||||
/// the run, and would tick nothing at all for a band drawn down the middle of a column.
|
||||
/// Touches rather than contains, which is what makes a band usable at all: a rectangle that had to
|
||||
/// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from
|
||||
/// every section's own list — see <see cref="AllCardContainers"/> — rather than one named control, since
|
||||
/// a band drawn across two sections has to touch cards in both.
|
||||
/// </remarks>
|
||||
private List<HostRowViewModel> CardsIn(Rect box)
|
||||
{
|
||||
var hit = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var container in HostGrid.GetRealizedContainers())
|
||||
foreach (var container in AllCardContainers())
|
||||
{
|
||||
if (container.DataContext is HostRowViewModel row
|
||||
&& container.TranslatePoint(default, Scroll) is { } corner
|
||||
@@ -649,227 +466,41 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return hit;
|
||||
}
|
||||
|
||||
/// <summary>Every realized card, across every section's own list.</summary>
|
||||
/// <remarks>
|
||||
/// A section is a <c>ListBox</c> of its own — see the type's remarks — so there is no single control
|
||||
/// whose <c>GetRealizedContainers()</c> would answer for the whole board. Found by class rather than by
|
||||
/// name, because how many sections exist is a fact about the keychain rather than the markup: a flat
|
||||
/// keychain draws one, a filed one draws several, and neither shape is known until the vault says so.
|
||||
/// </remarks>
|
||||
private List<ListBoxItem> AllCardContainers() =>
|
||||
[.. this.GetVisualDescendants()
|
||||
.OfType<ListBox>()
|
||||
.Where(list => list.Classes.Contains("sectioncards"))
|
||||
.SelectMany(list => list.GetRealizedContainers())
|
||||
.OfType<ListBoxItem>()];
|
||||
|
||||
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
|
||||
/// <remarks>
|
||||
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
|
||||
/// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
|
||||
/// tick everything it passed.
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up or in a section's own
|
||||
/// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would
|
||||
/// paint a band down the board and tick everything it passed.
|
||||
/// </remarks>
|
||||
private static bool TakesThePress(object? source) => source is Visual visual
|
||||
&& visual.GetSelfAndVisualAncestors().Any(element =>
|
||||
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
|
||||
|
||||
/// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
|
||||
private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, rows));
|
||||
|
||||
try
|
||||
{
|
||||
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
|
||||
// thread's.
|
||||
await DragDrop
|
||||
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
|
||||
// target for a drag that ended somewhere else entirely.
|
||||
Unmark();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says whether what is under the pointer would take these hosts, and marks it if it would.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A drag whose hosts are all already in the group under the pointer is refused, which is not pedantry:
|
||||
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
|
||||
/// would do something and then does nothing is worse than one that says so while it is still in the air.
|
||||
/// A set with even one host from somewhere else is accepted, because filing that one is a real move.
|
||||
/// </remarks>
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
if (Target(e) is not { } target)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
Unmark();
|
||||
return;
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
Mark(target.Container);
|
||||
}
|
||||
|
||||
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
|
||||
|
||||
/// <summary>
|
||||
/// Carries the grid under a drag that is over the cards rather than over a group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
|
||||
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
|
||||
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
|
||||
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
|
||||
/// list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
|
||||
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
|
||||
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
|
||||
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
|
||||
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
|
||||
/// everything that is not a group.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDragOverScroll(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
Unmark();
|
||||
|
||||
var at = e.GetPosition(Scroll).Y;
|
||||
var height = Scroll.Bounds.Height;
|
||||
|
||||
var step = at switch
|
||||
{
|
||||
_ when at < EdgeBand => -EdgeStep,
|
||||
_ when at > height - EdgeBand => EdgeStep,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (step == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
|
||||
|
||||
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
|
||||
/// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
|
||||
/// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
|
||||
/// a group belongs to one vault — and reports a count rather than forty status lines in a row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
|
||||
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
|
||||
/// task nothing observes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
Unmark();
|
||||
|
||||
if (Vault is not { } vault || Target(e) is not { } target)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
return;
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
|
||||
if (target.Hosts.Count > 1)
|
||||
{
|
||||
vault.FileChosenHostsUnderCommand.Execute(target.Group);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a drag currently is, or null if it is over nothing that would take it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
|
||||
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
|
||||
/// dropped on is what it says on it. The space around the cards takes nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A group the vault no longer has is read as no group at all, which is what the list already does with
|
||||
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
|
||||
/// rather than here, so the rule has one home.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static DropTarget? Target(DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
|
||||
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
||||
|| dragged.All(row => row.Host.GroupId == group.EntityId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropTarget(dragged, group, container);
|
||||
}
|
||||
|
||||
private void Mark(ListBoxItem container)
|
||||
{
|
||||
if (ReferenceEquals(marked, container))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Unmark();
|
||||
|
||||
marked = container;
|
||||
marked.Classes.Add("droptarget");
|
||||
}
|
||||
|
||||
private void Unmark()
|
||||
{
|
||||
marked?.Classes.Remove("droptarget");
|
||||
marked = null;
|
||||
}
|
||||
|
||||
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
|
||||
private void Forget()
|
||||
{
|
||||
press = null;
|
||||
pickedUp = [];
|
||||
collapse = null;
|
||||
}
|
||||
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
||||
private static object? RowUnder(object? source) => Container(source)?.DataContext;
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on a host card.</summary>
|
||||
private static HostRowViewModel? RowUnder(object? source) =>
|
||||
Container(source)?.DataContext as HostRowViewModel;
|
||||
|
||||
/// <remarks>
|
||||
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
||||
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
|
||||
/// yields null.
|
||||
/// is what an event's source is. Anything not inside a card, which includes a section's own heading and
|
||||
/// the space around them, yields null.
|
||||
/// </remarks>
|
||||
private static ListBoxItem? Container(object? source) => source is Visual visual
|
||||
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
||||
: null;
|
||||
|
||||
/// <summary>A drag in flight, and where it would land.</summary>
|
||||
/// <remarks>
|
||||
/// The group is the card rather than its id, which it was while only one host could be dragged: the
|
||||
/// command that files a set takes the card, because the refusal it makes is about which vault the group
|
||||
/// is in. The way out of a group is still the host's own editor, which is the one place "no group" can be
|
||||
/// said in words.
|
||||
/// </remarks>
|
||||
private sealed record DropTarget(
|
||||
IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a host card's <c>MonogramHue</c> string into the background or foreground brush the mock pairs it
|
||||
/// with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The four pairs are the design's own and are not in <c>Palette.axaml</c>: that file is another wave's, and
|
||||
/// none of the shared palette's existing keys is this hue ramp — a monogram's colour is a property of the
|
||||
/// four-way hash in <c>HostRowViewModel.MonogramHue</c>, not a fact the rest of the theme has any use for.
|
||||
/// Held here, beside the one screen that reads it, rather than invented as four new shared resources for a
|
||||
/// single card.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two instances rather than one converter taking a "which half" parameter, so the markup names each with a
|
||||
/// <c>StaticResource</c>-shaped reference instead of a converter parameter that means nothing without
|
||||
/// reading this file.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MonogramBrushConverter(bool background) : IValueConverter
|
||||
{
|
||||
/// <summary>The fill behind the two letters.</summary>
|
||||
internal static MonogramBrushConverter Background { get; } = new(background: true);
|
||||
|
||||
/// <summary>The colour of the two letters themselves.</summary>
|
||||
internal static MonogramBrushConverter Foreground { get; } = new(background: false);
|
||||
|
||||
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram.</summary>
|
||||
private static readonly Dictionary<string, (string Background, string Foreground)> Pairs = new(
|
||||
StringComparer.Ordinal)
|
||||
{
|
||||
["violet"] = ("#241C4F", "#A78BFA"),
|
||||
["green"] = ("#103A2F", "#34D399"),
|
||||
["amber"] = ("#423211", "#F5B942"),
|
||||
["gray"] = ("#1E1E2C", "#9C9EB4"),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var pair = value is string hue && Pairs.TryGetValue(hue, out var found) ? found : Pairs["gray"];
|
||||
|
||||
return new SolidColorBrush(Color.Parse(background ? pair.Background : pair.Foreground));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException("The monogram hue is read-only.");
|
||||
}
|
||||
@@ -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