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:
laurensandClaude Opus 5.5 committed 2026-10-20 16:26:32 +02:00
commit 6f6e92f315
25 files changed
+2667 -417

No files matched your search

+40
View File
@@ -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;
}
}
}
+12 -3
View File
@@ -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.
+159 -80
View File
@@ -5,7 +5,8 @@
x:DataType="vm:MainWindowViewModel">
<!--
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
Enter, and give a password, to connect to a machine that is not saved at all.
The design's box promises "search hosts · run command". Only the first half is here, because there is no
snippet or saved-command item type in the vault for the second half to run — see
@@ -35,93 +36,171 @@
BorderThickness="1" CornerRadius="14">
<StackPanel>
<!--
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
<!--
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<!--
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
own selection, because it is not a host and the list below holds hosts only — see the remark on
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
selected state is a class here because there is no ListBoxItem around it to carry :selected.
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
to the same question, and the one thing worth knowing before choosing it.
-->
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text="&gt;_" FontSize="12"
FontWeight="Bold" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Border>
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}"
IsVisible="{Binding HasAdHocRefusal}" />
<!--
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
took focus would take the arrow keys away from the box being typed into.
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
-->
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Green when this host has a terminal open right now, grey otherwise — the same two-state
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
-->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
Width="8" Height="8" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
matches nothing is often a machine that was never saved.
-->
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
Text="No host matches that. Type user@host to connect without saving it."
IsVisible="{Binding HasNothingToOffer}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
</StackPanel>
<!--
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
took focus would take the arrow keys away from the box being typed into.
============ the typed address's password ============
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
and VaultViewModel.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="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Margin="16,14" Spacing="8">
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
PlaceholderText="password" FontSize="14" Height="38" />
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
Text="No host matches that."
IsVisible="{Binding !HasSearchResults}" />
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
<TextBlock Classes="hint" FontSize="12"
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
</StackPanel>
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="ENTER to connect · ESC to go back" />
</Border>
</StackPanel>
</StackPanel>
</Border>
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
Results.Tapped += OnResultTapped;
AdHocRow.Tapped += OnAdHocRowTapped;
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
// the view model's change arrives ahead of the binding that reveals the step.
QueryStep.PropertyChanged += OnStepChanged;
PasswordStep.PropertyChanged += OnStepChanged;
}
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
internal TextBox QueryBox => Query;
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
internal TextBox PasswordBox => Password;
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
internal Control AdHocTargetRow => AdHocRow;
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
/// <summary>
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
return;
}
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
if (shell.IsAskingForAdHocPassword)
{
switch (e.Key)
{
case Key.Escape:
shell.BackToSearchQueryCommand.Execute(null);
e.Handled = true;
break;
case Key.Enter:
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
e.Handled = true;
break;
default:
break;
}
return;
}
switch (e.Key)
{
case Key.Escape:
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
break;
case Key.Down:
Move(shell, 1);
shell.MoveSearchSelection(1);
e.Handled = true;
break;
case Key.Up:
Move(shell, -1);
shell.MoveSearchSelection(-1);
e.Handled = true;
break;
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
NativeKeyboardFocus.ReturnTo(window);
}
Query.Focus();
if (PasswordStep.IsVisible)
{
Password.Focus();
}
else
{
Query.Focus();
}
}
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
private static void Move(MainWindowViewModel shell, int delta)
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (shell.SearchResults.Count == 0)
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
{
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
}
}
/// <summary>
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
/// </summary>
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
{
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
{
return;
}
var current = shell.SelectedSearchResult is { } selected
? shell.SearchResults.IndexOf(selected)
: -1;
shell.SelectedSearchResult =
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
e.Handled = true;
shell.ChooseAdHocTargetCommand.Execute(null);
}
/// <inheritdoc />
@@ -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="&#xE5DB;" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D8;" />
</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="&#xE9AD;" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9A2;" />
</Button>
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">