Public Access
Merge origin/main into claude/spelunk-api-key-tenant-6373cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6f6e92f315
25 files changed
+2667
-417
No files matched your search
@@ -843,6 +843,30 @@
|
||||
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
|
||||
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
|
||||
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
|
||||
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
|
||||
on the password line and sets nothing in common with.
|
||||
-->
|
||||
<Style Selector="TextBox.prompt">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
|
||||
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
|
||||
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
|
||||
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
|
||||
|
||||
THREE THINGS A PHONE CHANGES.
|
||||
|
||||
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
|
||||
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
|
||||
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
|
||||
and the system back gesture, which PhoneShell routes to the same command.
|
||||
|
||||
A CONNECT button, because the software keyboard's own return key may commit or may only close the
|
||||
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
|
||||
for every keyboard that does send it.
|
||||
|
||||
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
|
||||
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
|
||||
-->
|
||||
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="12" Padding="14,12">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
|
||||
|
||||
<!-- ============ what to connect to ============ -->
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||
AutomationProperties.Name="Connect to"
|
||||
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</Grid>
|
||||
|
||||
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
|
||||
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ the password, when one is needed ============ -->
|
||||
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||
Text="{Binding PromptPasswordLabel}" />
|
||||
<!--
|
||||
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
|
||||
-->
|
||||
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
|
||||
Text="{Binding PromptPassword}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||
|
||||
<!--
|
||||
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
|
||||
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
|
||||
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
|
||||
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding SubmitPromptCommand}" />
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,45 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
|
||||
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
|
||||
/// type nothing and look as though it should.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
|
||||
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
|
||||
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
|
||||
/// fields are null at run time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectPrompt : UserControl
|
||||
{
|
||||
public ConnectPrompt()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
var line = this.FindControl<StackPanel>("PasswordLine")!;
|
||||
var box = this.FindControl<TextBox>("Password")!;
|
||||
|
||||
line.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && line.IsVisible)
|
||||
{
|
||||
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
|
||||
// its line becomes visible, and focusing a control with none raises no keyboard.
|
||||
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="20" Margin="12,0,0,0" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
|
||||
Padding="9,0" VerticalContentAlignment="Center"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource Text}" FontSize="14" />
|
||||
@@ -82,6 +82,42 @@
|
||||
<!-- ============ sync + conflicts ============ -->
|
||||
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
|
||||
|
||||
<!--
|
||||
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
|
||||
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
|
||||
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
|
||||
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
|
||||
|
||||
Above the list rather than over it, like the panels below: the hosts that also match what was typed
|
||||
stay in view, and one of them may be the same machine with a key already bound to it.
|
||||
|
||||
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
|
||||
what keeps the password out of the software keyboard's dictionary, not only off the screen.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
|
||||
<StackPanel Spacing="9">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="primary" Height="44" Content="CONNECT"
|
||||
Command="{Binding ConnectToFilterAddressCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
|
||||
|
||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -674,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// The Connections prompt's password line, which back steps off onto the name it asked about — the
|
||||
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
|
||||
// surface would throw the name away along with the question.
|
||||
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
|
||||
{
|
||||
current.CancelPromptCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current.IsShowingPages)
|
||||
{
|
||||
current.ShowScreenCommand.Execute(current.Screen);
|
||||
|
||||
@@ -345,9 +345,11 @@
|
||||
running this surface is where a connection is made rather than a page apologising for having
|
||||
nothing on it. Two things are offered, in the order somebody reaches for them.
|
||||
|
||||
A box, because the case this application had no answer for is the address somebody has just been
|
||||
given. Everything else here starts from a keychain item, which is right for a machine used twice and
|
||||
is several screens of work for one used once.
|
||||
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
|
||||
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
|
||||
host's name or user@host for a machine that is not saved, and asks for a password only when one is
|
||||
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
|
||||
machine several screens away from here.
|
||||
|
||||
And the machines already connected to, under it, because that is the commoner half by far: the
|
||||
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
||||
@@ -359,44 +361,7 @@
|
||||
|
||||
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
||||
|
||||
<!--
|
||||
The manual target. One box in the grammar people already have — user@host, or user@host:port —
|
||||
rather than three, because three boxes on a phone is three taps between three keyboards for
|
||||
something that arrives as one string.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
|
||||
PlaceholderText="user@host or user@host:port">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<!--
|
||||
A typed password and nothing else. Offering the keychain's keys here would be a second binding
|
||||
resolution beside the one the connect path already has, and a key is the reason to save the
|
||||
machine as a host rather than dial it from a box.
|
||||
|
||||
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
|
||||
this is not prose, which is the difference between dots on screen and dots on screen with the
|
||||
password in the IME's dictionary. See Theme/Phone.axaml.
|
||||
-->
|
||||
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
|
||||
PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
|
||||
|
||||
<!--
|
||||
Beside the box rather than on the shell's status line. What fails here is about what was typed —
|
||||
a missing account, a port that is not a number — and a sentence about a text box belongs under
|
||||
the text box.
|
||||
-->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding Vault.ManualStatus}"
|
||||
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<views:ConnectPrompt />
|
||||
|
||||
<TextBlock Classes="body"
|
||||
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
||||
@@ -410,14 +375,13 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<!--
|
||||
Tapping one does not connect, and that is deliberate rather than a missing step. A row
|
||||
that names a keychain host goes to that host with its connect bar — which is where its
|
||||
key, or its password box, or its refusal lives — and one that names only an address goes
|
||||
back into the box above, without the password, which was never stored. Either way what
|
||||
happens next is visible before anything is dialled. See ConnectToRecentCommand.
|
||||
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
|
||||
connects, or asks for its password there; an address asks for its password, which was
|
||||
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
|
||||
MainWindowViewModel.ChoosePromptRecentCommand.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
|
||||
@@ -873,6 +873,29 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE EMPTY TERMINAL'S PROMPT LINES, which are typed into the way a shell is: no fill, no border and no
|
||||
padding in any state, so the text sits on the pane exactly where the label before it leaves off, and the
|
||||
caret is the only sign the line takes input. A box drawn around it would turn a prompt back into a form.
|
||||
See ConnectPrompt.axaml. After the base rules and .pathbox, for the reason this file keeps restating.
|
||||
-->
|
||||
<Style Selector="TextBox.prompt">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -1200,10 +1223,14 @@
|
||||
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
|
||||
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
|
||||
are flat and quiet until the pointer finds them.
|
||||
|
||||
No padding, because the box is fixed: Fluent's own 8,5,8,6 left a glyph ten pixels of width inside
|
||||
twenty-six, and every 15px Material icon on the SFTP panes was drawn with its sides cut off.
|
||||
-->
|
||||
<Style Selector="Button.paneicon">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
@@ -1342,6 +1369,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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.ConnectPrompt"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
What the terminal's rectangle holds when Terminal is pressed with nothing open: a prompt, because asking
|
||||
is what a terminal with nothing in it does. It used to be an empty frame, with the tab row's "+" the only
|
||||
thing on it that did anything.
|
||||
|
||||
Two lines, in the order ssh itself asks. The first takes a saved host's name — matched exactly, with the
|
||||
names that match so far printed under it for Tab or a click to take — or user@host[:port] for a machine
|
||||
that is not saved. The second appears only when a password is needed, worded as ssh words it, and is
|
||||
never offered for a host whose keychain binding supplies its own credential.
|
||||
|
||||
Drawn by Avalonia in the WebView's own rectangle, which the occlusion rule allows only because the two
|
||||
are exclusive: this shows while there are no tabs, and the WebView only for a selected tab with a
|
||||
session. See MainWindowViewModel.IsConnectPromptShowing.
|
||||
|
||||
Everything it does goes through the vault's own connect paths — ConnectToHostAsync for a saved host,
|
||||
ConnectToEndpointAsync for an address — so every refusal those make is made here too, and nothing typed
|
||||
at this prompt is written to the keychain.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="18,16" Spacing="3" TextElement.FontFamily="{StaticResource MonoFont}">
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="No session open. Connect to a saved host by name, or to any machine as user@host[:port]." />
|
||||
|
||||
<!-- ============ what to connect to ============ -->
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,14,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
The saved hosts that match so far, best first, the first one Tab's. A wrapped row rather than a list,
|
||||
because this is a shell's completion line and not a picker: a name and an address, nothing to scroll.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding PromptMatches}" Margin="0,2,0,0"
|
||||
IsVisible="{Binding ShowsPromptMatches}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,2" Margin="0,0,6,0" CornerRadius="5"
|
||||
Cursor="Hand" ToolTip.Tip="{Binding Address}"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ the password, when one is needed ============ -->
|
||||
<Grid x:Name="PasswordLine" ColumnDefinitions="Auto,*" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" Foreground="{StaticResource Text}"
|
||||
Text="{Binding PromptPasswordLabel}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Password" Classes="prompt" Margin="8,0,0,0" PasswordChar="•"
|
||||
Text="{Binding PromptPassword}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||
|
||||
<TextBlock Classes="mono" FontSize="10.5" Margin="0,10,0,0" Foreground="{StaticResource TextGhost}"
|
||||
Text="ENTER to connect · TAB to complete · ESC to go back · CTRL+K to search hosts" />
|
||||
|
||||
<!--
|
||||
============ where you have been ============
|
||||
|
||||
The machines most recently connected to, newest first — the vault's own connection log, deduplicated
|
||||
by address, so it survives a restart and arrives on a new machine with the keychain. A click puts the
|
||||
row on the prompt line and carries on from there exactly as if it had been typed: a saved host
|
||||
connects, or asks for its password; an address asks for its password.
|
||||
-->
|
||||
<StackPanel Margin="0,22,0,0" Spacing="1" IsVisible="{Binding HasRecentConnections}">
|
||||
<TextBlock Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}" Text="recent"
|
||||
Margin="0,0,0,4" />
|
||||
<ItemsControl ItemsSource="{Binding RecentConnections}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,3" CornerRadius="5" Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Address}" />
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding HostLabel}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,125 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The empty terminal's prompt: what to connect to, and its password when one is needed.</summary>
|
||||
/// <remarks>
|
||||
/// The keys and the caret live here, as the palette's do in <see cref="QuickConnect"/> and for its reason: a
|
||||
/// <see cref="UserControl"/> hosts in a bare window under the headless harness, and the window itself cannot
|
||||
/// be shown there at all.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectPrompt : UserControl
|
||||
{
|
||||
public ConnectPrompt()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is
|
||||
// in it, and a text box on the route must not get to them first.
|
||||
AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel);
|
||||
|
||||
// The password line appearing, or going, moves the caret with it: Enter on a name that needs a password
|
||||
// should leave somebody typing the password, and Escape off it should leave them editing the name.
|
||||
PasswordLine.PropertyChanged += OnLineChanged;
|
||||
}
|
||||
|
||||
/// <summary>The first line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox TargetBox => Target;
|
||||
|
||||
/// <summary>The password line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox PasswordBox => Password;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
// Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes
|
||||
// visible its boxes are not in the visual tree yet, and focus is refused to anything that is not.
|
||||
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the caret on whichever line is asking.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView can still
|
||||
/// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native
|
||||
/// call is a caret that receives nothing.
|
||||
/// </remarks>
|
||||
private void TakeKeyboard()
|
||||
{
|
||||
if (!IsEffectivelyVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is Window window)
|
||||
{
|
||||
NativeKeyboardFocus.ReturnTo(window);
|
||||
}
|
||||
|
||||
if (PasswordLine.IsVisible)
|
||||
{
|
||||
Password.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Target.Focus();
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPromptKey(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
// Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt,
|
||||
// and a connection's progress and failure in the tab it opens.
|
||||
_ = shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Tab:
|
||||
// Only when there is something to complete to; otherwise Tab moves the focus, as it always does.
|
||||
e.Handled = shell.CompletePrompt();
|
||||
|
||||
if (e.Handled)
|
||||
{
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,9 +285,8 @@
|
||||
<!--
|
||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it, and the surface with
|
||||
no tab open is answered by the prompt below instead — see ConnectPrompt.axaml.
|
||||
-->
|
||||
<!--
|
||||
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||
@@ -309,6 +308,16 @@
|
||||
<views:ConnectingCard x:Name="ConnectingPane"
|
||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
The third: nothing open at all, which is what pressing Terminal on the rail with no
|
||||
session reaches. A prompt asking what to connect to, where an empty frame used to be.
|
||||
Exclusive with the WebView for the same reason the card above is — no tab, no session —
|
||||
and FallbackValue=False for the reason the WebView's own binding gives. See
|
||||
ConnectPrompt.axaml.
|
||||
-->
|
||||
<views:ConnectPrompt x:Name="PromptPane"
|
||||
IsVisible="{Binding IsConnectPromptShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||
browser process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
@@ -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.ConnectToEndpointAsync 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 />
|
||||
|
||||
@@ -209,10 +209,9 @@
|
||||
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||
recorded where it is drawn.
|
||||
|
||||
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
||||
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||
faithfully carried over rather than a functional difference invented to justify it.
|
||||
UP is arrow_upward on both panes. The design drew arrow_downward on the host/bucket pane, and that
|
||||
was carried over until it was reported as a wrong icon — which it is: both buttons go to the parent
|
||||
directory, and a down arrow under the tooltip "Up one directory" says the opposite of what it does.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||
@@ -398,8 +397,8 @@
|
||||
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||
the style moves to the design's 10px tracked label, not the wording.
|
||||
|
||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
UP is arrow_upward, the same as the local pane's; see that pane's own remark for why it is no longer
|
||||
the design's arrow_downward.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
@@ -446,7 +445,7 @@
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
@@ -546,7 +545,7 @@
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||
|
||||
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
|
||||
/// <summary>The keychain host this was, if it was one.</summary>
|
||||
/// <remarks>
|
||||
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
||||
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
|
||||
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
|
||||
/// authentication behind it, and an address has only the box it was typed into.
|
||||
/// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
|
||||
/// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
|
||||
/// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
|
||||
/// </remarks>
|
||||
internal Guid? HostId => entry.Secret.HostId;
|
||||
|
||||
|
||||
@@ -1529,10 +1529,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// <b>Not gated on there being a tab, because the last clause already is.</b> A selected tab with a
|
||||
/// session is what this needs, so the surface with nothing open — which the rail's Terminal entry reaches
|
||||
/// deliberately — has the WebView collapsed, and that is the rectangle <see cref="IsConnectPromptShowing"/>
|
||||
/// draws its prompt in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
@@ -1860,16 +1860,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Switches to the terminal surface.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
|
||||
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
|
||||
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
|
||||
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
|
||||
/// button, and a button needs a command.
|
||||
/// The other half of <see cref="ShowScreenCommand"/>: the phone's bottom bar and the desktop rail's
|
||||
/// Terminal entry both name the terminal beside the pages, so the destination needs a button, and a
|
||||
/// button needs a command.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
|
||||
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
|
||||
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
|
||||
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
|
||||
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
|
||||
/// able to reach deliberately.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -1956,57 +1954,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasRecentConnections));
|
||||
}
|
||||
|
||||
/// <summary>Goes back to a machine that has been connected to before.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
|
||||
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
|
||||
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
|
||||
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
|
||||
/// path that had to answer all of that again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
|
||||
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
|
||||
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
|
||||
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
|
||||
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
|
||||
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
|
||||
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
|
||||
/// manual path, and a field that filled itself in would be claiming otherwise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ConnectToRecent(ConnectionLogRowViewModel row)
|
||||
{
|
||||
if (row is null || Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
vault.AskAboutHostCommand.Execute(known);
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.ManualTarget = row.Address;
|
||||
vault.ManualStatus = string.Empty;
|
||||
}
|
||||
|
||||
// ---- The rail's own page grouping ----
|
||||
|
||||
/// <summary>
|
||||
@@ -2373,7 +2320,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
internal bool HasTabs => Tabs.Count > 0;
|
||||
|
||||
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
||||
private void RaiseTabState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTabs));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes one terminal, ending its shell.
|
||||
@@ -2444,6 +2395,343 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- The empty terminal's prompt ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's rectangle is asking what to connect to: the terminal surface, with nothing open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The desktop's answer to pressing Terminal with no session.</b> The surface used to draw an empty
|
||||
/// frame there — the tab row's "+" was the only thing on it that did anything. A terminal with nothing in
|
||||
/// it should do what a terminal does, which is ask: so the frame holds a prompt that takes a saved host's
|
||||
/// name, or <c>user@host</c> for a machine that is not saved, and asks for a password when one is needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Exclusive with the renderer by construction: the WebView shows only for a selected tab with a session,
|
||||
/// and this only while there are no tabs at all. That is what lets Avalonia draw it in the WebView's
|
||||
/// rectangle, the way <c>ConnectingCard</c> already is. See <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsConnectPromptShowing => IsTerminalSurface && !HasTabs;
|
||||
|
||||
/// <summary>What was typed at the prompt: a saved host's name, or <c>user@host[:port]</c>.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptText = string.Empty;
|
||||
|
||||
/// <summary>The password typed at the prompt's second line.</summary>
|
||||
/// <remarks>
|
||||
/// Cleared the moment it is used or abandoned. It is never written into the vault's own password box
|
||||
/// either: that box carries a remember-this tick the prompt never offered — see
|
||||
/// <c>VaultViewModel.ConnectToHostAsync</c>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string promptPassword = string.Empty;
|
||||
|
||||
/// <summary>Whether the prompt has moved on to asking for a password.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPromptAskingForTarget))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsPromptMatches))]
|
||||
private bool isPromptAskingForPassword;
|
||||
|
||||
/// <summary>Whether the prompt is still asking what to connect to.</summary>
|
||||
internal bool IsPromptAskingForTarget => !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The second line's prompt, in <c>ssh</c>'s own words: whose password it is.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptPasswordLabel = string.Empty;
|
||||
|
||||
/// <summary>Why the prompt refused what was typed, or empty.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasPromptRefusal))]
|
||||
private string promptRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to print under the prompt.</summary>
|
||||
internal bool HasPromptRefusal => PromptRefusal.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Saved hosts whose name or address matches what has been typed so far, best first; Tab takes the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for a query with an <c>@</c> in it, which is an address rather than a name, and for an empty
|
||||
/// one: a list of every host under a prompt nobody has typed into yet is the hosts screen, drawn worse.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> PromptMatches { get; } = [];
|
||||
|
||||
/// <summary>Whether there are matches to print under the prompt.</summary>
|
||||
internal bool HasPromptMatches => PromptMatches.Count > 0;
|
||||
|
||||
/// <summary>Whether the matches are drawn: only while the name is being asked for, not under its password.</summary>
|
||||
internal bool ShowsPromptMatches => HasPromptMatches && !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The saved host the password line is for, when it is for one.</summary>
|
||||
private HostRowViewModel? promptHost;
|
||||
|
||||
/// <summary>The typed address the password line is for, when it is for one.</summary>
|
||||
private VaultViewModel.ManualEndpoint? promptEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Enter at the prompt: takes what was typed, or — on the password line — connects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A name is matched exactly, ignoring case, because Enter on a name is a decision and not a search: the
|
||||
/// matches printed under the prompt are how a partial name becomes a whole one, by Tab or by a click.
|
||||
/// Two hosts with the same name in two vaults are refused rather than guessed between, and pointed at
|
||||
/// the palette, which shows which vault each came from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Concurrent executions allowed, for the reason <see cref="ConnectToAdHocTargetAsync"/> gives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task SubmitPromptAsync()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
await ConnectFromPromptAsync(vault).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var typed = PromptText.Trim();
|
||||
|
||||
if (typed.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the name of a saved host, or user@host for a machine that is not saved.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
if (!VaultViewModel.TryParseManualTarget(typed, out var endpoint, out var reason))
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
AskForPromptPassword(endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
var named = vault.Hosts
|
||||
.Where(host => string.Equals(host.Label, typed, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
switch (named.Count)
|
||||
{
|
||||
case 0:
|
||||
PromptRefusal = PromptMatches.Count > 0
|
||||
? $"No saved host is called \"{typed}\". Press Tab for {PromptMatches[0].Label}, or type user@host."
|
||||
: $"No saved host is called \"{typed}\". Type user@host to reach a machine that is not saved.";
|
||||
return;
|
||||
|
||||
case > 1:
|
||||
PromptRefusal =
|
||||
$"More than one saved host is called \"{typed}\". Press Ctrl+K to choose between them.";
|
||||
return;
|
||||
|
||||
default:
|
||||
await TakePromptHostAsync(vault, named[0]).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A click on one of the matches printed under the prompt.</summary>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptMatchAsync(HostRowViewModel? host)
|
||||
{
|
||||
if (Vault is not { } vault || host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = host.Label;
|
||||
await TakePromptHostAsync(vault, host).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A click on one of the recent connections printed under the prompt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
|
||||
/// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
|
||||
/// password, its refusals — exactly as typing its name would. One that names none was typed in as an
|
||||
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
|
||||
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
|
||||
/// password was never stored, which is the whole point of that path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
|
||||
/// the rest of the application is deliberately not showing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
||||
{
|
||||
if (Vault is not { } vault || row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
PromptText = known.Label;
|
||||
await TakePromptHostAsync(vault, known).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = row.Address;
|
||||
|
||||
if (VaultViewModel.TryParseManualTarget(row.Address, out var endpoint, out var reason))
|
||||
{
|
||||
AskForPromptPassword(endpoint);
|
||||
}
|
||||
else
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape at the prompt: off the password line back to the name, or clears the name.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void CancelPrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Tab at the prompt: completes to the best match, if there is one.</summary>
|
||||
/// <returns>Whether anything was completed, so the key can move the focus as usual when it was not.</returns>
|
||||
internal bool CompletePrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword || PromptMatches.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PromptText = PromptMatches[0].Label;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Connects to a saved host, or asks for its password first if it needs one.</summary>
|
||||
private async Task TakePromptHostAsync(VaultViewModel vault, HostRowViewModel host)
|
||||
{
|
||||
if (VaultViewModel.AsksForAPassword(host))
|
||||
{
|
||||
promptHost = host;
|
||||
promptEndpoint = null;
|
||||
PromptPasswordLabel = $"{host.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPrompt();
|
||||
|
||||
// No password to offer and nothing to remember: the binding supplies the credential. Any refusal —
|
||||
// a key deleted on another machine, a host with no username — lands on the status line, as it does
|
||||
// for the palette.
|
||||
await vault.ConnectToHostAsync(host, string.Empty, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Moves the prompt on to the password for a typed address.</summary>
|
||||
private void AskForPromptPassword(VaultViewModel.ManualEndpoint endpoint)
|
||||
{
|
||||
promptHost = null;
|
||||
promptEndpoint = endpoint;
|
||||
PromptPasswordLabel = $"{endpoint.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
}
|
||||
|
||||
/// <summary>The password line's Enter: dials whichever of the two the line is for.</summary>
|
||||
private async Task ConnectFromPromptAsync(VaultViewModel vault)
|
||||
{
|
||||
if (PromptPassword.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the password, or press Esc to go back.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = PromptPassword;
|
||||
var host = promptHost;
|
||||
var endpoint = promptEndpoint;
|
||||
|
||||
// Before the dial, so the password does not wait in the box for the length of a handshake. The tab the
|
||||
// connection opens replaces the prompt anyway; this is what is there the next time nothing is open.
|
||||
ResetPrompt();
|
||||
|
||||
if (host is not null)
|
||||
{
|
||||
await vault.ConnectToHostAsync(host, password, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else if (endpoint is not null)
|
||||
{
|
||||
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the prompt back to an empty first line.</summary>
|
||||
private void ResetPrompt()
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal is about what was typed, so typing again clears it; the matches follow every keystroke.
|
||||
/// </remarks>
|
||||
partial void OnPromptTextChanged(string value)
|
||||
{
|
||||
PromptRefusal = string.Empty;
|
||||
|
||||
PromptMatches.Clear();
|
||||
|
||||
var query = value.Trim();
|
||||
|
||||
if (Vault is { } vault && query.Length > 0 && !query.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
foreach (var host in HostsMatching(vault, query).Take(5))
|
||||
{
|
||||
PromptMatches.Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasPromptMatches));
|
||||
OnPropertyChanged(nameof(ShowsPromptMatches));
|
||||
}
|
||||
|
||||
// ---- Quick connect ----
|
||||
|
||||
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
||||
@@ -2479,6 +2767,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 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 connect prompt's parser and
|
||||
/// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses 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 connect prompt
|
||||
/// 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()
|
||||
@@ -2504,11 +2888,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 connect prompt'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>
|
||||
@@ -2523,6 +3070,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;
|
||||
@@ -2560,13 +3115,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
IsAdHocSelected = false;
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var query = SearchText.Trim();
|
||||
|
||||
var matches = query.Length == 0
|
||||
? vault.Hosts.AsEnumerable()
|
||||
foreach (var host in HostsMatching(vault, query).Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
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>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
|
||||
/// <remarks>
|
||||
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
|
||||
/// machines in the same order.
|
||||
/// </remarks>
|
||||
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
|
||||
query.Length == 0
|
||||
? vault.Hosts
|
||||
: vault.Hosts
|
||||
.Select(host => (host, rank: Rank(host, query)))
|
||||
.Where(candidate => candidate.rank < int.MaxValue)
|
||||
@@ -2574,16 +3168,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||
.Select(candidate => candidate.host);
|
||||
|
||||
foreach (var host in matches.Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
SelectedSearchResult = SearchResults.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -5656,6 +6240,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||
|
||||
@@ -4250,32 +4250,52 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
||||
private bool isAskingForConnectPassword;
|
||||
|
||||
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
||||
/// <summary>
|
||||
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
||||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
||||
/// would make the ordinary case three taps between three keyboards on a phone.
|
||||
/// <para>
|
||||
/// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt on the
|
||||
/// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine
|
||||
/// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is
|
||||
/// always there, the design already calls it "Search or connect…", and <c>user@host</c> is what people
|
||||
/// type into a box, so it reads the address rather than the screen growing a second field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
|
||||
/// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
|
||||
/// three letters would arrive on every keystroke.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualTarget = string.Empty;
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
|
||||
private ManualEndpoint? filterAddress;
|
||||
|
||||
/// <inheritdoc cref="ConnectPassword" />
|
||||
/// <remarks>
|
||||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
||||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
||||
/// not a password offered on the other.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualPassword = string.Empty;
|
||||
/// <summary>Whether the search box holds an address that can be connected to.</summary>
|
||||
internal bool HasFilterAddress => FilterAddress is not null;
|
||||
|
||||
/// <summary>Why the manual box refused, if it did.</summary>
|
||||
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
|
||||
private string filterAddressRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to show under the search box.</summary>
|
||||
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
|
||||
|
||||
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
||||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
||||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
||||
/// Its own box rather than <see cref="ConnectPassword"/>: these are different screens, and a password
|
||||
/// typed on one is not a password offered on the other. Cleared when the address changes, so the next
|
||||
/// machine typed never arrives with the last one's password already in the box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualStatus = string.Empty;
|
||||
private string filterAddressPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||||
@@ -4329,9 +4349,21 @@ internal sealed partial class VaultViewModel(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
SelectedHost is null || AsksForAPassword(SelectedHost);
|
||||
|
||||
/// <summary>Whether connecting to this host needs a password typed for it.</summary>
|
||||
/// <remarks>
|
||||
/// The per-row half of <see cref="SelectedHostAsksForAPassword"/>, and the same reading for the same
|
||||
/// reasons: the resolved binding rather than the host's own ids, and never for a host Spelunk signs in to.
|
||||
/// The desktop's terminal prompt asks it of a host named there, which is not the selected one.
|
||||
/// </remarks>
|
||||
internal static bool AsksForAPassword(HostRowViewModel row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return !row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||
@@ -7756,40 +7788,6 @@ internal sealed partial class VaultViewModel(
|
||||
IsHostPaneOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises whatever the head in front of the user uses to ask about one host.
|
||||
/// </summary>
|
||||
/// <param name="row">The machine, which the caller is holding.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
|
||||
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
|
||||
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
|
||||
/// that is a different piece of state, so a caller that only opened the pane would leave this head
|
||||
/// arriving at a list with nothing on it to press.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both are set rather than branching on which head is running, because neither view model knows and
|
||||
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
|
||||
/// phone has no drawer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
|
||||
/// act both flags exist to tell apart from browsing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void AskAboutHost(HostRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OpenHostPane(row);
|
||||
ChooseHost(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the drawer away, whichever of the three panels is in it.
|
||||
/// </summary>
|
||||
@@ -11404,11 +11402,41 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
|
||||
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
|
||||
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
|
||||
/// choice the prompt never offered. Every refusal is still made here, once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="row">The host to connect to.</param>
|
||||
/// <param name="password">The password typed for it, or empty.</param>
|
||||
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
|
||||
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
|
||||
internal async Task ConnectToHostAsync(
|
||||
HostRowViewModel row,
|
||||
string password,
|
||||
bool mayRememberPassword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||
// thing and got another, and the host is the last place that would say so.
|
||||
if (!TryBuildAuthentication(
|
||||
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
||||
row.Host, row.Resolved, password, out var authentication, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -11421,13 +11449,89 @@ internal sealed partial class VaultViewModel(
|
||||
IsAskingForConnectPassword = false;
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||||
new ConnectionTarget(
|
||||
row.Label,
|
||||
row.Host.Hostname,
|
||||
row.Resolved.Port.Value,
|
||||
row,
|
||||
mayRememberPassword),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on somewhere that is not in the keychain.
|
||||
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
||||
/// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
|
||||
/// keychain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The search box and the password are cleared before the dial, from copies taken first. The list left
|
||||
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
|
||||
/// asked to keep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ConnectToFilterAddressAsync()
|
||||
{
|
||||
if (FilterAddress is not { } endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FilterAddressPassword.Length == 0)
|
||||
{
|
||||
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = FilterAddressPassword;
|
||||
|
||||
// In this order: clearing the filter clears the address and the password with it — see
|
||||
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
|
||||
HostFilter = string.Empty;
|
||||
|
||||
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
|
||||
private void RefreshFilterAddress(string filter)
|
||||
{
|
||||
var previous = FilterAddress;
|
||||
var trimmed = filter.Trim();
|
||||
|
||||
if (!trimmed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
|
||||
{
|
||||
FilterAddress = endpoint;
|
||||
FilterAddressRefusal = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
FilterAddress = null;
|
||||
FilterAddressRefusal = reason;
|
||||
}
|
||||
|
||||
// A different machine, or none, is a different question: the password typed for the last one goes.
|
||||
if (FilterAddress != previous)
|
||||
{
|
||||
FilterAddressPassword = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a typed address with a typed password, saving neither.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11448,31 +11552,20 @@ internal sealed partial class VaultViewModel(
|
||||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
|
||||
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. 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>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
||||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
||||
internal Task ConnectToEndpointAsync(
|
||||
ManualEndpoint endpoint,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
||||
{
|
||||
ManualStatus = refusal;
|
||||
return;
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
|
||||
if (ManualPassword.Length == 0)
|
||||
{
|
||||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
||||
return;
|
||||
}
|
||||
|
||||
ManualStatus = string.Empty;
|
||||
|
||||
await ConnectToAsync(
|
||||
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.
|
||||
@@ -11480,8 +11573,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>
|
||||
@@ -11506,7 +11599,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)
|
||||
@@ -11665,7 +11758,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>
|
||||
@@ -12167,10 +12269,10 @@ internal sealed partial class VaultViewModel(
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
//
|
||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||||
// Only for a target that came from a keychain host. A machine typed in as an address has nothing
|
||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
if (target is { Row: { } row, MayRememberPassword: true })
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -12457,6 +12559,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// <param name="Row">
|
||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||
/// </param>
|
||||
/// <param name="MayRememberPassword">
|
||||
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
|
||||
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
|
||||
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
|
||||
/// because the tick happened to be left on in a drawer that is not even open.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
@@ -12477,7 +12585,8 @@ internal sealed partial class VaultViewModel(
|
||||
string Label,
|
||||
string Hostname,
|
||||
int Port,
|
||||
HostRowViewModel? Row = null)
|
||||
HostRowViewModel? Row = null,
|
||||
bool MayRememberPassword = true)
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
@@ -12487,7 +12596,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
||||
/// reached with a key the item names, and a machine typed in as an address has neither.
|
||||
/// </remarks>
|
||||
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||
}
|
||||
@@ -13462,6 +13571,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
partial void OnHostFilterChanged(string value)
|
||||
{
|
||||
RefreshFilterAddress(value);
|
||||
RebuildVisibleHosts();
|
||||
RebuildHostSections();
|
||||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||||
|
||||
@@ -139,20 +139,41 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
IAsyncDisposable? lifetime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var info = BuildConnectionInfo(request);
|
||||
|
||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||
var client = new SftpClient(BuildConnectionInfo(request))
|
||||
//
|
||||
// ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's
|
||||
// default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange
|
||||
// and the home directory lookup — synchronously after the login, where the cancellation token is not
|
||||
// read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends
|
||||
// text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said
|
||||
// "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off.
|
||||
var client = new SftpClient(info)
|
||||
{
|
||||
BufferSize = SftpBufferSize,
|
||||
KeepAliveInterval = KeepAliveInterval,
|
||||
OperationTimeout = info.Timeout,
|
||||
};
|
||||
|
||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||
// is watching a step list for.
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
HostKeyGate gate;
|
||||
|
||||
try
|
||||
{
|
||||
gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (SshOperationTimeoutException exception) when (info.IsAuthenticated)
|
||||
{
|
||||
throw SftpHandshakeTimedOut(request, info, exception);
|
||||
}
|
||||
|
||||
client.OperationTimeout = Timeout.InfiniteTimeSpan;
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
@@ -172,6 +193,24 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>Says what a timeout after the login means for a file-transfer connection.</summary>
|
||||
/// <remarks>
|
||||
/// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words
|
||||
/// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then
|
||||
/// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking
|
||||
/// at the network for what is nearly always a line of text in a login script on the host.
|
||||
/// </remarks>
|
||||
private static TimeoutException SftpHandshakeTimedOut(
|
||||
SshConnectionRequest request,
|
||||
ConnectionInfo info,
|
||||
SshOperationTimeoutException exception) =>
|
||||
new(
|
||||
$"{request.Host} accepted the login, but its SFTP service did not answer within "
|
||||
+ $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that "
|
||||
+ "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when "
|
||||
+ "it is not interactive.",
|
||||
exception);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
|
||||
Reference in new issue
Block a user