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 @@ - + - + - - - - - - - - - - + - - - - - - - - + + + + - + + + + + + - - + + @@ -90,19 +120,54 @@ - - + + - - - + + + + + + + + + + + + + + + + + + + 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