Files
DodoSSH/src/DodoSSH.Client.App/Views/ConnectingCard.axaml
T
laurensandClaude Opus 5 dec2cf164a 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>
2026-08-26 17:25:43 +02:00

231 lines
13 KiB
XML

<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.
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
"How far along it is" used to be one line of prose that never changed after the tab was created, which
made every slow connection look exactly like every hung one. It is now the five steps of actually
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
what is true and purple is what you can press; a step still happening is neither, and it is precisely
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
the accent, because there is nothing on it to press.
Nothing here animates, which is the argument the transfer strip makes for its own track in
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
actually finished and then waits there. Waiting is what waiting looks like.
The list is drawn for both states rather than once per state. A refused connection has the same five
rows and the same track — the difference is only that one row is red and the track stops — and drawing
it twice would be two templates to keep identical for the sake of a colour.
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.
-->
<UserControl.Styles>
<!--
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
that differs from it is the one line that says how.
-->
<Style Selector="TextBlock.stepcaption">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepcaption.done">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.stepcaption.running">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<Style Selector="TextBlock.stepcaption.stopped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
<!--
The marker beside each caption. Fixed width and centred, because four different characters on a
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
-->
<Style Selector="TextBlock.stepmark">
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Width" Value="14" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.stepmark.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.stepmark.running">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="TextBlock.stepmark.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<!--
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
the same thing as the row it stopped on rather than staying the colour of something still being waited
for. Chip underneath, matching the transfer strip's track.
-->
<Style Selector="ProgressBar.steptrack">
<Setter Property="Height" Value="5" />
<Setter Property="MinHeight" Value="5" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<Style Selector="ProgressBar.steptrack.stopped">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Panel>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
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">
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
FontWeight="Medium" Foreground="{StaticResource Text}"
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding SelectedTab.Label}" />
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding SelectedTab.Address}" />
</StackPanel>
<!--
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
means the bar is a count of things that really finished, and the arithmetic that would turn it into
a percentage is exactly the arithmetic that would start inventing one.
-->
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="7" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="stepmark"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Mark}" />
<TextBlock Classes="stepcaption mono"
Classes.done="{Binding IsDone}"
Classes.running="{Binding IsRunning}"
Classes.stopped="{Binding IsStopped}"
Text="{Binding Caption}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
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. It sits under the list rather
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
-->
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"
IsVisible="{Binding SelectedTab.IsFailed}" />
<TextBlock Classes="hint" FontSize="11.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.
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
question both a connection taking too long and a connection just refused actually raise — has this
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
in the strip and the card is still here on the way back.
-->
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<!--
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
connect path, into this same tab — and it is worth having here for the reason the commonest
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
MainWindowViewModel.CanReconnectSelectedTab.
-->
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}" />
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsConnecting}" />
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsVisible="{Binding SelectedTab.IsFailed}" />
</StackPanel>
</StackPanel>
</Panel>
</UserControl>