Public Access
Terminal: ask what to connect to when Terminal is pressed with nothing open
The terminal surface with no session used to be an empty frame, with the tab row's "+" the only thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a click, or user@host[:port] for a machine that is not saved. A password line follows only when one is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below. It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a session. Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect now goes through too, so every refusal is still made once. Each attempt now carries whether it may act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in a closed drawer would save a password typed at a prompt that never offered to keep anything. Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
This commit is contained in:
1 parent
5b2e3f3651
commit
3c8c0980c2
9 files changed
+1154
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user