Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
+157 -18
View File
@@ -11,9 +11,9 @@ namespace DodoSSH.Client.App.Views;
/// <remarks>
/// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model,
/// because it is a property of the controls and not of the state. What the view models expose is the
/// two facts the policy needs — a session opened, and the vault is no longer unlocked — and both are
/// things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is a plain
/// <c>Focus()</c> call and the other is not.
/// facts the policy needs — a session opened, the vault is no longer unlocked, the palette is open — and
/// all of them are things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is
/// a plain <c>Focus()</c> call and the other is not.
/// </remarks>
internal sealed partial class MainWindow : Window
{
@@ -44,13 +44,121 @@ internal sealed partial class MainWindow : Window
// posted by the page arrives in Body verbatim.
if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
{
// The column decides which of its lists the keyboard belongs to; this window only decides
// that the keyboard should leave the terminal.
ReleaseKeyboardTo(VaultPane.KeyboardTarget);
ReleaseKeyboardTo(KeyboardHome);
}
};
}
/// <summary>
/// Where the keyboard belongs when the terminal is not holding it.
/// </summary>
/// <remarks>
/// <para>
/// Each screen answers for itself, because <c>Focus()</c> on a collapsed control is measurably a no-op
/// that is not replayed when the control is revealed — so a fixed target would swallow the keyboard
/// whenever its own screen was not the one showing. Only two screens have anything focusable on them;
/// the other two are prose, and the window is the fallback there.
/// </para>
/// <para>
/// The window has to be marked <c>Focusable="True"</c> in the markup for that fallback to mean
/// anything — a <c>Window</c> is not focusable by default, and <c>Focus()</c> on one that is not
/// measurably returns false. Without it, closing the palette on Files, Team or Preferences left the
/// 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>
/// </remarks>
private IInputElement KeyboardHome => shell?.Screen switch
{
ShellScreen.Vault => VaultPane.KeyboardTarget,
ShellScreen.Hosts => Hosts.KeyboardTarget,
_ => this,
};
/// <summary>
/// The shortcuts the window owns.
/// </summary>
/// <remarks>
/// <para>
/// A tunnelled handler rather than <c>KeyBindings</c>, because three of these four keys have to be
/// intercepted before the control under the pointer sees them: Escape and the arrows belong to the
/// palette while it is open, and the palette's own text box would otherwise eat them.
/// </para>
/// <para>
/// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
/// Win32 focus Avalonia sees no key events at all — which is why the terminal has its own way out
/// (Ctrl+Shift+F6, handled in the page) and why the shortcuts here can be as ordinary as they like.
/// </para>
/// </remarks>
protected override void OnKeyDown(KeyEventArgs e)
{
if (shell is not { } viewModel)
{
base.OnKeyDown(e);
return;
}
if (e.Key == Key.K && e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
viewModel.ToggleSearchCommand.Execute(null);
e.Handled = true;
}
else if (viewModel.IsSearching)
{
HandlePaletteKey(viewModel, e);
}
base.OnKeyDown(e);
}
/// <remarks>
/// The selection is moved here rather than by letting the list take focus, because the list taking
/// focus is exactly what would stop the query box receiving the next character typed.
/// </remarks>
private static void HandlePaletteKey(MainWindowViewModel viewModel, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
viewModel.CloseSearchCommand.Execute(null);
e.Handled = true;
break;
case Key.Enter:
viewModel.ConnectToSearchResultCommand.Execute(null);
e.Handled = true;
break;
case Key.Down:
Move(viewModel, 1);
e.Handled = true;
break;
case Key.Up:
Move(viewModel, -1);
e.Handled = true;
break;
default:
break;
}
}
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
private static void Move(MainWindowViewModel viewModel, int delta)
{
if (viewModel.SearchResults.Count == 0)
{
return;
}
var current = viewModel.SelectedSearchResult is { } selected
? viewModel.SearchResults.IndexOf(selected)
: -1;
viewModel.SelectedSearchResult =
viewModel.SearchResults[Math.Clamp(current + delta, 0, viewModel.SearchResults.Count - 1)];
}
private void Attach(MainWindowViewModel? viewModel)
{
if (shell is { } previous)
@@ -80,30 +188,61 @@ 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 an unlocked vault, and the vault being unlocked is what
/// reveals the control. Focus() on a collapsed control is measurably a no-op and is not replayed
/// when it is revealed.
/// 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.
/// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal)
|| shell is not { } viewModel)
if (shell is not { } viewModel)
{
return;
}
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)
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
{
ReleaseKeyboardTo(UnlockPassphrase);
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(UnlockPassphrase);
}
wasUnlocked = unlocked;
return;
}
wasUnlocked = unlocked;
// The palette is a text box somebody is expected to start typing into immediately, so opening it
// has to move the caret there — including out of the terminal, which needs the Win32 half as well.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
{
ReleaseKeyboardTo(viewModel.IsSearching ? Palette.QueryBox : KeyboardHome);
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)
{
KeyboardHome.Focus();
return;
}
// 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)
{
Terminal.Focus();
}
}
/// <remarks>