Author SHA1 Message Date
jaap-jan 05c56f20a4 Let a closed terminal take its own status line with it
The strip along the bottom is one line for the whole window, and connecting
writes to it, so "Connected to prod-db." outlived the tab it was about: close
prod-db, look at the terminal beside it, and the bar was still reporting on a
session that is no longer there.

A connection's own lines are now owned by the attempt that wrote them, and
closing a tab clears the line only when that is what the bar is holding.
Ownership rather than a blanket clear on close, because the alternative loses
the sentences this bar exists for — a failed save or a refused sync is what the
design deleted the bar's other four fields to make room for, and closing a
terminal is no answer to one.
2026-08-10 11:41:55 +02:00
jaap-jan 7f77539ba6 Merge pull request 'Give the desktop a macOS head, signed from the first release' (#3) from claude/macos-build-release-2a8a0d into main
ci / build and test (push) Failing after 2m4s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m34s
Reviewed-on: #3
2026-08-10 08:58:59 +00:00
jaap-jan ca081af209 Merge branch 'main' into claude/macos-build-release-2a8a0d
ci / build and test (pull_request) Failing after 2m10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m12s
2026-08-10 08:58:50 +00:00
jaap-jan 8c67fce32c Centre a phone row's caption in the row it is given
ci / build and test (push) Successful in 2m18s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Failing after 1m45s
ci / api image (push) Successful in 39s
Button.row sets the height a thumb needs and left the caption's placement to
Avalonia's Stretch default, so the content presenter stretched the caption to
the whole row and a TextBlock draws its line at the top of what it is given —
the same omission the desktop head's ghost/accent/danger rule had.

Most of the thirty-three rows never showed it, which is what made the four that
did look like four unrelated mistakes rather than one rule: a row whose content
is a StackPanel or a Grid of already-centred children is centred whatever this
property says. The four that are a bare TextBlock are FilesScreen's breadcrumb
crumb, its up-one-directory chip and its pinned-path chip, and TerminalScreen's
CLOSE THIS TAB — 36 or 44 tall with no vertical padding, so measured at those
numbers the caption sat flush against the top edge with 21 to 33 pixels of
nothing under it, eleven to seventeen pixels off centre in a control barely
twice that tall.

Nothing is excluded here, unlike the desktop's own sweep: no row's content
depends on being stretched — there is no full-height strip inside any of the
thirty-three, the thing that keeps flat and cat out of the equivalent rule over
there — and the Grids that stop filling hold only children that already centre
themselves, so they land where they always did.

The other two phone classes that do not set it are both fine and neither should
get it. RadioButton.chip declares its own ControlTemplate whose presenter reads
VerticalAlignment="Center" outright, so it centres regardless and the property
would not be read; Button.scrim is the full-screen dimmer behind a sheet and
has no caption at all.

Not covered by a test, and it cannot be from here: there is no Android layout
suite, the desktop harness cannot instantiate net10.0-android views, and
AvaloniaRuntimeXamlLoader — which would let it load Phone.axaml on its own —
lives in a package this repo does not reference. What is verified is that the
head builds, so the Avalonia XAML compiler has accepted the setter, and that
the desktop's own 147 layout tests are unmoved.
2026-08-10 10:35:38 +02:00
jaap-jan 0ffd259ccd Give the rest of the button shapes their content alignment too
ci / build and test (push) Successful in 2m28s
ci / desktop nightly (push) Successful in 50s
ci / api image (push) Successful in 24s
ci / android head (push) Successful in 3m15s
The sweep the ghost/accent/danger fix implied: navuser, poprow, panechip,
chiptoggle and choice each set VerticalContentAlignment now, because each set
everything else about how its content sits and left that one to Avalonia's
Stretch default.

None of them was misbehaving. Every one is content-sized everywhere it is used
today, so Stretch and Center agreed and this moves nothing — 113 buttons across
29 screens and cards measured byte-identical before and after, the strips that
have no height of their own included. What it buys is that the day one of them
is given a height, it is already right rather than quietly drawing its label in
the top third.

flat and cat are deliberately NOT swept in, and the reasoning that would sweep
them is exactly the trap. flat carries the titlebar's search pill, a
Border.searchpill with no height of its own that is meant to fill all 35 pixels
of its button — the usage states HorizontalContentAlignment="Stretch" and takes
the vertical default to match. cat carries the keychain rail's accent strip, a
Border.rowmark whose style sets Width="2" and no height at all, "at full row
height" by its own remark. Centring either from the style shrinks a pill and a
strip that are correct today. AStretchingShapeStillFillsItsButton pins both, and
fails when flat is centred.

ButtonCaptionTests covers the five new shapes on the existing rule. Its
stretch-fill assertion reads the content slot off the presenter rather than
recomputing it from the button's Padding: the shapes differ in whether their
presenter also draws a border, and a hand-rolled sum was two pixels out on
Button.cat for that reason.
2026-08-10 10:20:26 +02:00
jaap-jan 9bc9069425 Post the terminal's focus return past the dispatch that steals it
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m23s
ci / desktop nightly (push) Successful in 54s
ci / api image (push) Successful in 25s
The first fix handed Android's focus back from inside the keys' Click
handlers — which fire inside the UP event's dispatch, and Avalonia's
own view requests focus for itself after every handled touch dispatch
returns (AvaloniaView.DispatchTouchEvent, decompiled from 12.1.1). So
the platform's request ran after ours and undid it microseconds later,
which is exactly what the phone showed: the terminal still lost focus.

The return is now posted onto the main looper, landing one message
after the dispatch that stole, and it is wired at the row for both
halves of a press — DOWN steals too, and Click only exists for UP, so
a keyboard detached at DOWN would otherwise stay detached for the whole
length of the press. Check 11.10a now also says what a tolerable blink
looks like against a failure that stays.
2026-08-09 21:35:08 +02:00
11 changed files with 322 additions and 44 deletions
+9 -6
View File
@@ -1750,15 +1750,18 @@ tap of an arrow key.
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
keep typing on the software keyboard.
**Pass:** the keyboard does not change — not its layout, not its suggestion strip, not its height — and
**Pass:** the keyboard settles back unchangedsame layout, same suggestion strip, same height — and
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
keyboard throughout.
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
handed back. This is the half `Focusable = false` cannot reach — the platform moves its focus on the touch
itself, before Avalonia decides anything — and the symptom chain is the keyboard swapping to its no-input
layout and the inset churn parking it over the very row that was tapped. See
`TerminalFocus` in the Android head's Platform folder.
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
### 11.11 Closing a connection and opening a new one both take you somewhere real
@@ -10,17 +10,24 @@ namespace DodoSSH.Client.Android.Platform;
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>: the
/// tap lands on Avalonia's own input view, which is focusable-in-touch-mode because Avalonia's text boxes
/// need it to be, and the platform hands that view focus on the way to delivering the touch. The WebView's
/// input connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no
/// text, and the inset churn that follows can leave it sitting on top of the very row that was tapped.
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
/// </para>
/// <para>
/// So each accessory key hands focus straight back once its byte is on the wire. The page inside the
/// WebView never noticed anything — its own DOM focus never moved — so regaining native focus re-establishes
/// the same input connection and the keyboard settles back to what it was. Skipped when the WebView is still
/// focused, which makes the call free on any platform arrangement where the steal never happened.
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
/// about to be made stale by the very mechanism this exists to counter.
/// </para>
/// <para>
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
@@ -39,10 +46,18 @@ internal static class TerminalFocus
return;
}
if (FindWebView(decor) is { IsFocused: false } webView)
if (FindWebView(decor) is not { } webView)
{
return;
}
webView.Post(() =>
{
if (!webView.IsFocused)
{
webView.RequestFocus();
}
});
}
private static View? FindWebView(ViewGroup parent)
+17 -1
View File
@@ -116,11 +116,27 @@
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
<!--
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
stop filling hold only children that already centre themselves, so they land where they always did.
-->
<Style Selector="Button.row">
<Setter Property="MinHeight" Value="54" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
@@ -139,29 +139,27 @@ internal sealed partial class TerminalScreen : UserControl
{
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
// length of the press; and the Return is posted past the current dispatch, so its ordering against
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
foreach (var (label, bytes, latches) in Keys)
{
var key = CreateKey(label);
// TerminalFocus.Return in both, after the key has done its work: by Click time the touch has
// already moved Android's own focus onto Avalonia's input view, and leaving it there is what
// swaps the keyboard out from over the terminal. Returning it is free when nothing moved.
if (latches)
{
controlKey = key;
key.Click += (_, _) =>
{
ToggleControl();
TerminalFocus.Return();
};
key.Click += (_, _) => ToggleControl();
}
else
{
key.Click += (_, _) =>
{
SendAsync(bytes);
TerminalFocus.Return();
};
key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
@@ -213,8 +211,9 @@ internal sealed partial class TerminalScreen : UserControl
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
// decides about its element — taking the keyboard's input connection off the terminal and
// swapping its layout mid-typing. The Click wiring in BuildAccessoryRow hands that half
// back; see TerminalFocus for the whole story.
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
// that half back; see TerminalFocus for the whole story, including why the handing back has
// to be posted rather than done inline.
Focusable = false,
};
+5
View File
@@ -153,6 +153,7 @@
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontWeight" Value="Medium" />
@@ -463,6 +464,7 @@
<Setter Property="Padding" Value="13,7" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
@@ -482,6 +484,7 @@
<Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" />
@@ -671,6 +674,7 @@
-->
<Style Selector="Button.choice">
<Setter Property="Padding" Value="10,5" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="LetterSpacing" Value="0.5" />
@@ -1120,6 +1124,7 @@
<Style Selector="Button.panechip">
<Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" />
@@ -25,6 +25,14 @@
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
── One line, several terminals ────────────────────────────────────────────────────────────────────────
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
MainWindowViewModel.CloseTabAsync.
-->
<Border Height="24" Background="{StaticResource Chrome}"
@@ -1924,9 +1924,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Closes one terminal, ending its shell.
/// </summary>
/// <remarks>
/// <para>
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
/// </para>
/// <para>
/// <b>It takes the status line with it, when that line is this tab's.</b> The strip along the bottom is
/// one line for the whole window — see <c>StatusBar.axaml</c> — and connecting writes to it, so closing
/// prod-db used to leave "Connected to prod-db." reporting on a session that is gone, over whichever
/// terminal the user looked at next. Only the sentence this attempt wrote goes; anything the vault has
/// said since stays, because a failed save is not something closing a terminal answers.
/// </para>
/// </remarks>
[RelayCommand]
private async Task CloseTabAsync(TerminalTabViewModel tab)
@@ -1960,6 +1969,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseTabState();
// Only this tab's own sentence, and only if that is what the bar currently holds — see the remark.
Vault?.ForgetConnectionStatus(tab.AttemptId);
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
// terminal just went would keep a lit dot until something else happened to move the selection.
@@ -3400,7 +3412,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
var tab = new TerminalTabViewModel(e.Label, e.Address) { AttemptId = e.AttemptId };
attempts[e.AttemptId] = tab;
AdoptTab(tab);
@@ -3429,6 +3441,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// dropping it would leave a shell running with nothing in the window naming it.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{
AttemptId = e.AttemptId,
StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
@@ -84,6 +84,18 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks>
internal uint SessionId { get; private set; }
/// <summary>
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
/// </summary>
/// <remarks>
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
/// session id at all.
/// </remarks>
internal Guid AttemptId { get; init; }
internal string Label { get; }
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
@@ -2130,6 +2130,60 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string status = string.Empty;
/// <summary>
/// The connection <see cref="Status"/> is currently about, or <see cref="Guid.Empty"/> when it is about
/// something else — a save, a sync, a refusal that has nothing to do with a terminal.
/// </summary>
/// <remarks>
/// <para>
/// The status line is one line for the whole window, and most of what goes through it is about the vault,
/// which is one thing however many terminals are open. A connection is not: "Connected to prod-db." is
/// about one tab, and it used to outlive that tab — close prod-db, look at the terminal beside it, and the
/// strip along the bottom was still reporting on a session that is no longer there.
/// </para>
/// <para>
/// Ownership rather than a blanket clear on every close, because the alternative loses the sentences this
/// bar exists for: a failed save or a refused sync is what the design deleted the bar's other four fields
/// to make room for, and closing a terminal is no reason to take one away.
/// </para>
/// <para>
/// Reset by <see cref="OnStatusChanged"/> on every write, so the only messages that carry an owner are the
/// ones <see cref="SayAbout"/> writes. Anything assigning <see cref="Status"/> in the ordinary way says,
/// by doing so, that its message belongs to no tab.
/// </para>
/// </remarks>
private Guid statusAttemptId;
partial void OnStatusChanged(string value) => statusAttemptId = Guid.Empty;
/// <summary>Writes a status line that belongs to one connection attempt.</summary>
/// <remarks>
/// After the assignment, not before: <see cref="OnStatusChanged"/> clears the owner on every write, which
/// is what makes "belongs to nobody" the default rather than something each of the other status writes
/// would have to remember to say.
/// </remarks>
private void SayAbout(ConnectionAttemptEventArgs attempt, string message)
{
Status = message;
statusAttemptId = attempt.AttemptId;
}
/// <summary>
/// Clears the status line if what it is saying is about this connection, and leaves it alone otherwise.
/// </summary>
/// <remarks>
/// Called by the shell when a tab is closed — see <c>MainWindowViewModel.CloseTabAsync</c>. Cleared to
/// empty rather than replaced with "Closed prod-db.": the tab going is the report, it is the report the
/// user just made happen, and a bar that answers every close with a sentence is a bar people stop reading.
/// </remarks>
internal void ForgetConnectionStatus(Guid attemptId)
{
if (attemptId != Guid.Empty && statusAttemptId == attemptId)
{
Status = string.Empty;
}
}
[ObservableProperty]
private int pendingChanges;
@@ -10767,7 +10821,7 @@ internal sealed partial class VaultViewModel(
Dialled(target, authentication));
ConnectionStarting?.Invoke(this, attempt);
Status = $"Connecting to {target.Label}…";
SayAbout(attempt, $"Connecting to {target.Label}…");
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
}
@@ -11042,7 +11096,9 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
{
Status = reason;
// Owned by the attempt, unlike Answer's own line below: this is the case where the tab stays, carrying
// the same reason, so closing that tab is exactly the moment the line stops being about anything.
SayAbout(attempt, reason);
ConnectionFailed?.Invoke(
this,
@@ -11097,7 +11153,7 @@ internal sealed partial class VaultViewModel(
// only record that machine was reached at all.
connectionLog?.Identify(sessionId, target.Label, target.HostId);
Status = $"Connected to {target.Label}.";
SayAbout(attempt, $"Connected to {target.Label}.");
// The session the workspace just opened is the only place the negotiated cipher and host-key
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
@@ -11128,7 +11184,8 @@ internal sealed partial class VaultViewModel(
// screen it is typed on says so.
if (target.Row is { } row)
{
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
await RememberTypedPasswordAsync(attempt, row, authentication, cancellationToken)
.ConfigureAwait(true);
}
}
@@ -11160,6 +11217,7 @@ internal sealed partial class VaultViewModel(
/// </para>
/// </remarks>
private async Task RememberTypedPasswordAsync(
ConnectionAttemptEventArgs attempt,
HostRowViewModel row,
HostAuthentication authentication,
CancellationToken cancellationToken)
@@ -11175,8 +11233,10 @@ internal sealed partial class VaultViewModel(
if (row.IsReadOnly)
{
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
SayAbout(
attempt,
$"Connected to {row.Label}. Its password was not saved: this host was written by a newer "
+ "version of DodoSSH, and binding a credential would re-encode it.");
return;
}
@@ -11200,7 +11260,9 @@ internal sealed partial class VaultViewModel(
}
catch (Exception exception)
{
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
SayAbout(
attempt,
$"Connected to {row.Label}, but its password could not be saved: {exception.Message}");
return;
}
@@ -11211,8 +11273,10 @@ internal sealed partial class VaultViewModel(
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
+ "will not be asked for again.";
SayAbout(
attempt,
$"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it will "
+ "not be asked for again.");
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
@@ -1,13 +1,14 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Layout;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The three button shapes centre their caption inside a button taller than the caption.
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
/// </summary>
/// <remarks>
/// <para>
@@ -41,6 +42,15 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
/// the day the face does. "Centred" survives both.
/// </para>
/// <para>
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
/// correct.
/// </para>
/// </remarks>
public sealed class ButtonCaptionTests
{
@@ -61,6 +71,11 @@ public sealed class ButtonCaptionTests
[InlineData("ghost")]
[InlineData("accent")]
[InlineData("danger")]
[InlineData("navuser")]
[InlineData("poprow")]
[InlineData("panechip")]
[InlineData("chiptoggle")]
[InlineData("choice")]
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
{
await MeasureAsync(
@@ -96,6 +111,72 @@ public sealed class ButtonCaptionTests
"the caption should sit high, which is the defect this suite was written for"));
}
/// <summary>
/// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
/// </summary>
/// <remarks>
/// <para>
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
/// not a comment.
/// </para>
/// <para>
/// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
/// that is meant to fill all 35 pixels of the button; the usage states
/// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
/// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
/// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
/// height is the stretch and nothing else.
/// </para>
/// <para>
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
/// </para>
/// </remarks>
[Theory]
[InlineData("flat")]
[InlineData("cat")]
public async Task AStretchingShapeStillFillsItsButton(string shape)
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
// A bare Border is what both of them actually hold: no height, sized only by its parent.
var fill = new Border();
var button = new Button { Content = fill, Height = FixedHeight };
button.Classes.Add(shape);
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// The slot read off the presenter rather than recomputed from the button's Padding:
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
// sum was two pixels out on Button.cat for exactly that reason.
var presenter = button.GetVisualDescendants()
.OfType<ContentPresenter>()
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
var slot = presenter.Bounds.Height
- presenter.Padding.Top - presenter.Padding.Bottom
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
fill.Bounds.Height.ShouldBe(
slot,
Tolerance,
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
+ "strip have no height of their own");
}
finally
{
window.Close();
}
},
Token);
}
private static Task MeasureAsync(
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1254,6 +1254,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The status line is one line for the whole window, so a sentence about a session that has been closed is
/// a sentence the user reads over some other terminal.
/// </remarks>
[Fact]
public async Task ClosingATab_TakesItsOwnStatusLineWithIt()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
vault.Status.ShouldBeEmpty();
}
/// <remarks>
/// The other half, and the reason closing does not simply blank the bar: everything this application has
/// to say about a save, a sync or a refusal goes through the same line — see <c>StatusBar.axaml</c> — and
/// closing a terminal answers none of it.
/// </remarks>
[Fact]
public async Task ClosingATab_LeavesAStatusLineThatIsAboutSomethingElse()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status = "The keychain could not be saved.";
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
vault.Status.ShouldBe("The keychain could not be saved.");
}
/// <remarks>
/// Two sessions to the same host, which is the case a label could not tell apart: the line belongs to the
/// attempt that wrote it, so closing the other one leaves it alone.
/// </remarks>
[Fact]
public async Task ClosingATab_LeavesTheLineAnotherTabWrote()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var first = shell.Tabs[0];
// The second connection's own line is what the bar holds now.
await vault.ConnectCommand.ExecuteAsync(null);
var reported = vault.Status;
await shell.CloseTabCommand.ExecuteAsync(first);
vault.Status.ShouldBe(reported);
}
// ---- 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