Public Access
The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual box on its terminal screen all along. The palette's query now reads as an address when it has an @ in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter connects. Escape on the password step goes back to the address. It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the tab, the host-key question and the connection-log entry are the same on both heads and nothing is written to the keychain. Password only, as on the phone: a key is the reason to save the host. The typed row takes the highlight unless a saved host dials exactly that address, so typing a new address cannot land on a saved machine whose address merely starts the same way. Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
254 lines
10 KiB
C#
254 lines
10 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;
|
|
AdHocRow.Tapped += OnAdHocRowTapped;
|
|
|
|
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
|
|
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
|
|
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
|
|
// the view model's change arrives ahead of the binding that reveals the step.
|
|
QueryStep.PropertyChanged += OnStepChanged;
|
|
PasswordStep.PropertyChanged += OnStepChanged;
|
|
}
|
|
|
|
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
|
internal TextBox QueryBox => Query;
|
|
|
|
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
|
|
internal TextBox PasswordBox => Password;
|
|
|
|
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
|
|
internal Control AdHocTargetRow => AdHocRow;
|
|
|
|
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;
|
|
}
|
|
|
|
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
|
|
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
|
|
if (shell.IsAskingForAdHocPassword)
|
|
{
|
|
switch (e.Key)
|
|
{
|
|
case Key.Escape:
|
|
shell.BackToSearchQueryCommand.Execute(null);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Enter:
|
|
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
|
|
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:
|
|
shell.MoveSearchSelection(1);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case Key.Up:
|
|
shell.MoveSearchSelection(-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);
|
|
}
|
|
|
|
if (PasswordStep.IsVisible)
|
|
{
|
|
Password.Focus();
|
|
}
|
|
else
|
|
{
|
|
Query.Focus();
|
|
}
|
|
}
|
|
|
|
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
|
|
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
|
|
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
|
{
|
|
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
|
|
{
|
|
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
|
|
/// </summary>
|
|
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
|
|
{
|
|
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
|
|
{
|
|
return;
|
|
}
|
|
|
|
e.Handled = true;
|
|
shell.ChooseAdHocTargetCommand.Execute(null);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|