Public Access
Connecting held the vault's busy gate, which meant a window that did nothing visible for as long as a machine took to answer — and against one that is merely asleep, that is the whole timeout. The gate is gone from that one command. A tab now appears in the strip in the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's rectangle draws a card naming the host and the address being dialled. Every other screen stays usable, and two connections can be in flight at once. That splits the vault's one connection event into three, carrying an attempt id, because "which tab is this about" can no longer be answered by "the most recent one". The id also buys the two kinds of not-connecting their different endings: a refusal stays in the strip as a tab holding its reason, since by then the user is quite likely three screens away and a status line they are not looking at is not where a failure should end; a host key question takes the tab away and puts the window back on HOSTS, because the prompt is drawn there and a tab claiming failure would be competing with the thing about to resume it. ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than tidying. A [RelayCommand] over a method that takes one generates a command that cancels the previous execution's token on every invocation — so asking for a second machine silently abandoned the first, measured as the first tab disappearing with "Cancelled." the instant the second was asked for. Giving up on a connection is closing its tab, and a session that lands after that is adopted rather than dropped: a shell running with nothing naming it cannot be closed at all. A tab is marked active on IsShowing rather than IsSelected. The selection survives navigating away — that is what makes the strip a way back to a terminal instead of a way to lose one — so a tab lit while preferences filled the window was a second "you are here" mark pointing at something nobody could see. The nav rail's own entries have always made this distinction. The host list grows the two gestures it looked like it already had. A right click selects the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is on the list rather than in the item template, so its entries are the vault's own commands and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it out of a group; the write is one field of one host through the same repository a save uses, refused while the editor is open because a drop is a gesture on the list and not on a half-typed form. Clicking a result in the palette connects, which is what a list of hosts under a search box looks like it does. It went through the shell's own command, so the pointer and Enter take one path. And the files screen's two pickers followed the vault's lists once, at unlock: a host or a bucket created afterwards could not be picked until the keychain had been locked and opened again, with nothing on screen explaining why the machine plainly in the host list was missing. They follow the collections now, re-finding the selection by id across the rebuild a sync pass causes every minute. 165 shell tests and 69 layout tests green, including the connecting tab, both failure endings, two connections at once, a connection in flight across a lock, and the right click acting on the row under the pointer rather than on the selection. The drag itself is in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform drag, and a test that claimed to have dropped something would pass while confirming nothing.
202 lines
8.0 KiB
C#
202 lines
8.0 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Threading;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
namespace DodoSSH.Client.App.Views;
|
|
|
|
/// <summary>The Ctrl+K host search, overlaid on the window.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything a palette does once it is open belongs here rather than in <see cref="MainWindow"/>: the four
|
|
/// keys it answers to, the press outside it that dismisses it, and taking the keyboard the moment it appears.
|
|
/// Opening it is the window's business, because Ctrl+K has to work when this control is not showing.
|
|
/// </para>
|
|
/// <para>
|
|
/// The split used to fall the other way, and the cost was that none of it could be tested. Showing
|
|
/// <see cref="MainWindow"/> initialises WebView2, which refuses the headless dispatcher's thread — see
|
|
/// <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c> — so behaviour that lived on the window could only
|
|
/// be checked by hand. A <see cref="UserControl"/> hosts in a bare window and takes real key and pointer
|
|
/// input.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class QuickConnect : UserControl
|
|
{
|
|
public QuickConnect()
|
|
{
|
|
InitializeComponent();
|
|
|
|
// Tunnelled, and deliberately: the query box below is on the route these keys take, and a text box
|
|
// that grows a use for Enter or the arrows — a multi-line box, a completion list — would take them
|
|
// before a bubbling handler here ever ran. The palette owns them while it is open, so it says so at
|
|
// the point on the route where nothing else has had a chance yet.
|
|
AddHandler(KeyDownEvent, OnPaletteKey, RoutingStrategies.Tunnel);
|
|
|
|
// A click on a result connects to it, which is the pointer's version of what Enter already does.
|
|
// Tapped rather than PointerPressed: a press has not chosen anything yet — it is also the start of a
|
|
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
|
|
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
|
|
Results.Tapped += OnResultTapped;
|
|
}
|
|
|
|
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
|
internal TextBox QueryBox => Query;
|
|
|
|
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
|
|
|
/// <summary>
|
|
/// Answers one of the palette's keys, wherever in the window it was pressed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Internal because <see cref="MainWindow"/> calls it too, for the case this control's own tunnelled
|
|
/// handler cannot see: a key routes through here only while the focus is inside the palette, and the
|
|
/// window is what catches Escape when it is not.
|
|
/// </remarks>
|
|
internal void HandleKey(KeyEventArgs e)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(e);
|
|
|
|
if (Shell is not { IsSearching: true } shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
switch (e.Key)
|
|
{
|
|
case Key.Escape:
|
|
shell.CloseSearchCommand.Execute(null);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Enter:
|
|
shell.ConnectToSearchResultCommand.Execute(null);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Down:
|
|
Move(shell, 1);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Up:
|
|
Move(shell, -1);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the keyboard, so the palette can be typed into the instant it appears.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView is a native
|
|
/// child window that keeps Win32 focus even after it is collapsed, so focusing an Avalonia control without
|
|
/// the Win32 call produces a box with a caret in it that silently receives nothing.
|
|
/// </para>
|
|
/// <para>
|
|
/// Done here rather than by the window, and that is the fix rather than a tidying. <c>Focus()</c> on a
|
|
/// collapsed control is measurably a no-op that is not replayed when the control is revealed, and the
|
|
/// window's own attempt ran from the view model's <c>PropertyChanged</c> — ahead of the binding that makes
|
|
/// this control visible, so it focused a control that was still collapsed and the keyboard stayed wherever
|
|
/// it was.
|
|
/// </para>
|
|
/// <para>
|
|
/// Becoming visible is still too early on its own, which is why this is posted rather than called. A
|
|
/// control that has never been laid out has no visual children — measured: at the instant
|
|
/// <c>IsVisible</c> turns true the query box reports <c>IsAttachedToVisualTree() == false</c>, and focus is
|
|
/// refused to anything not in the tree. Layout runs at a higher priority than this callback, so by the
|
|
/// time it is picked up the box exists.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void TakeKeyboard()
|
|
{
|
|
// The palette can have been dismissed between the post and the callback — a press on the wash, or a
|
|
// second Ctrl+K — and stealing the keyboard back into a control nobody can see would be worse than
|
|
// arriving late.
|
|
if (!IsVisible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (TopLevel.GetTopLevel(this) is Window window)
|
|
{
|
|
NativeKeyboardFocus.ReturnTo(window);
|
|
}
|
|
|
|
Query.Focus();
|
|
}
|
|
|
|
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
|
private static void Move(MainWindowViewModel shell, int delta)
|
|
{
|
|
if (shell.SearchResults.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var current = shell.SelectedSearchResult is { } selected
|
|
? shell.SearchResults.IndexOf(selected)
|
|
: -1;
|
|
|
|
shell.SelectedSearchResult =
|
|
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
{
|
|
// Base first, so the visibility this control has just been given has already reached its descendants:
|
|
// focus is refused to anything not effectively visible, and the box below is a descendant.
|
|
base.OnPropertyChanged(change);
|
|
|
|
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
|
|
{
|
|
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
|
}
|
|
}
|
|
|
|
private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e);
|
|
|
|
/// <summary>
|
|
/// Connects to the result that was clicked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Goes through the shell's own command rather than reading the row out of the event, so the pointer and
|
|
/// the keyboard take exactly the same path: the palette closes, the hosts screen is put back in case the
|
|
/// connection has a question to ask, and the vault's connect command makes every refusal it already
|
|
/// makes. Fire-and-forget, as the sidebar's double-click is — the command reports its own failures onto
|
|
/// the status line and into the tab it opens.
|
|
/// </remarks>
|
|
private void OnResultTapped(object? sender, TappedEventArgs e)
|
|
{
|
|
if (Shell is not { IsSearching: true, SelectedSearchResult: not null } shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
e.Handled = true;
|
|
_ = shell.ConnectToSearchResultCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Only a press on the wash itself. Presses on the card bubble through here as well, and closing on those
|
|
/// would make the palette impossible to click into.
|
|
/// </remarks>
|
|
private void OnBackdropPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (!ReferenceEquals(e.Source, Backdrop) || Shell is not { IsSearching: true } shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
shell.CloseSearchCommand.Execute(null);
|
|
e.Handled = true;
|
|
}
|
|
}
|