Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
T
jaap-jan c859705b83
ci / android head (push) Successful in 4m1s
ci / build and test (push) Successful in 6m25s
ci / api image (push) Successful in 23s
ci / desktop nightly (push) Successful in 2m4s
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on
somebody's behalf.

The board draws a heading per vault and sorts each vault's hosts by that
vault's own groups. A group is an item of one vault, so drawing every
group of every vault in one run flattened away containment that already
exists: two vaults each holding a "production" were two headings with the
same word on them, told apart by a small grey name beside each. Single-
vault sessions are unchanged. There is now one "No group" heading per
vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey
rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts
folding every other vault's away with them.

A Ctrl-click starts its set with the card the board has lit. The board
always has a selection — a fresh unlock lights the first host so CONNECT
has something under it — so "one card lit, nothing ticked" is the state
this screen opens in, and ticking a second card there produced a set of
one: the machine the user was looking at was left out of the count and
out of everything Delete, Move and Change group then ran over.

The card tooltip says the host's name as well as its address. 214 pixels
of card trims the name, and the tooltip was the one place it was not said
in full.

The card's floor drops from 120 to 104. A card with no tags measures
about 87, so the mock's number was thirty pixels of air under every card
on a board that is scrolled.

"Move to another vault…" joins the board's context menu for one host.
Moving one machine meant opening its pane and finding the menu inside it,
while moving six was a right click away — the one-host case was the
harder of the two.

And the Hetzner import asks which vault and which key. A project belongs
to a team more often than to a person, so forty servers landing wherever
the session happens to be standing is the wrong default; ImportHostsAsync
takes the target vault, and the ssh_config importer passes null and is
unchanged. The key is the half Hetzner cannot supply and the keychain
usually already holds — the public half went to Hetzner when the servers
were made — so the import points every host at it rather than bringing
one. It defaults to no key rather than guessing, and says so when the key
it was given lives in a different vault from the hosts.
2026-08-26 11:54:29 +02:00

509 lines
24 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <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>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>
/// ◆ <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 far the pointer has to travel before a press on the empty space becomes a band.</summary>
/// <remarks>
/// 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>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 board falls back
/// to the selected card for.
/// </remarks>
private HostRowViewModel? anchor;
/// <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>
private Point bandFrom;
/// <summary>Whether that band adds to the set rather than being the whole of it.</summary>
/// <remarks>
/// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag
/// would change what the rectangle already crossed means, which is a selection nobody could predict.
/// </remarks>
private bool bandAdds;
public HostsScreen()
{
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. 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;
// 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();
}
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
/// <remarks>
/// Split out of the constructor rather than sitting in it, and only because the constructor is at the
/// length this repository's analyser allows for one. Everything here arrived together: it is the whole
/// of choosing more than one card with a pointer.
/// </remarks>
private void WireTheBand()
{
// 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 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.
Board.KeyDown += OnGridKey;
// ◆ 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;
Scroll.PointerCaptureLost += OnBandCaptureLost;
}
/// <remarks>
/// Null before the window has handed one over, and while the previewer is showing this control with no
/// data context at all. Every handler below checks rather than assuming.
/// </remarks>
private VaultViewModel? Vault => DataContext as VaultViewModel;
/// <summary>
/// Where the keyboard should land when the terminal hands it back.
/// </summary>
/// <remarks>
/// <para>
/// Exposed as a property rather than left for the window to find by name, because the name is inside
/// this control's template and the window cannot see it.
/// </para>
/// <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. <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 { HasHostBoardEntries: true } ? Board : HostFilter;
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
/// returning a task nothing observes.
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// 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>
/// Points the menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The menu's commands read the vault's host selection, and a right click does not move it — which would
/// mean a menu that quietly acted on whichever host happened to be selected instead of the one under the
/// pointer. Deleting the wrong machine is the version of that mistake worth designing against.
/// </para>
/// <para>
/// ◆ <b>A right click outside the ticked set drops the set, and one inside it keeps it.</b> That is the
/// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from
/// one markup: the entries about a set and the entries about a selection are never both meaningful, so
/// Delete… can never be a question about the card under the pointer asked while six others sit ticked
/// behind the menu.
/// </para>
/// <para>
/// 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)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
{
e.Handled = true;
return;
}
if (vault.IsChoosingHosts && !row.IsChosen)
{
vault.ClearHostChoiceCommand.Execute(null);
}
if (!vault.IsChoosingHosts)
{
vault.SelectedHost = row;
}
}
/// <summary>
/// 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 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 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 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)
{
if (Vault is not { } vault
|| !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
var control = e.KeyModifiers.HasFlag(KeyModifiers.Control);
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
{
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
Board.Focus();
e.Handled = true;
return;
}
if (control)
{
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
vault.ExtendHostChoiceCommand.Execute(row);
anchor = row;
Board.Focus();
e.Handled = true;
return;
}
anchor = row;
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
vault.SelectedHost = row;
}
/// <summary>
/// Esc drops the set, and Ctrl+A is every card on the board.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it
/// away from whichever of those the user meant.
/// </para>
/// </remarks>
private void OnGridKey(object? sender, KeyEventArgs e)
{
if (Vault is not { } vault)
{
return;
}
if (e.Key is Key.Escape && vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
e.Handled = true;
return;
}
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{
var order = vault.HostBoardOrder.ToList();
vault.ChooseHosts(order, replacing: true);
anchor = order[0];
e.Handled = true;
}
}
/// <summary>
/// Starts a band on the empty space between and below the cards.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <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 board with
/// nothing left to take it down.
/// </para>
/// <para>
/// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
/// </para>
/// </remarks>
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
{
EndBand();
if (Vault is null
|| !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
|| TakesThePress(e.Source))
{
return;
}
banding = true;
bandFrom = e.GetPosition(Scroll);
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
e.Pointer.Capture(Scroll);
Board.Focus();
}
/// <remarks>
/// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is
/// over at that moment — including the cards it has just been pulled back off. A band that only ever
/// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is
/// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how
/// a second run is picked up without losing the first.
/// </remarks>
private void OnBandMoved(object? sender, PointerEventArgs e)
{
if (!banding || Vault is not { } vault)
{
return;
}
if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed)
{
EndBand();
return;
}
var at = e.GetPosition(Scroll);
var box = new Rect(
Math.Min(bandFrom.X, at.X),
Math.Min(bandFrom.Y, at.Y),
Math.Abs(at.X - bandFrom.X),
Math.Abs(at.Y - bandFrom.Y));
if (box.Width < DragThreshold && box.Height < DragThreshold)
{
return;
}
Band.Margin = new Thickness(box.X, box.Y, 0, 0);
Band.Width = box.Width;
Band.Height = box.Height;
Band.IsVisible = true;
vault.ChooseHosts(CardsIn(box), replacing: !bandAdds);
}
/// <remarks>
/// A press and a release with no band between them is a click on the space around the cards, and that
/// drops the set — the same thing a plain click on a card does, and the way out of selection mode for
/// anybody who never finds Esc. Where a band <em>was</em> drawn the moves have already said what is
/// ticked, and re-applying it here would only repeat the last one.
/// </remarks>
private void OnBandReleased(object? sender, PointerReleasedEventArgs e)
{
if (!banding)
{
return;
}
var drawn = Band.IsVisible;
EndBand();
e.Pointer.Capture(null);
if (!drawn && Vault is { IsChoosingHosts: true } vault)
{
vault.ClearHostChoiceCommand.Execute(null);
}
}
/// <remarks>
/// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when
/// the window is deactivated, and when the platform simply takes it — none of which is the user saying
/// what they wanted; leaving the rectangle up would be the only visible consequence.
/// </remarks>
private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand();
private void EndBand()
{
banding = false;
Band.IsVisible = false;
}
/// <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: 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 AllCardContainers())
{
if (container.DataContext is HostRowViewModel row
&& container.TranslatePoint(default, Scroll) is { } corner
&& box.Intersects(new Rect(corner, container.Bounds.Size)))
{
hit.Add(row);
}
}
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 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>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 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;
}