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
+763 -22

No files matched your search

+1 -1
View File
@@ -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. |
+16
View File
@@ -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
+13
View File
@@ -1342,6 +1342,19 @@
<Style Selector="TextBlock.qcrow-subtext">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
The palette's typed-address row, which sits above the list rather than in it and so has no ListBoxItem
to take :pointerover and :selected from. The same two states, on the row itself.
-->
<Style Selector="Border.qcrow.adhoc:pointerover">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Border.qcrow.adhoc.selected, Border.qcrow.adhoc.selected:pointerover">
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<Style Selector="Border.qcrow.adhoc.selected TextBlock.qcrow-text, Border.qcrow.adhoc.selected TextBlock.qcrow-subtext">
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
</Style>
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
<!--
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
@@ -5,7 +5,8 @@
x:DataType="vm:MainWindowViewModel">
<!--
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
Enter, and give a password, to connect to a machine that is not saved at all.
The design's box promises "search hosts · run command". Only the first half is here, because there is no
snippet or saved-command item type in the vault for the second half to run — see
@@ -35,6 +36,8 @@
BorderThickness="1" CornerRadius="14">
<StackPanel>
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
<!--
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
@@ -46,12 +49,44 @@
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts" Margin="10,0,0,0"
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<!--
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
own selection, because it is not a host and the list below holds hosts only — see the remark on
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
selected state is a class here because there is no ListBoxItem around it to carry :selected.
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
to the same question, and the one thing worth knowing before choosing it.
-->
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text="&gt;_" FontSize="12"
FontWeight="Bold" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Border>
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}"
IsVisible="{Binding HasAdHocRefusal}" />
<!--
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
took focus would take the arrow keys away from the box being typed into.
@@ -114,9 +149,13 @@
</ListBox.ItemTemplate>
</ListBox>
<!--
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
matches nothing is often a machine that was never saved.
-->
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
Text="No host matches that."
IsVisible="{Binding !HasSearchResults}" />
Text="No host matches that. Type user@host to connect without saving it."
IsVisible="{Binding HasNothingToOffer}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
@@ -124,6 +163,46 @@
</Border>
</StackPanel>
<!--
============ the typed address's password ============
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
-->
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<StackPanel Margin="16,14" Spacing="8">
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
PlaceholderText="password" FontSize="14" Height="38" />
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
<TextBlock Classes="hint" FontSize="12"
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
</StackPanel>
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="ENTER to connect · ESC to go back" />
</Border>
</StackPanel>
</StackPanel>
</Border>
</Border>
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
Results.Tapped += OnResultTapped;
AdHocRow.Tapped += OnAdHocRowTapped;
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
// the view model's change arrives ahead of the binding that reveals the step.
QueryStep.PropertyChanged += OnStepChanged;
PasswordStep.PropertyChanged += OnStepChanged;
}
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
internal TextBox QueryBox => Query;
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
internal TextBox PasswordBox => Password;
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
internal Control AdHocTargetRow => AdHocRow;
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
/// <summary>
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
return;
}
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
if (shell.IsAskingForAdHocPassword)
{
switch (e.Key)
{
case Key.Escape:
shell.BackToSearchQueryCommand.Execute(null);
e.Handled = true;
break;
case Key.Enter:
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
e.Handled = true;
break;
default:
break;
}
return;
}
switch (e.Key)
{
case Key.Escape:
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
break;
case Key.Down:
Move(shell, 1);
shell.MoveSearchSelection(1);
e.Handled = true;
break;
case Key.Up:
Move(shell, -1);
shell.MoveSearchSelection(-1);
e.Handled = true;
break;
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
NativeKeyboardFocus.ReturnTo(window);
}
if (PasswordStep.IsVisible)
{
Password.Focus();
}
else
{
Query.Focus();
}
}
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
private static void Move(MainWindowViewModel shell, int delta)
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (shell.SearchResults.Count == 0)
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
{
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
}
}
/// <summary>
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
/// </summary>
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
{
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
{
return;
}
var current = shell.SelectedSearchResult is { } selected
? shell.SearchResults.IndexOf(selected)
: -1;
shell.SelectedSearchResult =
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
e.Handled = true;
shell.ChooseAdHocTargetCommand.Execute(null);
}
/// <inheritdoc />
@@ -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>
@@ -11480,7 +11480,32 @@ internal sealed partial class VaultViewModel(
ManualStatus = string.Empty;
await ConnectToAsync(
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on a typed address with a typed password, saving neither.
/// </summary>
/// <remarks>
/// <para>
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
/// over what it read rather than writing into the phone's fields to have them read back here.
/// </para>
/// <para>
/// The caller has already refused an unreadable address and an empty password, which is why neither is
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
/// </para>
/// </remarks>
internal Task ConnectToEndpointAsync(
ManualEndpoint endpoint,
string password,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(endpoint);
return ConnectToAsync(
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
// different connections, and a strip showing the same name twice would be the tab equivalent of
// the log entry this also names.
@@ -11488,8 +11513,8 @@ internal sealed partial class VaultViewModel(
$"{endpoint.Username}@{endpoint.Hostname}",
endpoint.Hostname,
endpoint.Port),
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
cancellationToken).ConfigureAwait(true);
new HostAuthentication(endpoint.Username, new SshPasswordCredential(password)),
cancellationToken);
}
/// <summary>
@@ -11514,7 +11539,7 @@ internal sealed partial class VaultViewModel(
/// would make this the only field in the product with its own address grammar.
/// </para>
/// </remarks>
private static bool TryParseManualTarget(
internal static bool TryParseManualTarget(
string typed,
[NotNullWhen(true)] out ManualEndpoint? endpoint,
[NotNullWhen(false)] out string? reason)
@@ -11673,7 +11698,16 @@ internal sealed partial class VaultViewModel(
/// credential rather than from the host — so a username on the shared record would be a field that is
/// null for every connection but this one.
/// </remarks>
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
internal sealed record ManualEndpoint(string Username, string Hostname, int Port)
{
/// <summary>The address as it will be dialled, in the grammar it was typed in.</summary>
/// <remarks>
/// The same shape as <see cref="HostRowViewModel.Address"/> and the connection log's own entries, so
/// the palette can compare what was typed against a saved host's address with a plain string match.
/// </remarks>
public string Address =>
string.Create(CultureInfo.InvariantCulture, $"{Username}@{Hostname}:{Port}");
}
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
/// <remarks>
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
Token);
}
// ---- A machine that is not saved ----
/// <remarks>
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
/// </remarks>
[Fact]
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5:2222";
Relayout(window);
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
});
}
/// <remarks>
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
/// makes the row comparable with a saved host's address, which always carries one.
/// </remarks>
[Fact]
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "root@box";
shell.AdHocAddress.ShouldBe("root@box:22");
});
}
/// <remarks>
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
/// machine that was never saved.
/// </remarks>
[Fact]
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "nothing-is-called-this";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("user@host", StringComparison.Ordinal),
"the hint names the other thing the box takes");
});
}
/// <remarks>
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
/// </remarks>
[Fact]
public async Task AnAddressThatCannotBeReadSaysWhy()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@box:99999";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("port", StringComparison.Ordinal),
"the refusal is the manual parser's own sentence");
});
}
/// <remarks>
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
/// </remarks>
[Fact]
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
shell.IsAskingForAdHocPassword.ShouldBeTrue();
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
});
}
/// <remarks>
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
/// to keep.
/// </remarks>
[Fact]
public async Task EscapeFromThePasswordGoesBackToTheAddress()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
shell.IsAskingForAdHocPassword.ShouldBeFalse();
shell.SearchText.ShouldBe("root@10.0.0.5");
shell.AdHocPassword.ShouldBeEmpty();
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
});
}
/// <remarks>
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
/// password with an empty one answers "authentication failed", which is a sentence about the machine
/// when the problem is the box.
/// </remarks>
[Fact]
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeTrue();
shell.HasAdHocRefusal.ShouldBeTrue();
started.ShouldBeEmpty();
});
}
/// <remarks>
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
/// handling; what the palette promises is the attempt and the closing.
/// </remarks>
[Fact]
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
var hostsBefore = vault.Hosts.Count;
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5:2222";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
var attempt = started.ShouldHaveSingleItem();
attempt.Address.ShouldBe("root@10.0.0.5:2222");
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
});
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
}
/// <remarks>
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
/// it was set up to be reached. The typed row is still offered, one arrow away.
/// </remarks>
[Fact]
public async Task AnAddressASavedHostDialsHighlightsThatHost()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "deploy@host-3.internal";
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
});
}
/// <remarks>
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
/// </remarks>
[Fact]
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
// Every seeded host's address starts with this, and none of them is exactly it.
shell.SearchText = "deploy@host";
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
shell.IsAdHocSelected.ShouldBeTrue();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue();
shell.SelectedSearchResult.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
palette.QueryBox.IsFocused.ShouldBeTrue();
});
}
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
[Fact]
public async Task ClickingTheAddressAsksForItsPassword()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5";
Relayout(window);
var row = palette.AdHocTargetRow;
window.MouseDown(Centre(row, window), MouseButton.Left);
window.MouseUp(Centre(row, window), MouseButton.Left);
shell.IsAskingForAdHocPassword.ShouldBeTrue();
shell.IsSearching.ShouldBeTrue();
});
}
// ---- Helpers ----
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>