Let the connecting card's host name and address be taken away

The card that fills the terminal's rectangle while a connection is being made
prints two lines saying which machine and at what address, and both were
TextBlocks — which in Avalonia is a drawing of a string rather than a string.

It is the one place the shell says what it is dialling *before* anything has
answered, and on a refusal it is the only copy of that on screen: there is no
prompt yet and there may never be one. That is exactly when the value is wanted
somewhere else — in a ticket, in a message to whoever owns the host, in the ssh
command being written in the window beside this one — and retyping it off the
screen is how a digit goes missing from an address that is mostly digits.

SelectableTextBlock, the same change the session column's own address got in
"Let the session address be selected, and copied whole" and for the same reason.
Nothing else about either line moves: same font, size, weight, colour, trimming
and centring. The refusal underneath them was already selectable, which made the
pair above it the odd ones out.

Both lines rather than only the name. They are two lines of one identifier, and a
pair where the first can be taken and the second cannot is a worse answer than
either.

Tooltips are new, and they are what makes the trimming safe rather than a trap.
A drag reaches only the glyphs that were drawn, so a name or an address wearing
an ellipsis has to be taken with select-all — which takes the whole value,
because the ellipsis is a drawing and not a shorter string. The hover says what
the whole value is, so nobody copies half of one without having been told there
is a half. Same argument, same shape, as the sidebar's.

The test asserts that half rather than the easy one, on values long enough to be
trimmed inside the card's 460 pixels; values that fit would pass whether the
trimming were render-only or not. Confirmed it bites by putting the two
TextBlocks back and watching it fail.

The phone draws the same two lines in the same state — TerminalScreen.axaml's
IsConnectingShowing block — and is deliberately left alone. Its captions are
styled by TextBlock.title and TextBlock.detail selectors in Phone.axaml, so the
same substitution there silently drops the styling until those rules gain
SelectableTextBlock siblings, which is a theme change rather than this one. Worth
doing; not worth smuggling in here.

Also: WindowDecorations on the splash instead of SystemDecorations, which
Avalonia 12 has deprecated in favour of it. That warning was mine, added two
commits ago; the main window's own is older and is not this change's to move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5 committed 2026-08-26 17:25:43 +02:00
1 parent 7d1f0a4b53
commit dec2cf164a
3 files changed
+108 -9

No files matched your search

@@ -111,13 +111,33 @@
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460" <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
Margin="24"> Margin="24">
<!--
Which machine, and at what address. Both selectable, for the reason the session column's own address
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
and this pair is the one place the shell says what it is dialling *before* anything has answered.
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
the host, in the ssh command being written in the window beside this one. It is also the moment it is
least available anywhere else, because there is no prompt yet and may never be one — a refused
connection leaves this card holding the only copy on screen.
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
be taken and the second cannot is a worse answer than either.
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
having been told there is a half. Same argument, same shape, as the sidebar's.
-->
<StackPanel Spacing="6" HorizontalAlignment="Center"> <StackPanel Spacing="6" HorizontalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium" <SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
Foreground="{StaticResource Text}" HorizontalAlignment="Center" FontWeight="Medium" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" /> HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5" ToolTip.Tip="{Binding SelectedTab.Label}" />
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center" <SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" /> Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding SelectedTab.Address}" />
</StackPanel> </StackPanel>
<!-- <!--
@@ -10,7 +10,7 @@
CanResize="False" CanResize="False"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource Chrome}" Background="{StaticResource Chrome}"
SystemDecorations="None"> WindowDecorations="None">
<!-- <!--
── WHY THERE IS A SPLASH AT ALL ─────────────────────────────────────────────────────────────────────── ── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
@@ -30,8 +30,10 @@
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
one. one.
SystemDecorations="None" rather than BorderOnly, which the main window uses: this cannot be resized, has WindowDecorations="None" rather than the BorderOnly the main window asks for: this cannot be resized, has
no titlebar of its own to drag by, and lives for about a second. What replaces the frame is the card's no titlebar of its own to drag by, and lives for about a second. (The main window still says
SystemDecorations, which Avalonia 12 has deprecated in favour of this property; that one is not this
change's to move.) What replaces the frame is the card's
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind. own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ───────────────────────────────── ── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
@@ -942,6 +942,49 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab); await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
} }
/// <remarks>
/// <para>
/// The card is the one place the shell says what it is dialling <em>before</em> anything answers, and on a
/// refusal it is the only copy of that on screen — so both lines are there to be taken away, and a
/// <c>TextBlock</c> in Avalonia is a drawing of a string rather than a string. Same change, same argument,
/// as the session column's own address; see <c>SessionSidebarTests</c>.
/// </para>
/// <para>
/// Asserted on values long enough to be trimmed, which is the half that could quietly break: an ellipsis
/// is a drawing and not a shorter string, so select-all has to reach the whole name and the whole address
/// rather than the part that happened to fit inside the card's 460 pixels. Values that fit would pass
/// whether that were true or not.
/// </para>
/// </remarks>
[Fact]
public async Task TheConnectingCardsNameAndAddressSelectAndCopyWhole()
{
const string label = "customer-production-database-01.eu-west-1.internal.example.company";
const string address = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
await OnTheConnectingCardAsync(
card =>
{
// Found by their text rather than by position: the order is the visual tree's, and a third
// selectable block is already in there — the refusal reason, hidden while this tab is only
// connecting. Single rather than First, so a duplicate would be a question rather than a
// silent pick.
var takeable = card.GetVisualDescendants().OfType<SelectableTextBlock>().ToList();
var name = takeable.Single(block => block.Text == label);
var endpoint = takeable.Single(block => block.Text == address);
name.SelectAll();
name.SelectedText.ShouldBe(label, "the ellipsis is a drawing, not a shorter string");
name.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
endpoint.SelectAll();
endpoint.SelectedText.ShouldBe(address, "the ellipsis is a drawing, not a shorter string");
endpoint.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
},
new TerminalTabViewModel(label, address));
}
// ---- The logs screen ---- // ---- The logs screen ----
[Fact] [Fact]
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}, },
Token); Token);
/// <summary>Lays the connecting card out and hands it over, rather than measuring it.</summary>
/// <remarks>
/// The same rectangle <see cref="MeasureConnectingAsync"/> uses and for the same reasons — see its
/// remark. Separate because the claim is about a control's own state rather than about where anything was
/// arranged, and threading an assertion over the card through a method whose parameter is a fault list
/// would make both callers harder to read than two methods are.
/// </remarks>
private Task OnTheConnectingCardAsync(Action<ConnectingCard> body, 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.SessionScreenWidth(sidebarVisible: true),
LayoutHarness.SessionScreenHeight);
try
{
body(card);
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary> /// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>