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
+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