Public Access
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.
This commit is contained in:
1 parent
902761d37b
commit
988248fa57
8 files changed
+840
-99
No files matched your search
@@ -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,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=">_" 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=">_" 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=">_" 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=">_" 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>
|
||||
|
||||
Reference in new issue
Block a user