From 43c939b6978ee8a787074a1ea81a7a486c9ac0b6 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sat, 8 Aug 2026 00:49:59 +0200 Subject: [PATCH] Give the window its v5b chrome and each session surface its own shell --- src/DodoSSH.Client.App/App.axaml | 428 ++++++++---- src/DodoSSH.Client.App/Views/MainWindow.axaml | 427 ++++++------ src/DodoSSH.Client.App/Views/NavRail.axaml | 398 ++++++++---- src/DodoSSH.Client.App/Views/NavRail.axaml.cs | 78 ++- .../Views/SessionHeader.axaml | 49 ++ .../Views/SessionHeader.axaml.cs | 58 ++ .../Views/SessionSidebar.axaml | 108 ++++ .../Views/SessionSidebar.axaml.cs | 29 + .../Views/SessionStatusBar.axaml | 55 ++ .../Views/SessionStatusBar.axaml.cs | 30 + .../Views/SessionTabRow.axaml | 102 +++ .../Views/SessionTabRow.axaml.cs | 63 ++ .../Views/TerminalTabs.axaml | 314 --------- .../Views/TerminalTabs.axaml.cs | 108 ---- src/DodoSSH.Client.App/Views/TitleBar.axaml | 105 +-- .../Views/TransfersScreen.axaml | 519 ++++++++++----- .../ViewModels/LogsViewModel.cs | 28 + .../ViewModels/MainWindowViewModel.cs | 612 +++++++++++++++--- .../ViewModels/SnippetsViewModel.cs | 97 ++- .../ViewModels/TerminalTabViewModel.cs | 14 + .../ViewModels/TransfersViewModel.cs | 108 +++- .../ViewModels/VaultViewModel.cs | 249 ++++++- .../LayoutHarness.cs | 112 +++- .../NavRailTests.cs | 338 ++++++++++ .../ScreenLayoutTests.cs | 390 +++++++++-- .../SessionTabRowTests.cs | 391 +++++++++++ .../TerminalTabsTests.cs | 507 --------------- .../TitleBarTests.cs | 104 +++ .../ShellFlowTests.cs | 376 ++++++++++- .../VaultVisibilityTests.cs | 8 +- 30 files changed, 4433 insertions(+), 1772 deletions(-) create mode 100644 src/DodoSSH.Client.App/Views/SessionHeader.axaml create mode 100644 src/DodoSSH.Client.App/Views/SessionHeader.axaml.cs create mode 100644 src/DodoSSH.Client.App/Views/SessionSidebar.axaml create mode 100644 src/DodoSSH.Client.App/Views/SessionSidebar.axaml.cs create mode 100644 src/DodoSSH.Client.App/Views/SessionStatusBar.axaml create mode 100644 src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs create mode 100644 src/DodoSSH.Client.App/Views/SessionTabRow.axaml create mode 100644 src/DodoSSH.Client.App/Views/SessionTabRow.axaml.cs delete mode 100644 src/DodoSSH.Client.App/Views/TerminalTabs.axaml delete mode 100644 src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs create mode 100644 tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs create mode 100644 tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs delete mode 100644 tests/DodoSSH.Client.App.Layout.Tests/TerminalTabsTests.cs create mode 100644 tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml index e4089f3..f906eb0 100644 --- a/src/DodoSSH.Client.App/App.axaml +++ b/src/DodoSSH.Client.App/App.axaml @@ -305,6 +305,22 @@ + + + + - + - - - - - - + - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -77,209 +96,227 @@ - + - + - + - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - + ◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the + two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their + internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session + shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding + a host header and a status bar around the screen's own content, and a 300px sidebar. + Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area, + not the WebView", which is the occlusion question, and which mode the switcher is on is this + wrapper's own separate question. + + The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in + this application, and building one is out of this wave's scope; see the notes' own open + question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click + reuses the same "Browse files" plumbing a pin click already does, honestly opening (or + reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session + exists that does not. + --> + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - + + - - - + - + + - + - + - + + - + - + - It shares a word with the tab strip's first tab, which is a different level of the window: that - tab is "this application rather than SFTP or S3", and this is one of the nine screens under it. - --> - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/NavRail.axaml.cs b/src/DodoSSH.Client.App/Views/NavRail.axaml.cs index 607203f..38747f7 100644 --- a/src/DodoSSH.Client.App/Views/NavRail.axaml.cs +++ b/src/DodoSSH.Client.App/Views/NavRail.axaml.cs @@ -1,9 +1,85 @@ using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.App.Views; -/// The five destinations down the left edge of the unlocked window. +/// The window's destinations, down the left edge — the switcher, the six rail rows and the user chip. internal sealed partial class NavRail : UserControl { public NavRail() => InitializeComponent(); + + /// Opens the user popover. + /// + /// A handler rather than relying on the click that opening a Flyout answers to on its own: a + /// named method is a thing a test can call directly, where an implicit open is not. + /// + private void OnUserChipPressed(object? sender, RoutedEventArgs e) + { + if (sender is Control chip) + { + FlyoutBase.ShowAttachedFlyout(chip); + } + } + + /// Hides the popover, whatever handler is about to navigate. + private void ClosePopover() + { + if (this.FindControl + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SessionSidebar.axaml.cs b/src/DodoSSH.Client.App/Views/SessionSidebar.axaml.cs new file mode 100644 index 0000000..44dd362 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/SessionSidebar.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Controls; + +namespace DodoSSH.Client.App.Views; + +/// +/// The v5b session sidebar: QUICK ACCESS on both surfaces, SNIPS on the terminal's own. See the remark at +/// the top of SessionSidebar.axaml. +/// +internal sealed partial class SessionSidebar : UserControl +{ + /// Whether the SNIPS section draws below QUICK ACCESS. + /// + /// Set from the usage site rather than inferred from a surface flag on the shell, for the same reason + /// is: which sections a particular instance of this control shows + /// is a fact about where it was placed in MainWindow.axaml, not one this control can read off its + /// own DataContext. + /// + internal static readonly StyledProperty ShowsSnipsProperty = + AvaloniaProperty.Register(nameof(ShowsSnips)); + + public SessionSidebar() => InitializeComponent(); + + internal bool ShowsSnips + { + get => GetValue(ShowsSnipsProperty); + set => SetValue(ShowsSnipsProperty, value); + } +} diff --git a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml new file mode 100644 index 0000000..3a967cc --- /dev/null +++ b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs new file mode 100644 index 0000000..d37bb0e --- /dev/null +++ b/src/DodoSSH.Client.App/Views/SessionStatusBar.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia; +using Avalonia.Controls; + +namespace DodoSSH.Client.App.Views; + +/// +/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true +/// — UTF-8. See the remark at the top of SessionStatusBar.axaml. +/// +internal sealed partial class SessionStatusBar : UserControl +{ + /// + /// Whether "UTF-8" is drawn on the right. + /// + /// + /// Set true only by the terminal surface's own usage in MainWindow.axaml. It is a fact about this + /// client's renderer and its write path — see the remark on SessionStatusBar.axaml — and has + /// nothing to do with an SFTP session, which moves bytes rather than decoded text. + /// + internal static readonly StyledProperty ShowsEncodingProperty = + AvaloniaProperty.Register(nameof(ShowsEncoding)); + + public SessionStatusBar() => InitializeComponent(); + + internal bool ShowsEncoding + { + get => GetValue(ShowsEncodingProperty); + set => SetValue(ShowsEncodingProperty, value); + } +} diff --git a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml new file mode 100644 index 0000000..27268e1 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml.cs b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml.cs new file mode 100644 index 0000000..1d653e0 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml.cs @@ -0,0 +1,63 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using DodoSSH.Client.Shell.ViewModels; + +namespace DodoSSH.Client.App.Views; + +/// +/// The v5b in-screen tab row: one pill per open terminal, and the "+" that opens another — parameterised by +/// so the terminal surface and the SFTP surface can each wire a click to a different +/// meaning over the same list. See the remark at the top of SessionTabRow.axaml. +/// +internal sealed partial class SessionTabRow : UserControl +{ + /// What a left click on a tab runs, with the tab itself as the command parameter. + /// + /// A plain rather than a bound property read off the shell, because which command + /// that is is the one thing this control cannot decide for itself — the terminal surface wants + /// SelectTabCommand and the SFTP surface wants SelectFilesHostCommand, and only the caller + /// in MainWindow.axaml knows which screen this instance is on. + /// + internal static readonly StyledProperty TabCommandProperty = + AvaloniaProperty.Register(nameof(TabCommand)); + + public SessionTabRow() => InitializeComponent(); + + internal ICommand? TabCommand + { + get => GetValue(TabCommandProperty); + set => SetValue(TabCommandProperty, value); + } + + /// + /// Closes a tab on a middle click. See the identical remark on the strip this control replaced, + /// TerminalTabs.axaml.cs, for why this is PointerUpdateKind rather than + /// IsMiddleButtonPressed, why it fires on press rather than release, and why it is wired on the + /// tab's own template root rather than on the row. + /// + /// + /// Not parameterised like : closing a tab ends its shell regardless of which + /// screen the middle click landed on, so both rows want the same answer — CloseTabCommand, read + /// directly off this control's own , which is the shell on both. + /// + private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Visual { DataContext: TerminalTabViewModel tab } + || DataContext is not MainWindowViewModel shell) + { + return; + } + + if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind + is not PointerUpdateKind.MiddleButtonPressed) + { + return; + } + + e.Handled = true; + + shell.CloseTabCommand.Execute(tab); + } +} diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml deleted file mode 100644 index e23bcf1..0000000 --- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml +++ /dev/null @@ -1,314 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs deleted file mode 100644 index 633fa9c..0000000 --- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Input; -using Avalonia.Interactivity; -using DodoSSH.Client.Shell.ViewModels; - -namespace DodoSSH.Client.App.Views; - -/// The tab strip, above every screen. -internal sealed partial class TerminalTabs : UserControl -{ - public TerminalTabs() => InitializeComponent(); - - /// - /// Closes a tab on a middle click. - /// - /// - /// - /// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself". - /// A middle press on the background, on the sentence, or on the button that opens a connection reaches - /// no handler at all, because there is none there to reach. Nothing has to test what was clicked. - /// - /// - /// PointerUpdateKind, not IsMiddleButtonPressed. The latter reports button - /// state: it is equally true for a left press made while the middle button happens to be held, - /// and for every press during a middle drag. The question here is which button caused this press, and - /// that is the one thing only PointerUpdateKind answers. - /// - /// - /// On press rather than on release, which is what every browser and every terminal does. Matching a - /// release to its press would need capture tracking, to buy the ability to change your mind about a - /// middle click — a gesture nobody makes by accident and nobody aborts. - /// - /// - private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (sender is not Visual { DataContext: TerminalTabViewModel tab } - || DataContext is not MainWindowViewModel shell) - { - return; - } - - if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind - is not PointerUpdateKind.MiddleButtonPressed) - { - return; - } - - // Handled, so the strip's ScrollViewer does not also take this as the start of a pan. - e.Handled = true; - - // Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous — - // it waits for the workspace to tear the session down — and an event handler has nowhere to await - // it. Its failures are the workspace's to report, not this strip's. - shell.CloseTabCommand.Execute(tab); - } - - /// - /// Opens the vault menu, on the Vaults tab. - /// - /// - /// - /// The tab is selected before the menu opens, and that order is the whole reason this is a handler - /// rather than Button.Flyout. Selecting it puts the shell on a page, which collapses the - /// renderer — so the popup never has to drop over the WebView's native child window, and the question - /// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the - /// caret in the markup, and docs/platform-flags.md for what treating such a question as settled - /// has already cost this project. - /// - /// - /// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at - /// Vaults is the same gesture as pressing the tab, with a menu on the end. - /// - /// - private void OnVaultMenuPressed(object? sender, RoutedEventArgs e) - { - if (DataContext is not MainWindowViewModel shell || sender is not Control caret) - { - return; - } - - shell.ShowVaultsCommand.Execute(null); - - FlyoutBase.ShowAttachedFlyout(caret); - } - - /// Leaves for the teams screen with the new-vault form open, shutting the menu behind it. - /// - /// The menu is closed first, because the command navigates and a flyout left open would be hanging over - /// a screen it has nothing to do with. A Flyout does not close when something inside it is - /// pressed — which is what the switches above it want, and not what this wants. - /// - private void OnNewVaultPressed(object? sender, RoutedEventArgs e) - { - if (DataContext is not MainWindowViewModel shell) - { - return; - } - - if (this.FindControl diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml index 08b531f..0fde69f 100644 --- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml +++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml @@ -35,40 +35,71 @@ + - + + + + + + + + + + + + + + @@ -99,14 +164,34 @@ --> - + + - - + + + + + @@ -116,14 +201,20 @@ - + @@ -150,25 +241,37 @@ - - - - + + + + + - + - - + + - - + Text="{Binding Name}" FontSize="13.5" + Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" /> + + @@ -219,26 +322,41 @@ - + + - - - - - + + @@ -246,27 +364,22 @@ - - - - - + + + + + + + +