Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
+154 -43
View File
@@ -1,6 +1,7 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
/// </para>
/// <para>
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
/// keyboard to a host list nobody can see.
/// </para>
/// </remarks>
private IInputElement KeyboardHome => shell?.Screen switch
private IInputElement KeyboardHome => shell switch
{
ShellScreen.Vault => VaultPane.KeyboardTarget,
ShellScreen.Hosts => Hosts.KeyboardTarget,
{ IsTerminalShowing: true } => Terminal,
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
_ => this,
};
/// <summary>
/// Asks for the terminal to take the keyboard, once layout has run.
/// </summary>
/// <remarks>
/// <para>
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
/// </para>
/// <para>
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
/// </para>
/// <para>
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
/// into a collapsed WebView would leave the window with nothing focused at all.
/// </para>
/// </remarks>
private void FocusTerminalWhenLaidOut() =>
Dispatcher.UIThread.Post(
() =>
{
if (shell is { IsTerminalShowing: true })
{
Terminal.Focus();
}
},
DispatcherPriority.Loaded);
/// <summary>
/// Where the keyboard belongs once the vault is no longer open.
/// </summary>
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
}
/// <remarks>
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
/// not replayed when it is revealed.
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
/// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
/// reliably is at this instant. A session can now be opened from any screen, so this event routinely
/// arrives in the same turn that revealed the WebView. Hence the post; see
/// <see cref="FocusTerminalWhenLaidOut"/>.
/// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
/// point at which "does this one return early" stops being obvious to a reader.
/// </remarks>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (shell is not { } viewModel)
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
return;
}
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
switch (e.PropertyName)
{
var unlocked = viewModel.IsUnlocked;
case nameof(MainWindowViewModel.IsUnlocked):
OnVaultOpenedOrClosed(viewModel);
break;
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
// change, and reacting to all of them would move focus during setup and sign-in.
if (wasUnlocked && !unlocked)
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
case nameof(MainWindowViewModel.IsSearching):
OnPaletteToggled(viewModel);
break;
wasUnlocked = unlocked;
// One arm for both, deliberately. They mean the same thing to this handler — what the window is
// showing may have changed — and answering them separately would make the order of two
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
case nameof(MainWindowViewModel.Surface):
case nameof(MainWindowViewModel.Screen):
OnShowingSomethingElse(viewModel);
break;
case nameof(MainWindowViewModel.SelectedTab):
OnSelectedTabChanged(viewModel);
break;
default:
break;
}
}
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
{
var unlocked = viewModel.IsUnlocked;
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
// change, and reacting to all of them would move focus during setup and sign-in.
if (wasUnlocked && !unlocked)
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
wasUnlocked = unlocked;
}
/// <remarks>
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
/// which is a moment this handler is measurably ahead of: it runs from the view model's
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
/// that is still collapsed is a no-op that is not replayed when it is revealed.
/// </remarks>
private void OnPaletteToggled(MainWindowViewModel viewModel)
{
if (viewModel.IsSearching)
{
return;
}
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
// expected to start typing into immediately — but the palette does that for itself when it becomes
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
// still collapsed is a no-op that is not replayed when it is revealed.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
// Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
// focus rather than the immediate one.
if (viewModel.IsTerminalShowing)
{
if (!viewModel.IsSearching)
{
ReleaseKeyboardTo(KeyboardHome);
}
FocusTerminalWhenLaidOut();
}
else
{
ReleaseKeyboardTo(KeyboardHome);
}
}
/// <summary>
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
/// </summary>
/// <remarks>
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
/// are both one click away at all times.
/// <para>
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
/// </para>
/// </remarks>
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
{
if (!viewModel.IsUnlocked)
{
return;
}
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
&& viewModel.IsUnlocked)
if (viewModel.IsTerminalShowing)
{
KeyboardHome.Focus();
return;
FocusTerminalWhenLaidOut();
}
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
// native focus, so without this the pane looks selected and every keystroke goes to the button
// instead of the shell until the user clicks inside the terminal by hand.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
else
{
Terminal.Focus();
ReleaseKeyboardTo(KeyboardHome);
}
}
/// <remarks>
/// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
/// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
/// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
/// native focus, so without this the pane looks selected and every keystroke goes to the button instead
/// of the shell until the user clicks inside the terminal by hand.
/// </remarks>
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
{
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
{
FocusTerminalWhenLaidOut();
}
}