Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s

Four files needed a hand, and all four were two branches adding something in
the same place rather than either changing what the other did.

The shell's constructor now takes both new parameters: main's SFTP session
factory, which it must have because it builds the transfers view model, and
this branch's optional resume handler, which stays last so every existing test
that constructs a shell without one still gets a shell that can only be online
because somebody signed in during this run. App.axaml.cs, ShellFlowTests and
QuickConnectTests pass the pair; the layout suite keeps both of its new fields.

Signing out now detaches the transfers screen exactly as locking does, and the
confirmation says that an open transfer session survives it. That is the same
policy both sides already argue for their own case: signing out destroys this
machine's copy of the vault, not work that authenticated before it.

QuickConnectTests did not compile on main — the SFTP commit added a constructor
parameter and the quick-connect suite, merged from a parallel branch just
before it, was still calling the old one. Fixed here rather than worked around,
since the merged tree has to build.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
980 tests, including the end-to-end suite against real containers.
This commit is contained in:
2026-07-31 11:16:49 +02:00
41 changed files with 5464 additions and 141 deletions
@@ -96,9 +96,15 @@ internal sealed partial class MainWindow : Window
/// </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.
/// Ctrl+K is here rather than on the palette because it has to work when the palette is not showing, and
/// it is a plain handler rather than a <c>KeyBinding</c> so that toggling stays one code path with the
/// rest of the chord set.
/// </para>
/// <para>
/// The palette's own keys are forwarded rather than answered: <see cref="QuickConnect"/> intercepts them
/// on their way down while the focus is inside it, and this is the net for when it is not — a press that
/// arrives with nothing focused, or from a control on the screen behind, still has to close the palette
/// rather than fall through to whatever is underneath it.
/// </para>
/// <para>
/// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
@@ -121,61 +127,12 @@ internal sealed partial class MainWindow : Window
}
else if (viewModel.IsSearching)
{
HandlePaletteKey(viewModel, e);
Palette.HandleKey(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)
@@ -233,11 +190,18 @@ internal sealed partial class MainWindow : Window
return;
}
// 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.
// 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))
{
ReleaseKeyboardTo(viewModel.IsSearching ? Palette.QueryBox : KeyboardHome);
if (!viewModel.IsSearching)
{
ReleaseKeyboardTo(KeyboardHome);
}
return;
}