Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s

Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
This commit is contained in:
2026-07-31 22:59:33 +02:00
parent 7a3a521c59
commit 4300d917a8
21 changed files with 2003 additions and 112 deletions
+111
View File
@@ -580,3 +580,114 @@ Repeat 6.1 and 6.3 with the endpoint blank, a real region, and path-style **off*
**Pass:** it lists. This is the path that exercises `RegionEndpoint.GetBySystemName` and virtual-host
addressing, neither of which MinIO covers.
---
## Phase 7 — Connecting without waiting, and the host list's pointer
Connecting no longer holds the vault while it happens: a tab appears at once and the handshake runs behind
it. The state machine around that is covered in `ShellFlowTests` — the tab, the card, the two kinds of
failure, two connections at once — and the card itself is measured. What is left needs a real window: a
native drag, a real menu popup, and a host that takes its time answering.
### 7.1 The window stays usable while a connection is being made · **the point of the change**
Connect to something that will take a while — an address that is routable but silent, so the handshake sits
there rather than failing at once (a firewalled IP is ideal; `10.255.255.1` will do). While it runs: click
around the host list, open KEYS, edit something, connect to a second host.
**Pass:** everything works. The strip shows a tab named for the host with `connecting…` beside it, and the
terminal area shows the host, the address it is dialling, and the same word.
**Failure means:** the connect has ended up back inside `RunAsync`'s busy gate, which is the one thing
`VaultViewModel.ConnectAsync` must never be put inside again.
### 7.2 Navigating away during a connection unlights the tab
While 7.1's connection is still running, click KEYS.
**Pass:** the tab stays in the strip and stops being marked as the active one. No accent bar along its top
edge while a page is showing.
**Failure means:** the tab is bound to `IsSelected` again rather than to `IsShowing`, and the strip is
claiming to be what the window is showing when it is not.
### 7.3 A refusal ends up in the tab, not only in the status line
Connect to a host with the wrong password, and navigate to PREFS before it answers.
**Pass:** the tab goes grey and carries the refusal; selecting it shows the reason in the middle of the
window with a CLOSE TAB button. Closing it returns to the page underneath.
**Failure means:** a connection that failed while the user was elsewhere has left no trace anywhere they
would look.
### 7.4 An unknown host key still wins the window
Connect to a host whose key is not yet approved.
**Pass:** no tab is left behind, and the window is on HOSTS with the fingerprint prompt. Approving it
connects and gives the tab a pane. This is 1.5 with the tab rework underneath it.
### 7.5 Giving up on a connection, and what arrives afterwards
Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is still trying.
**Pass:** the tab goes at once. If the host does eventually answer, a tab appears for the session that
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
back.
### 7.6 Dragging a host into a group · **least covered, like all drag and drop**
Make two groups and file a host into one. Drag a host row onto another group's heading; onto a host row
inside another group; and onto UNGROUPED.
**Pass:** the row under the pointer washes accent while the pointer is over it, the cursor shows a move
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
headings change. Dropping onto its own group's heading is refused while still in the air.
**Failure means:** headless Avalonia cannot synthesise a platform drag, so nothing about this gesture is
automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSelected`.
### 7.7 A click still selects, and a double click still connects
Click host rows; drag one a few pixels without releasing; double-click one.
**Pass:** a click selects, a small movement starts nothing, and a double click connects.
**Failure means:** the 5-pixel threshold in `HostSidebar.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the list unusable.
### 7.8 The highlight clears after a drag that goes nowhere
Drag a host over a heading and release outside the list, or press Escape mid-drag.
**Pass:** the wash goes away.
### 7.9 The right-click menu acts on the row under the pointer
With host A selected, right-click host B and choose Delete.
**Pass:** the question names **B**. Then right-click a group heading.
**Pass:** no menu opens at all, and the host selection has not moved.
**Failure means:** a menu acting on the selection rather than on the row under the pointer deletes the wrong
machine. `HostSidebarTests` covers both halves headlessly, so this is a confirmation that a real popup
behaves as the headless one did.
### 7.10 Clicking a host in the palette connects
Ctrl+K, then click a result with the mouse rather than pressing Enter.
**Pass:** the palette closes and the connection starts.
### 7.11 A host or a bucket made now can be picked on FILES now
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
add a bucket — then come back.
**Pass:** the new one is in the picker, without locking and unlocking.
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
@@ -77,7 +77,13 @@
<Panel IsVisible="{Binding IsVaultShowing}">
<views:KeychainScreen DataContext="{Binding Vault}" />
</Panel>
<views:TerminalScreen IsVisible="{Binding IsTerminalShowing}" />
<!--
IsTerminalSurface rather than IsTerminalShowing: a tab exists from the moment connecting starts and
its session does not, so this screen has to be on show while there is nothing to render — it is
what says which machine is being connected to. Which of the two things inside it draws is decided
in the screen itself. See TerminalTabViewModel.
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<views:PendingScreen IsVisible="{Binding IsPreferencesShowing}"
Heading="PREFERENCES"
@@ -115,7 +121,14 @@
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Classes="dot live" Width="5" Height="5" VerticalAlignment="Center" />
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now — connecting
opens the tab first — and a dot that was green before anything had answered would be
the one thing on this strip claiming something untrue.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="10" Text="{Binding Label}" />
</StackPanel>
</Button>
@@ -143,7 +156,7 @@
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
through ShowScreen. Its own command is on the shell.
-->
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalShowing}"
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<views:NavButton Grid.Column="3" Label="MORE" IsCurrent="{Binding IsPreferencesShowing}"
@@ -36,7 +36,9 @@
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot live" Width="5" Height="5" VerticalAlignment="Center" />
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
@@ -81,12 +83,33 @@
</StackPanel>
<!--
Collapsed rather than merely covered when there are no tabs. On Windows this control is a native
child window that composites above everything Avalonia draws, which is why the desktop head hides
it explicitly; whether Android's WebView does the same is recorded as unverified in
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
A tab with no pane behind it: one still connecting, or one that was refused. The desktop head draws
the same thing in its own ConnectingCard, and it exists for the same reason — connecting does not
hold the application still any more, so there is a stretch in which a tab is selected and there is
nothing yet to render in it. A phone needs it more than a desktop does: mobile links are slower, and
the alternative is a black rectangle.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding HasTabs, FallbackValue=False}" />
<StackPanel IsVisible="{Binding IsConnectingShowing}" VerticalAlignment="Center" Margin="24"
Spacing="10">
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
</Button>
</StackPanel>
<!--
Collapsed rather than merely covered when there is no pane to show. On Windows this control is a
native child window that composites above everything Avalonia draws, which is why the desktop head
hides it explicitly; whether Android's WebView does the same is recorded as unverified in
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
connecting has no pane, and showing the renderer for it would show the previous session's output
under the name of a machine nothing has connected to yet.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<!-- ============ accessory keys ============ -->
@@ -96,8 +119,9 @@
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
then release, because holding a modifier while typing is not possible one-thumbed.
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" IsVisible="{Binding HasTabs}">
BorderThickness="0,1,0,0" IsVisible="{Binding IsTerminalShowing}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Height="44" Spacing="4"
Margin="6,5" />
+31
View File
@@ -452,6 +452,37 @@
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<!--
The row a host is about to be dropped on. A wash rather than the accent strip a selection carries,
because it is not a selection: it says "let go here", it lasts as long as the pointer is over the row,
and it has to be legible on top of whatever that row already looks like — including the selected row,
which is often the one being dragged onto.
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
would be state the list throws away halfway through the drag. See HostSidebar.axaml.cs.
-->
<Style Selector="ListBoxItem.droptarget /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
</Style>
<!--
A context menu, in this window's palette rather than the theme's. The Fluent default is a lighter grey
than anything else here, which on a near-black chrome reads as a dialog from another application.
-->
<Style Selector="MenuFlyoutPresenter, ContextMenu">
<Setter Property="Background" Value="{StaticResource Chrome}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
</Style>
<Style Selector="MenuItem">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
The same strip on the vault's category rail, which is buttons rather than list items — so the class
the rail sets stands in for the :selected pseudo-class.
@@ -0,0 +1,77 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.ConnectingCard"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
What the terminal's rectangle holds while the selected tab has no pane: a connection being made, or one
that was refused.
It exists because connecting stopped blocking the window. The tab appears the moment the user asks for
it and the handshake runs behind it, so there is a stretch — seconds against a machine that is asleep,
longer against one that is not there — in which a tab is selected and there is nothing to show in it.
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
broken, so this says which machine, as whom, and how far along it is.
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
that safe rather than merely arranged. See MainWindow.axaml.
In its own file rather than in the window, like every other card here, because nothing inside that window
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
-->
<Panel>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
Margin="24">
<StackPanel Spacing="6" HorizontalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="15" FontWeight="Medium"
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
the reason, in the palette's red, because it is the only place the reason will be after the user
navigates away from the screen that started the connection.
-->
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="11"
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="12"
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsFailed}" />
<TextBlock Classes="hint" FontSize="10.5" TextAlignment="Center" HorizontalAlignment="Center"
Text="Nothing else is waiting for this. Every other screen still works, and the strip above says how this one is getting on."
IsVisible="{Binding SelectedTab.IsConnecting}" />
<!--
The same command the tab's own cross runs, named for what pressing it means in each of the two
states. On a connecting tab it is how the attempt is given up on: the shell forgets it, and a
handshake that finishes afterwards is adopted rather than dropped — see
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
two are different decisions and only one of them abandons something still running.
-->
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsFailed}" />
</StackPanel>
</Panel>
</UserControl>
@@ -0,0 +1,13 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What the terminal's rectangle holds while the selected tab has no pane.</summary>
/// <remarks>
/// No code of its own: everything it shows is the selected tab's, and the one button it carries is the
/// shell's own close command. See the markup for why it exists at all.
/// </remarks>
internal sealed partial class ConnectingCard : UserControl
{
public ConnectingCard() => InitializeComponent();
}
@@ -66,10 +66,33 @@
nothing focused and the keystrokes going nowhere.
-->
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
DragDrop.AllowDrop="True"
IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than one per
row: a ContextMenu inside a DataTemplate would have the row for its data context, and the commands
here are the vault's — the same three the buttons at the foot of this column run. The code-behind
selects whatever was right-clicked before the menu opens, so the selection-based commands act on the
row under the pointer, and cancels the menu outright over a group heading, which has no host to act
on. See HostSidebar.axaml.cs.
The same commands as the buttons, deliberately: a second path to deleting a host would be a second
place for the confirmation to be forgotten.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<!--
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -13,6 +16,40 @@ namespace DodoSSH.Client.App.Views;
/// </remarks>
internal sealed partial class HostSidebar : UserControl
{
/// <summary>
/// How a host travels from the row it was picked up on to the heading it is dropped on.
/// </summary>
/// <remarks>
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
/// </remarks>
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
/// <remarks>
/// A threshold, because a press on this list is nearly always a click: selecting a host, or the first
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
/// those into a drag gesture the user never asked for.
/// </remarks>
private const double DragThreshold = 5;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
/// over to the operating system.
/// </remarks>
private PointerPressedEventArgs? press;
private HostRowViewModel? pickedUp;
private Point origin;
/// <summary>The row the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostSidebar()
{
InitializeComponent();
@@ -22,8 +59,24 @@ internal sealed partial class HostSidebar : UserControl
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
// password box beside it, and a host that asks for a password still needs it typed first.
HostList.DoubleTapped += OnHostActivated;
// Tunnelled, so the row under the pointer is read before the ListBox has answered the press itself.
// Bubbling would work for the drag but not for the menu: by then the control has already decided
// what is selected, and the menu is about to open against it.
HostList.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
HostList.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
HostList.PointerMoved += OnPointerMoved;
HostList.PointerReleased += OnPointerReleased;
HostList.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostList, OnDragOver);
DragDrop.AddDragLeaveHandler(HostList, OnDragLeave);
DragDrop.AddDropHandler(HostList, OnDrop);
}
private VaultViewModel? Vault => DataContext as VaultViewModel;
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
@@ -31,12 +84,241 @@ internal sealed partial class HostSidebar : UserControl
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
if (DataContext is VaultViewModel vault)
if (Vault is { } vault)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
}
}
/// <summary>
/// Points the menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the empty space below the rows. Neither is a host,
/// and a menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or
/// act on something else entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
{
e.Handled = true;
return;
}
vault.SelectedSidebarRow = row;
}
/// <remarks>
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
/// until the pointer moves, so this is the point at which both are still possible.
/// </remarks>
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
press = null;
pickedUp = null;
if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
press = e;
pickedUp = row;
origin = e.GetPosition(HostList);
}
/// <remarks>
/// The drag is started from the remembered press once the pointer has travelled far enough — see
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (press is not { } pressed || pickedUp is not { } row)
{
return;
}
if (!e.GetCurrentPoint(HostList).Properties.IsLeftButtonPressed)
{
Forget();
return;
}
var moved = e.GetPosition(HostList) - origin;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
Forget();
_ = DragAsync(pressed, row);
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
/// <summary>Carries one host row for as long as the user holds it.</summary>
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, row));
try
{
// ConfigureAwait(true): what follows touches the list's own containers, and those are the UI
// thread's.
await DragDrop
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
.ConfigureAwait(true);
}
finally
{
// Whatever the drop did or did not do. A mark left behind would be a row that looks like a
// target for a drag that ended somewhere else entirely.
Unmark();
}
}
/// <summary>
/// Says whether the row under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
/// does nothing is worse than one that says so while it is still in the air.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
e.Handled = true;
if (Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
Unmark();
return;
}
e.DragEffects = DragDropEffects.Move;
Mark(target.Container);
}
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <remarks>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
/// task nothing observes.
/// </remarks>
private void OnDrop(object? sender, DragEventArgs e)
{
e.Handled = true;
Unmark();
if (Vault is not { } vault || Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
return;
}
e.DragEffects = DragDropEffects.Move;
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
}
/// <summary>
/// Where a drag currently is, or null if it is over nothing that would take it.
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a host is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of rows under a heading is a target rather than
/// one seven-pixel line of text. The ungrouped heading is a target like any other, and it is how a host
/// is taken out of a group without opening the editor.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the list already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
/// </remarks>
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
}
private void Mark(ListBoxItem container)
{
if (ReferenceEquals(marked, container))
{
return;
}
Unmark();
marked = container;
marked.Classes.Add("droptarget");
}
private void Unmark()
{
marked?.Classes.Remove("droptarget");
marked = null;
}
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
private void Forget()
{
press = null;
pickedUp = null;
}
/// <summary>The view model of the list row an event happened on, if it happened on one.</summary>
private static object? RowUnder(object? source) => Container(source)?.DataContext;
/// <remarks>
/// Walks up from whatever was actually hit — a text block, a border, the row's own grid — because that is
/// what an event's source is. Anything not inside a row, which is the empty space below the last one,
/// yields null.
/// </remarks>
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
/// <summary>
/// Where the keyboard should land when the terminal hands it back.
/// </summary>
@@ -53,6 +335,6 @@ internal sealed partial class HostSidebar : UserControl
/// filter box is always there, and it is a perfectly good place for a keyboard to arrive.
/// </para>
/// </remarks>
internal Avalonia.Input.IInputElement KeyboardTarget =>
internal IInputElement KeyboardTarget =>
HostList.IsEffectivelyVisible ? HostList : HostFilter;
}
@@ -161,6 +161,15 @@
</Panel>
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
has a session or it does not — which is what makes drawing it here safe under the occlusion
rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, 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.
@@ -47,6 +47,11 @@
<!--
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.
-->
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
ItemsSource="{Binding SearchResults}"
@@ -74,7 +79,7 @@
<Border Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="9" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER to connect · ESC to close" />
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
</StackPanel>
@@ -33,6 +33,12 @@ internal sealed partial class QuickConnect : UserControl
// before a bubbling handler here ever ran. The palette owns them while it is open, so it says so at
// the point on the route where nothing else has had a chance yet.
AddHandler(KeyDownEvent, OnPaletteKey, RoutingStrategies.Tunnel);
// A click on a result connects to it, which is the pointer's version of what Enter already does.
// Tapped rather than PointerPressed: a press has not chosen anything yet — it is also the start of a
// 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;
}
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
@@ -157,6 +163,27 @@ internal sealed partial class QuickConnect : UserControl
private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e);
/// <summary>
/// Connects to the result that was clicked.
/// </summary>
/// <remarks>
/// Goes through the shell's own command rather than reading the row out of the event, so the pointer and
/// the keyboard take exactly the same path: the palette closes, the hosts screen is put back in case the
/// connection has a question to ask, and the vault's connect command makes every refusal it already
/// makes. Fire-and-forget, as the sidebar's double-click is — the command reports its own failures onto
/// the status line and into the tab it opens.
/// </remarks>
private void OnResultTapped(object? sender, TappedEventArgs e)
{
if (Shell is not { IsSearching: true, SelectedSearchResult: not null } shell)
{
return;
}
e.Handled = true;
_ = shell.ConnectToSearchResultCommand.ExecuteAsync(null);
}
/// <remarks>
/// Only a press on the wash itself. Presses on the card bubble through here as well, and closing on those
/// would make the palette impossible to click into.
@@ -56,23 +56,39 @@
event handled — so a left press on the cross does not also select the tab. It deliberately
does not handle any other button, which is exactly what lets a middle press bubble out of
the cross and reach the handler below.
Marked active on IsShowing rather than on IsSelected, which are not the same question. The
selection survives navigating away — that is what makes the strip a way back to a terminal —
so a tab lit while preferences filled the window would be a second "you are here" mark
pointing at something nobody can see. See TerminalTabViewModel.IsShowing.
-->
<Button Classes="flat tab"
Classes.active="{Binding IsSelected}"
Classes.active="{Binding IsShowing}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
keeps its scrollback either way, which is usually why somebody is still looking at a
tab whose dot has gone out.
Green while the shell behind this tab is running, grey while it is connecting and once
it has ended. The pane keeps its scrollback either way, which is usually why somebody is
still looking at a tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
What a tab with no pane has to say for itself: "connecting…" while the handshake runs,
and the refusal once one has failed. It is here rather than only on the card because the
whole point of not blocking the window is that the user is somewhere else — the strip is
the one piece of chrome that is on screen wherever that is.
-->
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="9.5"
MaxWidth="180" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !HasSession}" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
@@ -200,6 +200,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TeamsViewModel teams;
/// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet.
/// </summary>
/// <remarks>
/// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
/// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
/// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
/// when the user closes the tab out from under it.
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -619,7 +630,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
/// <summary>
/// Whether the terminal half of the window is the half being shown, pane or no pane.
/// </summary>
/// <remarks>
/// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
/// is worth its own name because a tab exists before its session does — see
/// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
/// other two say which of the two things that can be in that rectangle is drawn.
/// </remarks>
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <summary>
/// Whether the card that stands in for a pane is showing.
/// </summary>
/// <remarks>
/// <para>
/// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
/// tab either has a session or it does not. It covers both of the states in which it does not — still
/// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
/// </para>
/// <para>
/// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
/// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
/// </para>
/// </remarks>
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
@@ -721,6 +759,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// terminal just went would keep a lit dot until something else happened to move the selection.
RefreshConnectedHosts();
// What the rectangle holds is decided by the selected tab's state, and the line above may well have
// moved the selection from a card to a pane or the other way round.
RaiseTerminalState();
if (!tab.HasSession)
{
// Nothing to end: this tab is a connection that has not happened, or one that never will. The
// attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
// user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
// rather than drops, because a running shell nothing names is worse than a tab that reappears.
foreach (var attemptId in attempts
.Where(entry => ReferenceEquals(entry.Value, tab))
.Select(entry => entry.Key)
.ToArray())
{
attempts.Remove(attemptId);
}
return;
}
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
}
@@ -810,11 +869,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CloseSearch();
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
// screen — so connecting from the files screen without this would put the question behind the screen
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
// is what moves the surface to the terminal, and only if there is one.
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
// connecting from the files screen without this would leave the question behind the screen that asked
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
@@ -1816,13 +1875,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (oldValue is not null)
{
oldValue.SessionOpened -= OnVaultSessionOpened;
oldValue.PropertyChanged -= OnVaultPropertyChanged;
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
// The three connection events are kept while an attempt is still in flight, and that is not an
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
// what holds both, and it outlives every vault — so a connection started just before a lock still
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
// tabs are this object's rather than the vault's. Detaching here would strand that tab on
// "connecting…" for ever and leave the session it eventually opened with nothing in the window
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
// resolves: the vault holds the handler, not the other way round.
if (attempts.Count == 0)
{
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
oldValue.SessionOpened -= OnVaultSessionOpened;
}
}
if (newValue is not null)
{
newValue.ConnectionStarting += OnVaultConnectionStarting;
newValue.ConnectionFailed += OnVaultConnectionFailed;
newValue.SessionOpened += OnVaultSessionOpened;
newValue.PropertyChanged += OnVaultPropertyChanged;
@@ -1878,29 +1953,118 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(SignOutWarning));
}
/// <remarks>
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
/// <summary>
/// Takes a newly opened session into the tab strip and shows it.
/// Puts a tab in the strip for a connection that has only just been asked for.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these four steps is decided
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
/// <para>
/// This is what stops connecting looking like the application having stopped. The tab appears in the same
/// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
/// machine that is asleep is a card that says which machine, rather than a status line under a window
/// that does nothing for thirty seconds.
/// </para>
/// <para>
/// Kept by attempt id rather than by label: several connections can be in flight now that one does not
/// hold the vault, and two of them can perfectly well be to the same host.
/// </para>
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
attempts[e.AttemptId] = tab;
AdoptTab(tab);
}
/// <remarks>
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
// Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
// no pane to name until this line.
Activate(tab);
RefreshConnectedHosts();
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
/// <remarks>
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
/// now, and the status line they are not looking at is not where a failure should end. A host key
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
/// back where the question is being asked.
/// </remarks>
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
return;
}
if (!e.IsAwaitingAnAnswer)
{
tab.Failed(e.Reason);
RaiseTerminalState();
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
RaiseTabState();
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
// started from the palette on any screen, so without this the prompt would be behind whatever the
// user was looking at, with the connection waiting on an answer they cannot reach.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
/// <summary>
/// Takes a tab into the strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these steps is decided once.
/// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
/// against a strip that already shows what it is about.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab);
RaiseTabState();
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged. The page
// also activates a newly created pane on its own, so this is belt and braces for the first session
// and load-bearing for every one after it.
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
// that has one. A tab that is still connecting has none, and selecting it shows the card instead.
SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
@@ -1908,19 +2072,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
if (tab.HasSession)
{
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
}
/// <remarks>
/// <para>
/// Fire-and-forget, and it has to be: this runs from a property setter, and a selection that awaited a
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
/// </para>
/// <para>
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
/// an already-open socket and returns immediately when there is no renderer.
/// </para>
/// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
/// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
/// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
/// </remarks>
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
{
@@ -1929,6 +2090,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
tab.IsSelected = ReferenceEquals(tab, value);
}
// Which of the two things can be in the terminal's rectangle depends on the selected tab having a
// session, so moving the selection is one of the ways that answer changes. It also repaints the
// strip's active mark, which follows the selection and the surface together.
RaiseTerminalState();
RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
@@ -1938,10 +2104,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (value is not null)
{
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
Activate(value);
}
}
/// <summary>Tells the renderer which pane to show.</summary>
/// <remarks>
/// <para>
/// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
/// </para>
/// <para>
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
/// an already-open socket and returns immediately when there is no renderer.
/// </para>
/// <para>
/// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
/// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
/// the handshake finishes.
/// </para>
/// </remarks>
private void Activate(TerminalTabViewModel tab)
{
if (!tab.HasSession)
{
return;
}
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
/// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
@@ -2033,7 +2226,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsLocked));
OnPropertyChanged(nameof(IsAskingForThePassphrase));
OnPropertyChanged(nameof(IsUnlocked));
OnPropertyChanged(nameof(IsTerminalShowing));
RaiseTerminalState();
OnPropertyChanged(nameof(SignOutWarning));
RaiseSyncState();
@@ -2105,10 +2298,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsTerminalShowing));
RaiseTerminalState();
}
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
/// <summary>
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
/// </summary>
/// <remarks>
/// One method for all four, because they are one fact read from four directions: the surface, the
/// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
/// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
/// pointing at something nobody can see.
/// </remarks>
private void RaiseTerminalState()
{
OnPropertyChanged(nameof(IsTerminalSurface));
OnPropertyChanged(nameof(IsTerminalShowing));
OnPropertyChanged(nameof(IsConnectingShowing));
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
// on screen; see TerminalTabViewModel.IsShowing.
foreach (var tab in Tabs)
{
tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
}
}
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
@@ -3,7 +3,28 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// One open terminal, as a tab.
/// How far along a tab's connection is.
/// </summary>
/// <remarks>
/// A tab exists before its session does — see <see cref="TerminalTabViewModel"/> — so "is there a pane
/// behind this" is a question the strip and the window both have to be able to ask. Three states rather
/// than a nullable session id, because <see cref="Failed"/> and <see cref="Connecting"/> are both "no
/// session" and only one of them is still worth waiting for.
/// </remarks>
internal enum TerminalTabState
{
/// <summary>The connection is being made. There is no pane yet.</summary>
Connecting = 0,
/// <summary>A session was opened, and the renderer has a pane for it.</summary>
Open = 1,
/// <summary>The connection did not happen. There is no pane, and there never will be for this tab.</summary>
Failed = 2,
}
/// <summary>
/// One terminal, as a tab: from the moment connecting starts to the moment the tab is closed.
/// </summary>
/// <remarks>
/// <para>
@@ -13,24 +34,73 @@ namespace DodoSSH.Client.Shell.ViewModels;
/// is the WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
/// a DNS lookup and a handshake take, and the strip is where that is admitted to: the tab appears at the
/// moment the user asks for it, carrying <see cref="Status"/> instead of a pane, and becomes a real terminal
/// when <see cref="Opened"/> is called. Nothing about the rest of the application waits for that — which is
/// the point, because the alternative is a window that does nothing visible for ten seconds.
/// </para>
/// <para>
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
/// shell outlives every lock, and so does this.
/// </para>
/// </remarks>
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this is logged in as, and where.</param>
internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address)
: ObservableObject
internal sealed partial class TerminalTabViewModel : ObservableObject
{
internal uint SessionId { get; } = sessionId;
/// <summary>A tab for a connection that is still being made.</summary>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param>
internal TerminalTabViewModel(string label, string address)
{
Label = label;
Address = address;
status = "connecting…";
isLive = false;
}
internal string Label { get; } = label;
/// <summary>A tab for a session that is already open.</summary>
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this is logged in as, and where.</param>
internal TerminalTabViewModel(uint sessionId, string label, string address)
: this(label, address)
{
SessionId = sessionId;
state = TerminalTabState.Open;
status = string.Empty;
isLive = true;
}
/// <summary>
/// Identifies this terminal to the renderer, or zero while there is no session.
/// </summary>
/// <remarks>
/// Zero is not a session id the workspace ever hands out — it counts from one — so it can stand for
/// "not connected yet" without a nullable that every caller would have to unwrap. <see cref="HasSession"/>
/// is what the window asks rather than this.
/// </remarks>
internal uint SessionId { get; private set; }
internal string Label { get; }
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; } = address;
internal string Address { get; }
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
/// <summary>
/// What this tab has to say for itself while it has no pane.
/// </summary>
/// <remarks>
/// Empty once a session is open, because from then on the pane speaks for itself — anything written here
/// would be a second, staler account of what the terminal is already showing.
/// </remarks>
[ObservableProperty]
private string status;
/// <summary>
/// Whether the shell behind this tab is still running.
@@ -41,7 +111,7 @@ internal sealed partial class TerminalTabViewModel(uint sessionId, string label,
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
/// </remarks>
[ObservableProperty]
private bool isLive = true;
private bool isLive;
/// <summary>
/// Whether this is the tab whose pane is showing.
@@ -53,4 +123,58 @@ internal sealed partial class TerminalTabViewModel(uint sessionId, string label,
/// </remarks>
[ObservableProperty]
private bool isSelected;
/// <summary>
/// Whether this tab is the thing the window is currently showing.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsSelected"/>, and the strip has to ask this one. The selection
/// survives navigating away — that is what makes the strip a way back to a terminal rather than a way to
/// lose it — so a tab that stayed lit while preferences filled the window would be a second "you are
/// here" mark pointing at something nobody can see. The rail's own entries make exactly this distinction;
/// see <c>MainWindowViewModel.IsHostsShowing</c>. The shell writes it, from the selection and the surface
/// together.
/// </remarks>
[ObservableProperty]
private bool isShowing;
/// <summary>Whether there is a pane behind this tab.</summary>
internal bool HasSession => State is TerminalTabState.Open;
/// <summary>Whether this tab is still waiting on a connection.</summary>
internal bool IsConnecting => State is TerminalTabState.Connecting;
/// <summary>Whether this tab is a connection that never happened.</summary>
internal bool IsFailed => State is TerminalTabState.Failed;
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
internal void Opened(uint sessionId)
{
SessionId = sessionId;
Status = string.Empty;
IsLive = true;
State = TerminalTabState.Open;
}
/// <summary>
/// Records that the connection this tab was opened for did not happen.
/// </summary>
/// <remarks>
/// The tab stays, and that is deliberate: connecting no longer blocks the window, so by the time a
/// refusal arrives the user is quite likely looking at something else — and a tab that vanished would
/// take the only account of what went wrong with it. It is closed the way every other tab is.
/// </remarks>
internal void Failed(string reason)
{
Status = reason;
IsLive = false;
State = TerminalTabState.Failed;
}
partial void OnStateChanged(TerminalTabState value)
{
OnPropertyChanged(nameof(HasSession));
OnPropertyChanged(nameof(IsConnecting));
OnPropertyChanged(nameof(IsFailed));
}
}
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -482,6 +483,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
connectionLog = log;
objectStores = buckets;
// Followed rather than copied once, which is the difference between a picker that is right at unlock
// and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
// synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
// snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
// Detach: these collections belong to a vault that is about to be disposed.
openVault.Hosts.CollectionChanged += OnVaultListChanged;
openVault.ObjectStores.CollectionChanged += OnVaultListChanged;
RefreshHosts();
// Read once per unlock rather than per navigation: a drive appearing while the application is open
@@ -505,6 +514,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
internal void Detach()
{
if (vault is { } open)
{
open.Hosts.CollectionChanged -= OnVaultListChanged;
open.ObjectStores.CollectionChanged -= OnVaultListChanged;
}
vault = null;
knownHosts = null;
@@ -1189,8 +1204,29 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
}
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
/// <summary>
/// Rebuilds both pickers from the vault's lists.
/// </summary>
/// <remarks>
/// <para>
/// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts
/// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first.
/// </para>
/// <para>
/// <b>The selection is re-found by id rather than kept.</b> The vault replaces every row on every reload,
/// which a synchronisation pass does once a minute, so holding the object would leave the picker showing
/// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also
/// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be
/// connected to.
/// </para>
/// </remarks>
private void RefreshHosts()
{
var host = SelectedHost?.EntityId;
var bucket = SelectedBucket?.EntityId;
Hosts.Clear();
if (vault is not { } open)
@@ -1198,21 +1234,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return;
}
foreach (var host in open.Hosts)
foreach (var row in open.Hosts)
{
Hosts.Add(host);
Hosts.Add(row);
}
SelectedHost ??= Hosts.FirstOrDefault();
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var bucket in open.ObjectStores)
foreach (var row in open.ObjectStores)
{
Buckets.Add(bucket);
Buckets.Add(row);
}
SelectedBucket ??= Buckets.FirstOrDefault();
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
@@ -82,6 +82,16 @@ internal sealed record GroupChoice(Guid? EntityId, string Label)
internal static GroupChoice None { get; } = new(null, "No group");
}
/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
/// <remarks>
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
/// carries the vault the edit has to return to.
/// </remarks>
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
/// <summary>One snippet, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
@@ -432,8 +442,16 @@ internal static class ItemBadge
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">The account and endpoint actually dialled.</param>
internal sealed class TerminalSessionEventArgs(uint sessionId, string label, string address) : EventArgs
internal sealed class TerminalSessionEventArgs(
Guid attemptId,
uint sessionId,
string label,
string address) : EventArgs
{
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
internal Guid AttemptId { get; } = attemptId;
internal uint SessionId { get; } = sessionId;
internal string Label { get; } = label;
@@ -441,6 +459,55 @@ internal sealed class TerminalSessionEventArgs(uint sessionId, string label, str
internal string Address { get; } = address;
}
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param>
/// <remarks>
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
/// has no vault to read it from.
/// </remarks>
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
{
/// <summary>Identifies this attempt for the whole of its life.</summary>
/// <remarks>
/// Carried by all three events, because several connections can be in flight at once now that one no
/// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one".
/// </remarks>
internal Guid AttemptId { get; } = attemptId;
internal string Label { get; } = label;
internal string Address { get; } = address;
}
/// <summary>A connection that was asked for and did not happen.</summary>
/// <param name="attemptId">The attempt that has just ended.</param>
/// <param name="reason">What to say about it, in the tab.</param>
/// <param name="isAwaitingAnAnswer">
/// Whether the connection stopped on a question rather than on a failure.
/// </param>
/// <remarks>
/// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and
/// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something
/// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown
/// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection
/// resumes the moment it is answered — so the tab goes and the window shows the question instead.
/// </remarks>
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
: EventArgs
{
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
internal Guid AttemptId { get; } = attemptId;
internal string Reason { get; } = reason;
/// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
@@ -1417,6 +1484,22 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
/// <summary>
/// Raised the moment a connection is asked for, before anything has been dialled.
/// </summary>
/// <remarks>
/// The other half of <see cref="SessionOpened"/>, and the reason connecting no longer makes the window
/// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the
/// handshake is still happening, and every other screen stays usable. Exactly one of
/// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
/// <c>AttemptId</c>.
/// </remarks>
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
@@ -1893,6 +1976,93 @@ internal sealed partial class VaultViewModel(
SelectedSidebarRow = SelectedHost;
}
/// <summary>
/// Files one host under one group, or under none.
/// </summary>
/// <remarks>
/// <para>
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
/// host that is about arrangement rather than about the machine.
/// </para>
/// <para>
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
/// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
/// host would be a save the user never asked for, and one they would then be unable to cancel.
/// </para>
/// <para>
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
/// </para>
/// <para>
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
/// method that takes one cancels the previous execution's token on every invocation, and two drops in
/// quick succession are two writes rather than one superseding the other. This is one row's one field
/// and it is over in a moment.
/// </para>
/// </remarks>
/// <param name="request">The host to move, and where to.</param>
[RelayCommand]
private async Task MoveHostToGroupAsync(HostGroupMove? request)
{
if (request is not { Host: { } row })
{
return;
}
if (row.IsReadOnly)
{
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
// wrote would drop the fields this build has no concept of.
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
return;
}
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
return;
}
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
? wanted
: null;
if (row.Host.GroupId == target)
{
return;
}
var moved = row.Host with { GroupId = target };
var name = target is null
? "no group"
: Groups.First(group => group.EntityId == target).Label;
await RunAsync(
$"Filing {row.Label} under {name}…",
async () =>
{
await session.Hosts
.UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
.ConfigureAwait(true);
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
// Re-found rather than kept: the reload replaces every row, so the object that was dragged is
// no longer the one in the list, and leaving the selection pointing at it would light nothing.
SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);
Status = target is null
? $"'{row.Label}' is no longer in a group."
: $"Filed '{row.Label}' under '{name}'.";
}).ConfigureAwait(true);
// Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
// editor.
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
}
/// <remarks>
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
@@ -3488,9 +3658,46 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Opens a terminal on the selected host.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
/// <summary>
/// Opens a terminal on the selected host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> That gate is
/// what makes the vault do one thing at a time, and connecting is the one operation that must not hold
/// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate
/// for it means a window in which nothing else can be saved, edited or even connected to. The strip
/// carries the feedback instead — <see cref="ConnectionStarting"/> puts a tab there before anything is
/// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
/// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
/// </para>
/// <para>
/// Several connections can therefore be in flight at once, which is why an attempt has an id and why
/// this command allows concurrent executions. That is a feature rather than a tolerated race: opening
/// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting
/// for each in turn. Without the flag the generated command refuses a second call outright while the
/// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of
/// the explanation.
/// </para>
/// <para>
/// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
/// <c>[RelayCommand]</c> over a method that takes one generates a command which cancels the previous
/// execution's token every time it is invoked — so a second connection would quietly abandon the first,
/// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the
/// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not
/// having one is a way to abort a handshake from here; closing the tab is that, and the session it
/// abandons is adopted rather than lost. See <c>MainWindowViewModel.CloseTabAsync</c>.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
/// <inheritdoc cref="ConnectAsync" />
/// <param name="cancellationToken">
/// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
/// which is a different command's and so is not cancelled by anyone else connecting.
/// </param>
private async Task ConnectToSelectedHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
@@ -3510,9 +3717,18 @@ internal sealed partial class VaultViewModel(
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, authentication, cancellationToken)).ConfigureAwait(true);
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The
// address is the one that will actually be dialled — a bound credential can supply the username —
// rather than the host's own fields, so the tab does not rename itself on connecting.
var attempt = new ConnectionAttemptEventArgs(
Guid.CreateVersion7(),
row.Label,
Dialled(row, authentication));
ConnectionStarting?.Invoke(this, attempt);
Status = $"Connecting to {row.Label}…";
await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true);
}
/// <summary>
@@ -3549,7 +3765,7 @@ internal sealed partial class VaultViewModel(
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true);
// After connecting, not before. A pin is worth pushing straight away — the same host on another
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
@@ -3671,63 +3887,106 @@ internal sealed partial class VaultViewModel(
await session.DisposeAsync().ConfigureAwait(false);
}
/// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
/// <remarks>
/// <para>
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
/// </para>
/// <para>
/// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
/// operation has timed out", and the one thing worth saying is where to look: a runtime this application
/// does not install.
/// </para>
/// <para>
/// <b>An unknown host key is not a failure and is deliberately not logged.</b> Nothing was refused and
/// nothing broke — the connection is paused on a question, and it becomes a session the moment the user
/// answers it. An entry here would record a failure that did not happen, once per new host. A changed
/// key <em>is</em> logged, and it is the entry the connection log most exists for: it is refused outright
/// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses.
/// </para>
/// <para>
/// <b>Everything else is caught by shape rather than by type.</b> This project's SSH layer defines only
/// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not
/// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded
/// before it is reported — the log is an observer here and must never become the thing that swallows an
/// error. Cancellation is excluded from that, because a user who gave up did not fail to connect.
/// </para>
/// </remarks>
private async Task OpenSessionAsync(
ConnectionAttemptEventArgs attempt,
HostRowViewModel row,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
try
{
await ConnectAndAnnounceAsync(row, authentication, cancellationToken).ConfigureAwait(true);
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
.ConfigureAwait(true);
}
catch (TimeoutException)
{
// The renderer never attached, so nothing was connected. Reported here rather than left to
// RunAsync's generic handler because TimeoutException says only "The operation has timed out",
// and the one thing worth saying is where to look: a runtime this application does not install.
Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ "runtime is probably missing or blocked; install it and try again.";
Abandon(
attempt,
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ "runtime is probably missing or blocked; install it and try again.");
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
//
// Deliberately not logged. Nothing was refused and nothing failed — the connection is paused on a
// question, and it becomes a session the moment the user answers it. An entry here would record a
// failure that did not happen, once per new host.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
Answer(attempt, "This host has not been seen before.");
}
catch (SshHostKeyMismatchException exception)
{
// Logged, and this is the entry the connection log most exists for. A changed host key is
// refused outright with no way past it, so the only trace it would otherwise leave is a status
// line the user dismisses — and a run of these against one machine is what somebody reviewing a
// log needs to see.
RecordFailure(row, authentication, ConnectionOutcome.Refused);
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
Answer(attempt, "The host key has changed. The connection was refused.");
}
catch (Exception exception) when (exception is not OperationCanceledException)
catch (OperationCanceledException)
{
// Everything else: an unreachable host, a rejected password, a key the remote will not take.
// Caught by shape rather than by type because this project's SSH layer defines only the two
// host-key exceptions above and everything else arrives from SSH.NET, which the client
// deliberately does not reference.
//
// Recorded and rethrown, so RunAsync goes on reporting it exactly as it did. The log is an
// observer here and must never become the thing that swallows an error. Cancellation is excluded
// because a user who gave up did not fail to connect.
RecordFailure(row, authentication, ConnectionOutcome.Failed);
throw;
Answer(attempt, "Cancelled.");
}
catch (Exception exception)
{
RecordFailure(row, authentication, ConnectionOutcome.Failed);
Abandon(attempt, exception.Message);
}
}
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
/// <remarks>
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
/// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary
/// case, because connecting does not hold the window still any more.
/// </remarks>
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
{
Status = reason;
ConnectionFailed?.Invoke(
this,
new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
}
/// <summary>
/// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
/// </summary>
/// <remarks>
/// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away
/// so the window can show the question instead. See <see cref="ConnectionFailedEventArgs"/>. Cancelling
/// counts as a question in the sense that matters here — the tab is going either way, and nothing about
/// it is worth keeping on screen.
/// </remarks>
private void Answer(ConnectionAttemptEventArgs attempt, string status)
{
Status = status;
ConnectionFailed?.Invoke(
this,
new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
}
/// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
@@ -3736,6 +3995,7 @@ internal sealed partial class VaultViewModel(
/// happy path, and everything above it is one <c>catch</c> per way of not having one.
/// </remarks>
private async Task ConnectAndAnnounceAsync(
ConnectionAttemptEventArgs attempt,
HostRowViewModel row,
HostAuthentication authentication,
CancellationToken cancellationToken)
@@ -3765,7 +4025,11 @@ internal sealed partial class VaultViewModel(
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
SessionOpened?.Invoke(
this,
new TerminalSessionEventArgs(sessionId, row.Label, Dialled(row, authentication)));
new TerminalSessionEventArgs(
attempt.AttemptId,
sessionId,
row.Label,
Dialled(row, authentication)));
}
/// <summary>The address as actually dialled.</summary>
@@ -0,0 +1,208 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the host list answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures this control rather than driving it. What
/// is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring: a
/// right click has to move the selection <em>before</em> the menu opens, because all three of that menu's
/// commands read the vault's host selection. A menu that quietly acted on whichever host happened to be
/// selected would delete the wrong machine, which is the version of this mistake worth a suite.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
/// one: compiled bindings resolve against the declared type, and the list is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostSidebarTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
session = outcome.Session!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
knownHosts.Close();
await workspace.DisposeAsync();
await vault.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
/// before, which for Delete is a question asked about one machine and answered about another.
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheSidebarAsync((sidebar, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(RowFor(sidebar, other), window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = sidebar.HostList.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// The commands are the vault's, which is the other half of putting the menu on the list rather
// than in the item template: a menu inside the template would have the row for its data context,
// and every one of these would silently bind to nothing.
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
edit.Command!.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// A heading is a row in the same list and the control will happily select it, but it is not a host —
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
/// or act on a machine somewhere else in the list.
/// </remarks>
[Fact]
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
{
await OnTheSidebarAsync((sidebar, window) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = sidebar.HostList
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
RightClick(heading, window);
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
sidebar.HostList.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
// ---- Helpers ----
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, window);
((Window)window).MouseDown(at, MouseButton.Right);
((Window)window).MouseUp(at, MouseButton.Right);
}
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
}
finally
{
window.Close();
}
},
Token);
private static ListBoxItem RowFor(Visual sidebar, HostRowViewModel host) =>
sidebar.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
private async Task SeedAsync()
{
foreach (var label in new[] { "prod-db", "stage-web" })
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
}
}
@@ -215,6 +215,27 @@ public sealed class QuickConnectTests : IAsyncLifetime
});
}
/// <remarks>
/// The pointer's version of Enter, and the gesture a list of hosts under a search box plainly looks like
/// it offers. It did not: a click moved the highlight and left the palette open over a choice that had
/// already been made, so the second thing everybody tried was to click and then press Enter.
/// </remarks>
[Fact]
public async Task ClickingAResultConnectsToIt()
{
await OnThePaletteAsync((palette, window) =>
{
var wanted = shell.SearchResults[2];
var row = RowFor(palette, wanted);
window.MouseDown(Centre(row, window), MouseButton.Left);
window.MouseUp(Centre(row, window), MouseButton.Left);
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as Enter does");
vault.SelectedHost?.EntityId.ShouldBe(wanted.EntityId);
});
}
/// <remarks>
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
@@ -280,6 +301,12 @@ public sealed class QuickConnectTests : IAsyncLifetime
},
Token);
/// <summary>The list row showing one result.</summary>
private static ListBoxItem RowFor(Visual palette, HostRowViewModel host) =>
palette.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
@@ -561,6 +561,37 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
}
// ---- The connecting card ----
//
// It fills the terminal's own rectangle, which is the one part of this window no other test can lay out:
// the WebView it stands in for cannot be attached here at all. That makes it worth measuring for exactly
// the reason the harness exists — its two buttons are the only way out of a connection that is not
// going to happen.
[Fact]
public async Task TheConnectingCardFitsWhileAConnectionIsBeingMade()
{
await MeasureConnectingAsync(
faults => faults.ShouldBeEmpty("while connecting"),
new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22"));
}
/// <remarks>
/// The taller of the two shapes, and the one with something variable in it: a refusal is whatever the
/// SSH layer said, which is a sentence rather than a word.
/// </remarks>
[Fact]
public async Task TheConnectingCardFitsWithARefusalInIt()
{
var tab = new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22");
tab.Failed(
"Permission denied (publickey,keyboard-interactive). The server closed the connection after "
+ "three attempts.");
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
}
// ---- The logs screen ----
[Fact]
@@ -1080,6 +1111,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.State = ShellState.Unlocked;
shell.Tabs.Clear();
shell.Tabs.Add(tab);
shell.SelectedTab = tab;
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
+19 -3
View File
@@ -15,6 +15,17 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
internal Exception? Failure { get; set; }
/// <summary>
/// Holds a connection open until it is completed, when set.
/// </summary>
/// <remarks>
/// A handshake takes as long as a network takes, and connecting is deliberately no longer allowed to
/// hold the window still while it does — so there is now behaviour that only exists <em>during</em> a
/// connection: a tab in the strip with no session behind it. This is how a test gets to look at that
/// moment rather than at the two on either side of it.
/// </remarks>
internal TaskCompletionSource? Gate { get; set; }
/// <summary>Requests this factory was asked for, in order.</summary>
internal List<SshConnectionRequest> Requests { get; } = [];
@@ -27,15 +38,20 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
internal List<SshConnectionRequest> SftpRequests { get; } = [];
/// <inheritdoc />
public Task<ISshConnection> ConnectAsync(
public async Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
if (Gate is { } gate)
{
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
return Failure is { } failure
? Task.FromException<ISshConnection>(failure)
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
? throw failure
: new FakeSshConnection(request);
}
/// <inheritdoc />
@@ -653,6 +653,244 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue();
}
// ---- Connecting, while it is still happening ----
//
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
// in which a tab exists and its session does not. Everything below is about that stretch: what the strip
// shows, what the window draws in the terminal's rectangle, and what happens to the tab when the
// connection answers — or does not.
/// <remarks>
/// The point of the whole thing, stated as one assertion: the tab is in the strip before the connection
/// has answered, and the vault is not busy while it waits. A user who asked for a machine that is asleep
/// used to get a status line and a window that did nothing for as long as the timeout took.
/// </remarks>
[Fact]
public async Task Connecting_ShowsATabImmediatelyAndLeavesTheVaultUsable()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsConnecting.ShouldBeTrue();
tab.HasSession.ShouldBeFalse();
tab.Label.ShouldBe("prod-db");
tab.Address.ShouldBe("deploy@db.internal:22", "named for what is being dialled, not for what answered");
// The card, not the renderer. They share one rectangle and there is no pane to put in it yet.
shell.SelectedTab.ShouldBe(tab);
shell.IsConnectingShowing.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse();
// The gate this command does not hold. Everything else on this screen still works, which is the
// difference between waiting and being stuck.
vault.IsBusy.ShouldBeFalse();
vault.Hosts.ShouldNotBeEmpty();
ssh.Gate.SetResult();
await connecting;
tab.HasSession.ShouldBeTrue();
tab.IsLive.ShouldBeTrue();
tab.Status.ShouldBeEmpty("the pane speaks for itself from here on");
shell.IsTerminalShowing.ShouldBeTrue();
shell.IsConnectingShowing.ShouldBeFalse();
}
/// <remarks>
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
/// and it stays until it is closed.
/// </remarks>
[Fact]
public async Task ARefusedConnection_LeavesATabCarryingTheReason()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new InvalidOperationException("No route to host.");
await vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsFailed.ShouldBeTrue();
tab.IsLive.ShouldBeFalse();
tab.Status.ShouldBe("No route to host.");
shell.IsConnectingShowing.ShouldBeTrue("the card is where the reason is drawn");
shell.IsTerminalShowing.ShouldBeFalse();
// Closed like any other tab, and without asking the workspace to end a session that never existed.
await shell.CloseTabCommand.ExecuteAsync(tab);
shell.Tabs.ShouldBeEmpty();
shell.IsHostsShowing.ShouldBeTrue();
}
/// <remarks>
/// The other kind of not-connecting. An unknown host key is a question drawn on the hosts screen rather
/// than a failure, so the tab goes and the window is put back where the question is — a tab saying the
/// connection failed would be competing with the prompt that is about to resume it.
/// </remarks>
[Fact]
public async Task AnUnknownHostKey_TakesTheTabAwayAndShowsTheQuestion()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.ShouldBeEmpty();
vault.HasPendingHostKey.ShouldBeTrue();
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn there, and it has to be reachable");
// And answering it connects, which is the whole reason the tab was not left saying it had failed.
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue();
}
/// <remarks>
/// Giving up on a connection that is still in flight. The tab goes at once — that is what the button
/// promises — and the handshake that finishes afterwards is adopted rather than dropped, because a shell
/// running with nothing in the window naming it is worse than a tab that comes back.
/// </remarks>
[Fact]
public async Task ClosingATabThatIsStillConnecting_TakesItAwayAndKeepsWhateverArrives()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
shell.Tabs.ShouldBeEmpty();
shell.IsHostsShowing.ShouldBeTrue();
ssh.Gate.SetResult();
await connecting;
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue("the session is real, so it gets a tab");
}
/// <remarks>
/// Two at once, which is the other thing not holding the vault made possible — and the reason an attempt
/// carries an id rather than being found by the host's name.
/// </remarks>
[Fact]
public async Task TwoConnectionsCanBeInFlightAtOnce()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await AddHostAsync(vault, "stage-web");
ssh.Gate = new TaskCompletionSource();
vault.SelectedHost = Host(vault, "prod-db");
var first = vault.ConnectCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "stage-web");
var second = vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.Select(tab => tab.Label).ShouldBe(["prod-db", "stage-web"]);
shell.Tabs.ShouldAllBe(tab => tab.IsConnecting);
ssh.Gate.SetResult();
await first;
await second;
shell.Tabs.ShouldAllBe(tab => tab.HasSession);
}
/// <remarks>
/// Locking does not end a handshake any more than it ends a shell, and the tab standing in for one is
/// shell state that survives a lock. So the answer still has to arrive somewhere: without it the tab
/// would say "connecting…" for ever and the session it opened would have nothing naming it, and so no
/// way to be closed.
/// </remarks>
[Fact]
public async Task AConnectionInFlightWhenTheVaultLocks_StillLandsInItsTab()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "tabs outlive the vault that opened them");
ssh.Gate.SetResult();
await connecting;
tab.HasSession.ShouldBeTrue();
tab.IsLive.ShouldBeTrue();
}
/// <remarks>
/// A tab is marked by whether its terminal is the thing on screen, not by whether it is the selected
/// one — the selection survives navigating away, which is what makes the strip a way back rather than a
/// way to lose a shell. Two "you are here" marks at once is one too many, and the rail's own entries
/// already make the same distinction.
/// </remarks>
[Fact]
public async Task ATabIsMarkedOnlyWhileItsTerminalIsShowing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsShowing.ShouldBeTrue("the connecting card is what the window is showing");
// Navigating away during the connection, which is the case this most exists for: the connection goes
// on, the tab stays selected, and nothing in the strip claims to be on screen.
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
tab.IsShowing.ShouldBeFalse();
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
ssh.Gate.SetResult();
await connecting;
tab.IsShowing.ShouldBeFalse("a connection that finishes while you are elsewhere does not grab the window");
shell.SelectTabCommand.Execute(tab);
tab.IsShowing.ShouldBeTrue();
// And the palette, which draws over the same rectangle.
shell.ToggleSearchCommand.Execute(null);
tab.IsShowing.ShouldBeFalse();
}
/// <remarks>
/// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
/// terminal share a rectangle, and the card is the one that has to win.
@@ -2847,6 +3085,64 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
}
/// <remarks>
/// What dragging a row onto a heading does. It is the same write the editor makes — one field of the
/// host, pushed straight away — reached without opening a form, because filing thirty imported machines
/// through the editor is thirty rounds of open, pick, save.
/// </remarks>
[Fact]
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
var group = vault.Groups.Single().EntityId;
var host = vault.Hosts.Single();
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
// Under the group's own heading now, which is the thing the drop was aiming at.
vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(header => header.GroupId == group)
.Count.ShouldBe(1);
// And back out again, which is what the ungrouped heading is a target for.
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
}
/// <remarks>
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
/// one is open would be a save nobody asked for, and one they could then not cancel.
/// </remarks>
[Fact]
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
vault.SelectedHost = vault.Hosts.Single();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
vault.Status.ShouldContain("editing");
}
/// <remarks>
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
@@ -3669,17 +3965,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
// ---- File transfer ----
/// <remarks>
/// The list is followed rather than copied at unlock, which is the whole of this test. A snapshot taken
/// when the vault opened meant a host created five seconds later could not be picked here until the
/// keychain had been locked and opened again — and nothing on the screen explained why the machine that
/// was plainly in the host list was missing from the picker.
/// </remarks>
[Fact]
public async Task TheTransfersScreen_TakesItsHostListFromTheUnlockedVault()
public async Task TheTransfersScreen_FollowsTheVaultsHostList()
{
await UnlockedAsync();
shell.Transfers.Hosts.ShouldBeEmpty("the vault has no hosts yet");
await AddHostAsync(shell.Vault!, "prod-db");
// Attached at unlock, after the vault has loaded. Before that ordering was right the picker was
// empty until something else happened to reload it.
shell.Transfers.Hosts.ShouldBeEmpty("the vault had no hosts when it was attached");
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
shell.Transfers.SelectedHost?.Label.ShouldBe("prod-db", "the only host is the one to offer");
await shell.LockCommand.ExecuteAsync(null);
shell.Transfers.Hosts.ShouldBeEmpty("those rows carry decrypted secrets");
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);