From 988248fa57afa15747b6ced0bd81cf1396090c12 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 14 Oct 2026 17:02:26 +0200 Subject: [PATCH 1/7] Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it 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. --- docs/design-import-gaps.md | 2 +- docs/manual-checks.md | 16 + src/DodoSSH.Client.App/App.axaml | 13 + .../Views/QuickConnect.axaml | 239 +++++++++----- .../Views/QuickConnect.axaml.cs | 76 ++++- .../ViewModels/MainWindowViewModel.cs | 292 +++++++++++++++++- .../ViewModels/VaultViewModel.cs | 44 ++- .../QuickConnectTests.cs | 257 +++++++++++++++ 8 files changed, 840 insertions(+), 99 deletions(-) diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 03f34a8..b873f14 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -510,7 +510,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint | Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. | | `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | -| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | +| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". | | Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. | | Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. | | Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. | diff --git a/docs/manual-checks.md b/docs/manual-checks.md index d49fc09..1cb9058 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -1388,6 +1388,22 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter. **Pass:** the palette closes and the connection starts. +### 7.10a Connecting to a machine that is not saved + +Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and +press Enter again. Do it once against a machine whose host key this vault has never seen. + +**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter +it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects. +Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from +the password box goes back to the address with what was typed still in it. + +**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with +"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A +MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between +the two is in how the palette hands the password over, not in the connection. The headless half of this is +`QuickConnectTests`; what it cannot do is dial. + ### 7.11 A host or a bucket made now can be picked on FILES now With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml index 8caa75a..45f86fe 100644 --- a/src/DodoSSH.Client.App/App.axaml +++ b/src/DodoSSH.Client.App/App.axaml @@ -1342,6 +1342,19 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/ConnectPrompt.axaml.cs b/src/DodoSSH.Client.App/Views/ConnectPrompt.axaml.cs new file mode 100644 index 0000000..b7e861a --- /dev/null +++ b/src/DodoSSH.Client.App/Views/ConnectPrompt.axaml.cs @@ -0,0 +1,125 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using DodoSSH.Client.Shell.ViewModels; + +namespace DodoSSH.Client.App.Views; + +/// The empty terminal's prompt: what to connect to, and its password when one is needed. +/// +/// The keys and the caret live here, as the palette's do in and for its reason: a +/// hosts in a bare window under the headless harness, and the window itself cannot +/// be shown there at all. +/// +internal sealed partial class ConnectPrompt : UserControl +{ + public ConnectPrompt() + { + InitializeComponent(); + + // Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is + // in it, and a text box on the route must not get to them first. + AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel); + + // The password line appearing, or going, moves the caret with it: Enter on a name that needs a password + // should leave somebody typing the password, and Escape off it should leave them editing the name. + PasswordLine.PropertyChanged += OnLineChanged; + } + + /// The first line's box, for the caret and for the tests. + internal TextBox TargetBox => Target; + + /// The password line's box, for the caret and for the tests. + internal TextBox PasswordBox => Password; + + private MainWindowViewModel? Shell => DataContext as MainWindowViewModel; + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + // Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes + // visible its boxes are not in the visual tree yet, and focus is refused to anything that is not. + if (change.Property == IsVisibleProperty && change.GetNewValue()) + { + Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded); + } + } + + private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == IsVisibleProperty && IsVisible) + { + Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded); + } + } + + /// Puts the caret on whichever line is asking. + /// + /// Both halves, for the reason gives: the terminal's WebView can still + /// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native + /// call is a caret that receives nothing. + /// + private void TakeKeyboard() + { + if (!IsEffectivelyVisible) + { + return; + } + + if (TopLevel.GetTopLevel(this) is Window window) + { + NativeKeyboardFocus.ReturnTo(window); + } + + if (PasswordLine.IsVisible) + { + Password.Focus(); + } + else + { + Target.Focus(); + Target.CaretIndex = Target.Text?.Length ?? 0; + } + } + + private void OnPromptKey(object? sender, KeyEventArgs e) + { + if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None) + { + return; + } + + switch (e.Key) + { + case Key.Enter: + // Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt, + // and a connection's progress and failure in the tab it opens. + _ = shell.SubmitPromptCommand.ExecuteAsync(null); + e.Handled = true; + break; + + case Key.Escape: + shell.CancelPromptCommand.Execute(null); + e.Handled = true; + break; + + case Key.Tab: + // Only when there is something to complete to; otherwise Tab moves the focus, as it always does. + e.Handled = shell.CompletePrompt(); + + if (e.Handled) + { + Target.CaretIndex = Target.Text?.Length ?? 0; + } + + break; + + default: + break; + } + } +} diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index 115f189..976d17f 100644 --- a/src/DodoSSH.Client.App/Views/MainWindow.axaml +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -285,9 +285,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +