diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index 6216db9..c497ccc 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -720,3 +720,24 @@ grid of cards with a drawer — see above. The split it describes did not change
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
+
+---
+
+## v5c-4 — two more, asked for after living with v5b
+
+**The session shell's host header is gone, and it is a deliberate departure from the design.**
+`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
+left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
+facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
+button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
+one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
+full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
+back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
+subtracting the retired window-wide tab strip.
+
+**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
+spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
+to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
+that vanishes without trace is one people report as lost. The choice is written through to
+`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
+window a terminal gets, and one that had to be made again on every launch would not really be on offer.
diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml
index 3cb95e3..68240a2 100644
--- a/src/DodoSSH.Client.App/App.axaml
+++ b/src/DodoSSH.Client.App/App.axaml
@@ -475,11 +475,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -191,17 +197,22 @@
-
-
+
+
+
-
@@ -277,11 +295,11 @@
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/DodoSSH.Client.App/Views/SessionHeader.axaml.cs b/src/DodoSSH.Client.App/Views/SessionHeader.axaml.cs
deleted file mode 100644
index ee4dd87..0000000
--- a/src/DodoSSH.Client.App/Views/SessionHeader.axaml.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System.Windows.Input;
-using Avalonia;
-using Avalonia.Controls;
-
-namespace DodoSSH.Client.App.Views;
-
-///
-/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
-/// See the remark at the top of SessionHeader.axaml.
-///
-internal sealed partial class SessionHeader : UserControl
-{
- /// What the cross-surface ghost button says — "Open SFTP" or "Open terminal".
- internal static readonly StyledProperty OpenLabelProperty =
- AvaloniaProperty.Register(nameof(OpenLabel));
-
- /// What the cross-surface ghost button runs.
- ///
- /// The terminal usage binds SelectFilesHostCommand with the selected tab as its parameter; the
- /// SFTP usage binds OpenTerminalForFilesHostCommand, which needs none — see the remark on both in
- /// MainWindowViewModel for why the two directions are not symmetrical.
- ///
- internal static readonly StyledProperty OpenCommandProperty =
- AvaloniaProperty.Register(nameof(OpenCommand));
-
- internal static readonly StyledProperty
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
index b1e151a..8c8b33f 100644
--- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
@@ -9,8 +9,8 @@
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
the same three window-control glyphs on the right TitleBar.axaml already draws.
- A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
- SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
+ A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
+ SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml b/src/DodoSSH.Client.App/Views/TitleBar.axaml
index ed5c3b5..6dbf537 100644
--- a/src/DodoSSH.Client.App/Views/TitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml
@@ -79,8 +79,16 @@
-->
-
-
+
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
///
+///
+/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
+///
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
- bool AutomaticUpdateChecks = true)
+ bool AutomaticUpdateChecks = true,
+ bool SessionSidebarOpen = true)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
@@ -102,6 +106,24 @@ public sealed record ClientSettings(
warns against.
*/
+ /*
+ SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
+
+ On by default, because the sidebar is where a session's pins, its snips and the way across to the
+ other surface live — a first launch that hid all three would be hiding the feature rather than
+ offering to.
+
+ Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
+ gets, and a choice that has to be made again on every launch is one the application is not really
+ offering. It belongs in this file rather than in the vault for the same reason the font size does:
+ it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
+ monitor onto a laptop would be a preference nobody asked for.
+
+ Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
+ SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
+ window that appears to lose it at random.
+ */
+
/// Brings a value inside the range this type will store.
public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 26d879c..39a297b 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -550,16 +550,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
updateScreen = CreateUpdateScreen(updates);
- // Read straight away rather than at first use, so the value is right before anything can read it —
- // a phone draws its terminal buttons from this, and a size that arrived a moment later would show
- // as the interface correcting itself.
- TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
+ ApplyStoredPreferences();
_ = TellRendererTheFontSizeAsync();
StartSessionShellTracking();
}
+ ///
+ /// Takes this machine's own preferences off disk, before anything can read them.
+ ///
+ ///
+ /// Read straight away rather than at first use, and both of them for the same reason: whatever is stored
+ /// is what the first window draws. A phone builds its terminal's font buttons from the size, and the
+ /// session shell decides whether to give a sidebar 300 pixels — either arriving a moment later shows as
+ /// the interface correcting itself in front of the user.
+ ///
+ private void ApplyStoredPreferences()
+ {
+ var stored = settings.Read();
+
+ TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize);
+ IsSessionSidebarOpen = stored.SessionSidebarOpen;
+ }
+
///
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
///
@@ -1003,6 +1017,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
internal event EventHandler? TerminalSessionOpened;
+ ///
+ /// Raised when something this shell did belongs in the terminal the user is already looking at, so the
+ /// view can put the keyboard back there.
+ ///
+ ///
+ /// Separate from because no session opened: the sidebar's SNIPS row
+ /// typed into one that was already running, and the click that did it moved Win32 focus onto an Avalonia
+ /// button. The page cannot fix that from its side — see the term.focus() at the end of
+ /// terminal.js's paste handler, which only ever reaches document.activeElement — so the
+ /// half that can only be done by the host is asked for here. The view re-checks that a terminal is
+ /// actually showing before it acts; see MainWindow.FocusTerminalWhenLaidOut.
+ ///
+ internal event EventHandler? TerminalFocusRequested;
+
internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
@@ -3824,6 +3852,72 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
(IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected);
+ ///
+ /// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back.
+ ///
+ ///
+ /// A separate question from , and the two are not interchangeable:
+ /// that one is "is there a session for this to be about", which the shell answers, and this one is "does
+ /// the person want to see it", which only they can. Closed still draws something — a 34-pixel rail with
+ /// the way back on it; see SessionSidebar.axaml — because a panel that vanishes with no trace of
+ /// how to get it back is one people report as lost rather than as closed. Remembered between launches;
+ /// see and ClientSettings.SessionSidebarOpen.
+ ///
+ [ObservableProperty]
+ private bool isSessionSidebarOpen = true;
+
+ /// Opens the session sidebar, or closes it to its rail.
+ ///
+ /// Written through on every toggle rather than on shutdown: this shell is disposed on paths that do not
+ /// all run to completion — a killed process, a phone's activity going away — and a preference that
+ /// survives only a clean exit is one that will sometimes be forgotten for no reason the user can see.
+ /// The store swallows its own failures and says whether it wrote; nothing here can do anything useful
+ /// with the answer, so the toggle stands whether or not the disk took it.
+ ///
+ [RelayCommand]
+ private void ToggleSessionSidebar()
+ {
+ IsSessionSidebarOpen = !IsSessionSidebarOpen;
+
+ _ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen });
+ }
+
+ ///
+ /// The label on the sidebar's cross-surface row: where the other half of this host is.
+ ///
+ ///
+ /// v5c-4 moved this button off the session shell's own 60-pixel header row and into the sidebar, and the
+ /// header went with it — see SessionSidebar.axaml. What the two surfaces hand in separately used
+ /// to be a pair of properties on the header control; it is resolved here now, for the same reason
+ /// is: the sidebar is one control drawn on both surfaces, and a view that
+ /// branched on which one it was would be asking a question the shell has already answered.
+ ///
+ internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal";
+
+ /// Goes to the other half of the session the sidebar is about.
+ ///
+ /// The two directions were two commands bound from two usages of the header control, and they still are
+ /// two methods — takes a tab and opens an SFTP connection to its host;
+ /// dials a fresh terminal at whatever SFTP has open, because
+ /// there is no terminal session to reuse. What is new is only that one control now asks for both, so the
+ /// branch lives here beside , which has to agree with it.
+ ///
+ [RelayCommand]
+ private async Task OpenOtherSurfaceAsync()
+ {
+ if (IsTerminalSurface)
+ {
+ if (SelectedTab is { } tab)
+ {
+ await SelectFilesHostAsync(tab).ConfigureAwait(true);
+ }
+
+ return;
+ }
+
+ await OpenTerminalForFilesHostAsync().ConfigureAwait(true);
+ }
+
///
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
///
@@ -3896,6 +3990,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (screen.CanInsert)
{
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
+
+ // The click that got here took the keyboard off the terminal and gave it to the sidebar row, so
+ // the command lands at a prompt that cannot be typed at until somebody clicks the pane. Asked
+ // for after the insert rather than before it, so the caret arrives to find the text already
+ // there. See TerminalFocusRequested.
+ TerminalFocusRequested?.Invoke(this, EventArgs.Empty);
return;
}
@@ -3989,14 +4089,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
///
- /// The account and endpoint the session shell's header and status bar are about right now, or null when
+ /// The account and endpoint the session shell's sidebar and status bar are about right now, or null when
/// neither surface has one.
///
///
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
- /// source directly — SessionHeader.axaml and SessionStatusBar.axaml are the same markup on
+ /// source directly — SessionSidebar.axaml and SessionStatusBar.axaml are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
- /// to. The terminal's is 's own address; SFTP's is ,
+ /// to. It was the retired header row that printed this first; v5c-4 moved the line into the sidebar's own
+ /// session block and left this property exactly as it was. The terminal's is 's own address; SFTP's is ,
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
///
internal string? SessionAddress => Surface switch
@@ -4141,6 +4242,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(SessionIdentityLabel));
OnPropertyChanged(nameof(SessionIdentityText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
+
+ // v5c-4: the sidebar's cross-surface row says where the other half of this session is, so it turns
+ // over with the surface exactly as the facts above do.
+ OnPropertyChanged(nameof(SessionCrossSurfaceLabel));
}
///
diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.css b/src/DodoSSH.Client.Shell/WebAssets/terminal.css
index 8992ec5..6dc9380 100644
--- a/src/DodoSSH.Client.Shell/WebAssets/terminal.css
+++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.css
@@ -78,6 +78,50 @@ body {
height: 100%;
}
+/*
+ ── THE BLACK STRIP UNDER THE TERMINAL ───────────────────────────────────────────────────────────────
+ xterm.css paints its scrolling viewport #000 — literally black, and its own comment says why: on macOS
+ the overlay scrollbar is only fully opaque over an opaque backdrop. Everywhere else that black is a
+ surface nobody sees, because the rows cover it — except along the bottom, where they do not. The fit
+ addon floors the row count, so whatever is left of the pane below the last whole row is viewport with
+ nothing drawn on it: a full-width black bar under the terminal, up to one line tall, against this
+ page's own #171a26. On Windows it is also where the classic scrollbar's bottom corner lands, which is
+ the light square at its right-hand end.
+
+ Repainting it in the page's own background is the whole fix. The remainder is still there — it is the
+ cost of a grid that has to divide evenly — but it now reads as the terminal's own margin rather than
+ as a strip of chrome that belongs to something else.
+*/
+.xterm .xterm-viewport {
+ background-color: var(--dodo-background);
+
+ /*
+ And the scrollbar itself, which WebView2 draws in the classic Windows style: a 15-pixel light-grey
+ channel with arrow buttons, down the right of a near-black terminal. Thin and in this page's own
+ colours instead — kept rather than hidden, because the scrollback is real and a surface that scrolls
+ with no sign that it does is worse than a quiet bar saying where you are.
+
+ Both spellings. scrollbar-width/-color is the standard one and is what current WebView2 and WebKitGTK
+ honour; ::-webkit-scrollbar is what older Chromium builds and WKWebView answer to. Neither is
+ load-bearing on its own and the two do not conflict — whichever the host understands wins.
+ */
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--dodo-muted) 45%, transparent) transparent;
+}
+
+.xterm .xterm-viewport::-webkit-scrollbar {
+ width: 9px;
+}
+
+.xterm .xterm-viewport::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.xterm .xterm-viewport::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--dodo-muted) 45%, transparent);
+ border-radius: 5px;
+}
+
#status {
position: absolute;
left: 0;
diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
index c2d4664..1d7f8b1 100644
--- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
@@ -417,8 +417,23 @@ function handleFrame(buffer) {
is something only this page sees — and a shell that receives a multi-line command inside those
markers treats every newline as text. Without them it treats each one as "run this", so a
three-line snippet runs three commands the moment it is inserted.
+
+ ◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline
+ uses to decide it has been pasted into, and bash marks the result as an active region: the
+ inserted command sits at the prompt in reverse video, looking selected, until the next
+ keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a
+ snippet they picked off the sidebar, which should read as though they had typed it.
+
+ The markers are only load-bearing for text carrying a newline — that is the whole of what the
+ paragraph above protects against — so a single-line snippet does not need them and is written
+ as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands
+ unasked" is the worse of the two.
*/
- session.term.paste(text);
+ if (text.includes('\n') || text.includes('\r')) {
+ session.term.paste(text);
+ } else {
+ session.term.input(text);
+ }
/*
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
@@ -430,6 +445,15 @@ function handleFrame(buffer) {
session.term.input('\r');
}
+ /*
+ The caret goes back where the text landed. Half of it, anyway: this reaches
+ document.activeElement and nothing further, so it is what makes the pane the page's own focused
+ element and what stops a hidden textarea from keeping the caret. The other half is Win32
+ focus — the sidebar row that sent this frame took it — and only the host can give that back;
+ see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut.
+ */
+ session.term.focus();
+
break;
}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
index 2454569..6beee52 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
@@ -122,8 +122,14 @@ internal static class LayoutHarness
/// The v5b session shell's own right-hand sidebar, from SessionSidebar.axaml.
internal const double SessionSidebarWidth = 300;
- /// The v5b session shell's own host header, from SessionHeader.axaml.
- internal const double SessionHeaderHeight = 60;
+ /*
+ A third session-shell constant stood here through wave B and C: SessionHeaderHeight, 60 pixels, for
+ the host header that sat above the pane on both surfaces. v5c-4 retires that row — its address and
+ its cross-surface button both live in the sidebar now; see SessionSidebar.axaml — so the pane between
+ the tab row and the status bar is 60 pixels taller and this budget no longer subtracts anything for
+ it. The same treatment the retired window-wide tab strip got above, and for the same reason: a
+ constant for chrome that is not drawn is a budget that quietly under-measures every screen.
+ */
/// The v5b session shell's own status bar, from SessionStatusBar.axaml.
internal const double SessionStatusBarHeight = 37;
@@ -146,12 +152,12 @@ internal static class LayoutHarness
/// The arithmetic, top to bottom: less on
/// both the top and the bottom of the outer padded column, less for the
/// tab row that sits above the bordered container, less on both
- /// the top and the bottom of that border, less and
- /// for the two fixed strips the pane sits between.
+ /// the top and the bottom of that border, less for the one fixed
+ /// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
///
internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- - SessionHeaderHeight - SessionStatusBarHeight;
+ - SessionStatusBarHeight;
///
/// The width a session-shell screen gets, with or without SessionSidebar's own QUICK ACCESS
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
index 8c50eea..3080823 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
@@ -1,8 +1,10 @@
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
+using Avalonia.Media;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
@@ -272,6 +274,49 @@ public sealed class NavRailTests : IAsyncLifetime
});
}
+ ///
+ /// Every row in the popover rests flat, and the pointer is what fills one.
+ ///
+ ///
+ /// Button.poprow set a radius and a padding and left the Background alone, so each row wore the
+ /// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six
+ /// lines of text. Read as a colour off the templated presenter rather than off the Button, because that
+ /// is where the theme puts its brush and therefore the only place the absence of one can be proven.
+ ///
+ /// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows
+ /// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse
+ /// answer than one that answers wrongly.
+ ///
+ [Fact]
+ public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
+ {
+ await OnTheRailAsync((rail, window) =>
+ {
+ Click(UserChip(rail), window);
+
+ var row = PopoverRow(window, "Settings");
+ var presenter = row.GetVisualDescendants()
+ .OfType()
+ .First(candidate => candidate.Name is "PART_ContentPresenter");
+
+ var resting = presenter.Background as ISolidColorBrush;
+
+ (resting is null || resting.Color.A == 0).ShouldBeTrue(
+ $"a popover row rests flat, and this one is filled with {resting?.Color}");
+
+ var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
+ ?? throw new InvalidOperationException("the row is not in this window's tree");
+
+ window.MouseMove(centre);
+ LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
+
+ row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
+ (presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe(
+ (byte)0,
+ "a row that does not change under the pointer is one nobody can tell is clickable");
+ });
+ }
+
// ---- Helpers ----
private Task OnTheRailAsync(Action body) =>
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 488cf1f..dd0d8b9 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -1274,7 +1274,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// this screen — see ShowsQuickAccessSidebar — so this is the test that actually reaches the
/// 472-pixel budget computes, 204 pixels a side. DISCONNECT
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
- /// share the row with it moved out, because SessionHeader already prints the same address above
+ /// share the row with it moved out, because the session shell already prints the same address beside
/// this screen — see TransfersScreen.axaml's own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width.
///
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs
new file mode 100644
index 0000000..03fe383
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/SessionSidebarTests.cs
@@ -0,0 +1,184 @@
+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.Shell.ViewModels;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using NSubstitute;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
+///
+///
+/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block
+/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that
+/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width
+/// column holding a string of unbounded length beside a button that must stay clickable; the second is a
+/// width the rest of the window has to cope with, and MainWindow.axaml copes with it by asking this
+/// control how wide it is rather than by knowing.
+///
+public sealed class SessionSidebarTests : IAsyncLifetime
+{
+ /// The widths SessionSidebar.axaml declares for its two states.
+ private const double OpenWidth = 300;
+
+ ///
+ private const double RailWidth = 34;
+
+ ///
+ /// Long on purpose, and longer than the column is wide at this font: the address is the one string here
+ /// whose length nobody controls, and it shares its row with the button that closes the column.
+ ///
+ private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
+
+ private string directory = null!;
+ private ClientCacheFactory caches = null!;
+ private TerminalWorkspace workspace = null!;
+ private MainWindowViewModel shell = null!;
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ ///
+ public ValueTask InitializeAsync()
+ {
+ // A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written
+ // through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths
+ // would be editing the preferences of whoever ran it.
+ directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}");
+ caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}");
+
+ workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
+ Substitute.For(),
+ TimeProvider.System);
+
+ shell = new MainWindowViewModel(
+ new ClientPaths(directory),
+ caches,
+ workspace,
+ new VaultKnownHostStore(),
+ Substitute.For(),
+ (_, _) => throw new NotSupportedException("nothing here signs in"),
+ TimeProvider.System,
+ Substitute.For())
+ {
+ State = ShellState.Unlocked,
+ };
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await shell.DisposeAsync();
+ await workspace.DisposeAsync();
+ caches.Dispose();
+
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ ///
+ /// The address trims and the close button stays where it is; that is the whole claim. Asserted through
+ /// rather than by reading the address's own width, because what
+ /// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing
+ /// beside it was pushed out of the column to make room.
+ ///
+ [Fact]
+ public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt()
+ {
+ await OnTheSidebarAsync((sidebar, window) =>
+ {
+ // DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on
+ // the Border inside it, so what the surrounding "Auto" column is given — and what this asks for
+ // — is what the control asks for, not how wide a host window happened to stretch it.
+ sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
+
+ shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one");
+
+ LayoutHarness.Unreachable(window).ShouldBeEmpty();
+ });
+ }
+
+ ///
+ /// The cross-surface button that used to live in the header row. Read off the control rather than off the
+ /// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still
+ /// draw as an empty button — fails this.
+ ///
+ [Fact]
+ public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
+ {
+ await OnTheSidebarAsync((sidebar, _) =>
+ {
+ var button = sidebar.GetVisualDescendants()
+ .OfType