Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it
ci / build and test (push) Successful in 5m48s
ci / api image (push) Successful in 47s
ci / deploy ssh.dodotech.cloud (push) Failing after 2s
ci / desktop nightly (push) Successful in 3m26s
ci / android head (push) Successful in 10m2s

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.
This commit is contained in:
jaap-jan committed 2026-10-14 17:02:26 +02:00
1 parent 902761d37b
commit 988248fa57
8 files changed
+840 -99

No files matched your search

@@ -2471,6 +2471,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal bool HasSearchResults => SearchResults.Count > 0;
/// <summary>
/// The machine the palette's query names, when what was typed reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
/// than growing a second field.
/// </para>
/// <para>
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
/// </para>
/// <para>
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
/// row offering to dial whatever three letters were typed so far would be noise on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdHocTarget))]
[NotifyPropertyChangedFor(nameof(AdHocAddress))]
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
private VaultViewModel.ManualEndpoint? adHocTarget;
/// <summary>Whether the palette is offering to connect to a typed address.</summary>
internal bool HasAdHocTarget => AdHocTarget is not null;
/// <summary>The typed address as it will be dialled, port and all.</summary>
internal string AdHocAddress => AdHocTarget?.Address ?? string.Empty;
/// <summary>
/// Why a query with an <c>@</c> in it is not an address yet, or empty.
/// </summary>
/// <remarks>
/// Said under the box rather than swallowed, because a query that looks like an address and produces no
/// row to connect to is otherwise a palette that silently ignored the one thing it was asked. It doubles
/// as the password step's own refusal line.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdHocRefusal))]
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
private string adHocRefusal = string.Empty;
/// <summary>Whether there is a refusal to show under the box.</summary>
internal bool HasAdHocRefusal => AdHocRefusal.Length > 0;
/// <summary>
/// Whether the highlight is on the typed address rather than on a saved host.
/// </summary>
/// <remarks>
/// A flag beside <see cref="SelectedSearchResult"/> rather than a row in <see cref="SearchResults"/>,
/// because that list is the vault's own host rows and every reader of it — the ranking, the clicks, the
/// tests — is entitled to find a host in each. At most one of the two is ever set; see
/// <see cref="OnIsAdHocSelectedChanged"/> and <see cref="OnSelectedSearchResultChanged"/>.
/// </remarks>
[ObservableProperty]
private bool isAdHocSelected;
/// <summary>
/// Whether the palette has moved on from the query to asking for the typed address's password.
/// </summary>
/// <remarks>
/// <para>
/// <b>A typed password and nothing else</b>, which is the manual path's own rule and for its reason: a
/// key is the reason to save the machine as a host, and offering the keychain's keys here would be a
/// second binding resolution beside the one saved hosts already go through.
/// </para>
/// <para>
/// A second step inside the palette rather than a dialog, so the whole thing stays on the keyboard:
/// type the address, Enter, type the password, Enter. Escape goes back to the query rather than closing,
/// because a mistyped address is the likeliest reason to want out of this step.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAskingForSearchQuery))]
private bool isAskingForAdHocPassword;
/// <summary>Whether the palette is showing its query box and results, rather than the password step.</summary>
internal bool IsAskingForSearchQuery => !IsAskingForAdHocPassword;
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
/// <remarks>
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
/// last one's password already in the box.
/// </remarks>
[ObservableProperty]
private string adHocPassword = string.Empty;
/// <summary>
/// Whether the palette has nothing to offer at all — no host, no typed address and no reason why not.
/// </summary>
internal bool HasNothingToOffer => !HasSearchResults && !HasAdHocTarget && !HasAdHocRefusal;
/// <summary>Opens the palette, or closes it if it is already open.</summary>
[RelayCommand]
private void ToggleSearch()
@@ -2496,11 +2592,174 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void CloseSearch()
{
IsSearching = false;
IsAskingForAdHocPassword = false;
AdHocPassword = string.Empty;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
IsAdHocSelected = false;
AdHocTarget = null;
AdHocRefusal = string.Empty;
OnPropertyChanged(nameof(HasSearchResults));
OnPropertyChanged(nameof(HasNothingToOffer));
}
/// <summary>
/// Moves the palette's highlight by one row, the typed address first when there is one.
/// </summary>
/// <remarks>
/// Clamped rather than wrapped: a list that jumps from the last row to the first loses people. Here
/// rather than in the palette's code-behind because the typed address is a row that is not in
/// <see cref="SearchResults"/>, and which of the two highlights to set is the view model's to know.
/// </remarks>
internal void MoveSearchSelection(int delta)
{
var offset = HasAdHocTarget ? 1 : 0;
var count = offset + SearchResults.Count;
if (count == 0 || IsAskingForAdHocPassword)
{
return;
}
var current = IsAdHocSelected
? 0
: SelectedSearchResult is { } selected
? offset + SearchResults.IndexOf(selected)
: -1;
var next = Math.Clamp(current + delta, 0, count - 1);
if (offset == 1 && next == 0)
{
IsAdHocSelected = true;
}
else
{
SelectedSearchResult = SearchResults[next - offset];
}
}
/// <summary>
/// Takes the typed address, for a click on its row: the pointer's version of Enter.
/// </summary>
[RelayCommand]
private void ChooseAdHocTarget()
{
if (!HasAdHocTarget)
{
return;
}
IsAdHocSelected = true;
AskForAdHocPassword();
}
/// <summary>
/// Puts the palette back on its query, leaving what was typed there and dropping the password.
/// </summary>
[RelayCommand]
private void BackToSearchQuery()
{
IsAskingForAdHocPassword = false;
AdHocPassword = string.Empty;
AdHocRefusal = string.Empty;
}
/// <summary>
/// Connects to the typed address with the typed password, saving neither.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
/// exactly the one whose key nobody has seen before.
/// </para>
/// <para>
/// The palette closes before the dial rather than after it, as it does for a saved host: the tab the
/// connection opens is where its progress and its failure are drawn, and a palette left up for the length
/// of a handshake would sit over the very card that reports it.
/// </para>
/// <para>
/// Concurrent executions allowed, for the reason <c>VaultViewModel.ConnectAsync</c> gives: this command is
/// still running for as long as the handshake takes, and without the flag a second address typed into the
/// palette meanwhile would be refused as a silent no-op.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ConnectToAdHocTargetAsync()
{
if (Vault is not { } vault || AdHocTarget is not { } endpoint)
{
return;
}
if (AdHocPassword.Length == 0)
{
AdHocRefusal = $"Type the password for {endpoint.Address}.";
return;
}
var password = AdHocPassword;
CloseSearch();
// No token, for the reason VaultViewModel.ConnectAsync gives at length: a connection is abandoned by
// closing its tab, not by anything here.
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Moves the palette on to asking for the typed address's password.</summary>
private void AskForAdHocPassword()
{
AdHocPassword = string.Empty;
AdHocRefusal = string.Empty;
IsAskingForAdHocPassword = true;
}
/// <summary>
/// Reads the query as an address, if it has an <c>@</c> in it, and says why not if it cannot be one.
/// </summary>
private void RefreshAdHocTarget(string query)
{
if (!query.Contains('@', StringComparison.Ordinal))
{
AdHocTarget = null;
AdHocRefusal = string.Empty;
return;
}
if (VaultViewModel.TryParseManualTarget(query, out var endpoint, out var reason))
{
AdHocTarget = endpoint;
AdHocRefusal = string.Empty;
}
else
{
AdHocTarget = null;
AdHocRefusal = reason;
}
}
/// <remarks>Clears the other half of the highlight, so at most one row is ever lit.</remarks>
partial void OnIsAdHocSelectedChanged(bool value)
{
if (value)
{
SelectedSearchResult = null;
}
}
/// <inheritdoc cref="OnIsAdHocSelectedChanged" />
partial void OnSelectedSearchResultChanged(HostRowViewModel? value)
{
if (value is not null)
{
IsAdHocSelected = false;
}
}
/// <summary>
@@ -2515,6 +2774,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private async Task ConnectToSearchResultAsync()
{
// The typed address has no keychain item behind it, so nothing can be resolved for it: the password
// it needs has to be asked for first. See IsAskingForAdHocPassword.
if (IsAdHocSelected && HasAdHocTarget)
{
AskForAdHocPassword();
return;
}
if (Vault is not { } vault || SelectedSearchResult is not { } row)
{
return;
@@ -2552,6 +2819,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (Vault is not { } vault)
{
SelectedSearchResult = null;
IsAdHocSelected = false;
AdHocTarget = null;
AdHocRefusal = string.Empty;
return;
}
@@ -2571,9 +2841,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
SearchResults.Add(host);
}
SelectedSearchResult = SearchResults.FirstOrDefault();
RefreshAdHocTarget(query);
// The typed address is highlighted when there is one, unless a saved host dials exactly that address:
// that host is the same machine with whatever the keychain knows about it — a key, a saved password —
// and Enter should reach it the way it was set up to be reached. The typed row is still one arrow
// away for somebody who wants the password prompt instead.
var saved = AdHocTarget is { } typed
? SearchResults.FirstOrDefault(host =>
string.Equals(host.Address, typed.Address, StringComparison.OrdinalIgnoreCase))
: null;
if (HasAdHocTarget && saved is null)
{
IsAdHocSelected = true;
}
else
{
IsAdHocSelected = false;
SelectedSearchResult = saved ?? SearchResults.FirstOrDefault();
}
OnPropertyChanged(nameof(HasSearchResults));
OnPropertyChanged(nameof(HasNothingToOffer));
}
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>