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

+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
+159 -80
View File
@@ -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,93 +36,171 @@
BorderThickness="1" CornerRadius="14">
<StackPanel>
<!--
">_" 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
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<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" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<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
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<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" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
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.
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
-->
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Green when this host has a terminal open right now, grey otherwise — the same two-state
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
-->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
Width="8" Height="8" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</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. 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}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
</StackPanel>
<!--
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.
============ the typed address's password ============
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
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.
-->
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Green when this host has a terminal open right now, grey otherwise — the same two-state
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
-->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
Width="8" Height="8" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
<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>
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<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" Margin="18,16"
Text="No host matches that."
IsVisible="{Binding !HasSearchResults}" />
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
<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>
@@ -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);
}
Query.Focus();
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>