Merge pull request 'Give the terminal back the width and the keyboard the session shell took' (#7) from claude/quick-access-terminal-fixes-565d4a into main
ci / build and test (push) Failing after 2m20s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m36s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-10 14:52:22 +00:00
22 changed files with 865 additions and 216 deletions
+21
View File
@@ -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 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, 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. `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.
+58 -2
View File
@@ -475,11 +475,34 @@
<Setter Property="Background" Value="{StaticResource Track}" /> <Setter Property="Background" Value="{StaticResource Track}" />
</Style> </Style>
<!--
The user popover itself: the panel the rail's chip opens, in this window's own idiom rather than the
theme's. The shared MenuFlyoutPresenter rule further down this file gives every popup Chrome and a
4-pixel radius, which is right for a context menu and wrong for this one — the design draws the account
menu as a rounded card, the same radius-12 shape as every other floating surface here. Reached with
FlyoutPresenterClasses from NavRail.axaml rather than by widening that rule, so a right-click menu two
screens away does not quietly become a card as well.
-->
<Style Selector="FlyoutPresenter.poppanel">
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="8" />
</Style>
<!-- <!--
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
draws them as one list and a row that looked different from its neighbours would read as a separator draws them as one list and a row that looked different from its neighbours would read as a separator
that is not one. that is not one.
◆ FLAT MEANS SAYING SO, which this rule did not. It set a corner radius and a padding and left the
Background alone, so every row wore the Fluent theme's own button fill and its border: six raised pills
stacked in a menu, where the design draws six lines of text that light up under the pointer. The hover
rule below was already right and was simply invisible against a fill that was there all along. Set on
the ContentPresenter as well as on the Button, the same as Button.flat does and for the same reason —
the theme's template binds its own brush there, and a Background set only on the control loses to it.
--> -->
<Style Selector="Button.poprow"> <Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
@@ -488,14 +511,20 @@
<Setter Property="Padding" Value="11,4" /> <Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" /> <Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" /> <Setter Property="MinHeight" Value="20" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style> </Style>
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" /> <Setter Property="CornerRadius" Value="8" />
</Style> </Style>
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" /> <Setter Property="Background" Value="{StaticResource Track}" />
</Style> </Style>
<Style Selector="Button.poprow:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!-- <!--
The check square beside a shown vault in the popover — magenta rather than the accent, because the The check square beside a shown vault in the popover — magenta rather than the accent, because the
@@ -594,10 +623,13 @@
</Style> </Style>
<!-- <!--
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────── ── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c> A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>. no filled hover, only the border turning to the accent.
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
--> -->
<Style Selector="Button.headerghost"> <Style Selector="Button.headerghost">
<Setter Property="Height" Value="32" /> <Setter Property="Height" Value="32" />
@@ -643,6 +675,30 @@
<Setter Property="Background" Value="{StaticResource Track}" /> <Setter Property="Background" Value="{StaticResource Track}" />
</Style> </Style>
<!--
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
at the head of the column when it is open and at the head of the rail when it is not. Square rather
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
is holding, and matching the rows' shape would have offered it as one.
-->
<Style Selector="Button.sidebargrip">
<Setter Property="Width" Value="26" />
<Setter Property="Height" Value="26" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!-- <!--
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections. quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
@@ -690,7 +690,7 @@
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2" <Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
IsVisible="{Binding HasPins}"> IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3"> <StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xE946;" FontFamily="{StaticResource IconFont}" <TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" /> FontSize="11" Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5" <TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" /> Foreground="{StaticResource TextFaint}" />
+21 -15
View File
@@ -148,13 +148,17 @@
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" <Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True"> CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto"> <!--
<views:SessionHeader Grid.Row="0" v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
OpenLabel="Open terminal" screen is gone; the address and the "Open terminal" button it carried are in the
OpenCommand="{Binding OpenTerminalForFilesHostCommand}" sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
EmptyText="{Binding Transfers.Status}" /> binding, the Transfers.Status line it printed while no host was open, is not moved
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" /> either: TransfersScreen draws that same string itself, both in its own empty state and
<views:SessionStatusBar Grid.Row="2" /> beside the remote pane's DISCONNECT once something is open.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
<views:SessionStatusBar Grid.Row="1" />
</Grid> </Grid>
<!-- <!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
@@ -268,14 +272,16 @@
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" <Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True"> CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto"> <!--
<views:SessionHeader Grid.Row="0" v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
OpenLabel="Open SFTP" reason — the host header is gone and the terminal has its 60 pixels. The empty state it
OpenCommand="{Binding SelectFilesHostCommand}" used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
OpenCommandParameter="{Binding SelectedTab}" this Grid is only drawn on the terminal surface, and the surface with no tab open already
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" /> answers for itself in the tab row's own "+" and in the connecting card below.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<Panel Grid.Row="1" Background="{StaticResource Pane}"> <Panel Grid.Row="0" Background="{StaticResource Pane}">
<!-- <!--
The other thing that can be in the terminal's rectangle: a tab whose session does not The other thing that can be in the terminal's rectangle: a tab whose session does not
@@ -300,7 +306,7 @@
</Panel> </Panel>
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" /> <views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
</Grid> </Grid>
<!-- <!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn: Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
@@ -268,6 +268,7 @@ internal sealed partial class MainWindow : Window
if (shell is { } previous) if (shell is { } previous)
{ {
previous.TerminalSessionOpened -= OnTerminalSessionOpened; previous.TerminalSessionOpened -= OnTerminalSessionOpened;
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
previous.PropertyChanged -= OnShellPropertyChanged; previous.PropertyChanged -= OnShellPropertyChanged;
} }
@@ -286,6 +287,7 @@ internal sealed partial class MainWindow : Window
wasUnlocked = viewModel.IsUnlocked; wasUnlocked = viewModel.IsUnlocked;
viewModel.TerminalSessionOpened += OnTerminalSessionOpened; viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
viewModel.PropertyChanged += OnShellPropertyChanged; viewModel.PropertyChanged += OnShellPropertyChanged;
} }
@@ -298,6 +300,15 @@ internal sealed partial class MainWindow : Window
/// </remarks> /// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut(); private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// The same call for a session that was already open and has just been typed into from the sidebar —
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
/// screen instead.
/// </remarks>
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks> /// <remarks>
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes, /// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the /// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
+29 -11
View File
@@ -131,13 +131,19 @@
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
than picked afresh so the one concept reads as one glyph everywhere it appears. than picked afresh so the one concept reads as one glyph everywhere it appears.
◆ U+F10D, not U+E946, which both sites drew until this pass and which no glyph in the embedded
face answers to: the cmap of Assets/Fonts/MaterialIcons (Material Icons 1.017, 2019) skips E944
and E946, so this row and the hosts screen's own pin badge were both drawing a tofu box. F10D is
where push_pin lives in that vintage, verified against the file rather than against a codepoints
table for a later release of the font.
--> -->
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}" <Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}" CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one"> ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE946;" /> <TextBlock Classes="navicon" Text="&#xF10D;" />
<TextBlock Classes="navlabel" Text="Pins" /> <TextBlock Classes="navlabel" Text="Pins" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -191,17 +197,22 @@
</Grid> </Grid>
<FlyoutBase.AttachedFlyout> <FlyoutBase.AttachedFlyout>
<Flyout Placement="TopEdgeAlignedLeft"> <!--
<StackPanel Width="227" Spacing="8"> FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
the shared popup rule was not simply widened to cover it.
-->
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
<StackPanel Width="227" Spacing="4">
<!-- <!--
The real email, when the server sent one — verified against MainWindowViewModel.Email rather The real email, when the server sent one — verified against MainWindowViewModel.Email rather
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
is no organisation concept behind a vault, only the vault itself, which the rows below name. is no organisation concept behind a vault, only the vault itself, which the rows below name.
--> -->
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" <TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
Foreground="{StaticResource TextGhost}" Foreground="{StaticResource TextGhost}"
Text="{Binding Email}" Text="{Binding Email}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- <!--
@@ -246,7 +257,7 @@
</Grid> </Grid>
</Button> </Button>
<Border Height="1" Background="{StaticResource BorderMid}" /> <Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!-- <!--
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode — v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
@@ -260,16 +271,23 @@
<Button Classes="poprow" Click="OnPopoverSettingsPressed"> <Button Classes="poprow" Click="OnPopoverSettingsPressed">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource Text}" /> Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" /> <TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel> </StackPanel>
</Button> </Button>
<!--
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
label was TextGhost where the other two rows' was Text, so a menu of five equally live
destinations drew two of them in the colour this window uses for something switched off. The
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
— but "quieter than the word beside it" and "dimmed" are not the same statement.
-->
<Button Classes="poprow" Click="OnPopoverVaultsPressed"> <Button Classes="poprow" Click="OnPopoverVaultsPressed">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE84F;" FontSize="12" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE84F;" FontSize="12"
Foreground="{StaticResource TextGhost}" /> Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" /> <TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -277,11 +295,11 @@
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE429;" FontSize="12" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE429;" FontSize="12"
Foreground="{StaticResource TextGhost}" /> Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" /> <TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel> </StackPanel>
</Button> </Button>
<Border Height="1" Background="{StaticResource BorderMid}" /> <Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
<!-- <!--
The existing sign-out flow, with its own confirm card — see The existing sign-out flow, with its own confirm card — see
@@ -291,7 +309,7 @@
<Button Classes="poprow" Click="OnPopoverLogoutPressed"> <Button Classes="poprow" Click="OnPopoverLogoutPressed">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE9BA;" FontSize="12" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE9BA;" FontSize="12"
Foreground="{StaticResource Text}" /> Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" /> <TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -1,49 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionHeader"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
forward" button — none of those are facts this application has.
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
surface-aware property, so this control asks no question about which screen it is on. What differs
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
-->
<Border Height="60" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
<!--
The address, only while a session/host context is active — see SessionAddress's own remark for what
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
form to fill in.
-->
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
<Button Grid.Column="1" Classes="headerghost"
Content="{Binding #Root.OpenLabel}"
Command="{Binding #Root.OpenCommand}"
CommandParameter="{Binding #Root.OpenCommandParameter}" />
</Grid>
</Border>
</UserControl>
@@ -1,58 +0,0 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// 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.
/// </summary>
internal sealed partial class SessionHeader : UserControl
{
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
internal static readonly StyledProperty<string?> OpenLabelProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
/// <summary>What the cross-surface ghost button runs.</summary>
/// <remarks>
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
/// </remarks>
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
internal static readonly StyledProperty<string?> EmptyTextProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
public SessionHeader() => InitializeComponent();
internal string? OpenLabel
{
get => GetValue(OpenLabelProperty);
set => SetValue(OpenLabelProperty, value);
}
internal ICommand? OpenCommand
{
get => GetValue(OpenCommandProperty);
set => SetValue(OpenCommandProperty, value);
}
internal object? OpenCommandParameter
{
get => GetValue(OpenCommandParameterProperty);
set => SetValue(OpenCommandParameterProperty, value);
}
internal string? EmptyText
{
get => GetValue(EmptyTextProperty);
set => SetValue(EmptyTextProperty, value);
}
}
+124 -59
View File
@@ -19,70 +19,100 @@
Every row here is a command the shell already exposes for exactly this purpose — see Every row here is a command the shell already exposes for exactly this purpose — see
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand — MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
so this control carries no logic of its own beyond the list it draws and the click it forwards. so this control carries no logic of its own beyond the list it draws and the click it forwards.
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
by the shell now rather than handed in from the two usage sites; see
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
does and would be the same word twice beside it.
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
trace is one people report as lost. Both halves live in this control and swap on
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
--> -->
<Border Width="300" Background="{StaticResource Sidebar}" <Panel>
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" Margin="16,20">
<!-- ============ QUICK ACCESS ============ --> <!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
<Grid ColumnDefinitions="*,Auto" Margin="8,0"> <!--
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" /> Painted and bordered like the open column so the closing reads as the same surface narrowing rather
<TextBlock Grid.Column="1" Classes="mono" FontSize="10" than as one piece of furniture being swapped for another.
Foreground="{StaticResource TextGhost}" -->
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" /> <Border Width="34" Background="{StaticResource Sidebar}"
</Grid> BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
IsVisible="{Binding !IsSessionSidebarOpen}">
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
<TextBlock Text="&#xE5CB;" FontFamily="{StaticResource IconFont}" FontSize="18"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Border>
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}"> <!-- ============ THE COLUMN ============ -->
<ItemsControl.ItemTemplate> <Border Width="300" Background="{StaticResource Sidebar}"
<DataTemplate x:DataType="x:String"> BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
<!-- IsVisible="{Binding IsSessionSidebarOpen}">
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin <ScrollViewer VerticalScrollBarVisibility="Auto">
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up <StackPanel Spacing="6" Margin="16,20">
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}" <!-- ============ THE SESSION ============ -->
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS."> <!--
<StackPanel Orientation="Horizontal" Spacing="10"> The address, and the button that closes the column. Both on one row, and the address is the
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14" trimming one: a long account@host:port is exactly the string that would otherwise push the
VerticalAlignment="Center" /> close button off the edge of a panel whose whole point is that it can be got rid of.
<TextBlock Text="Pin folder" VerticalAlignment="Center" /> -->
</StackPanel> <Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
</Button> <TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<Button Grid.Column="1" Classes="flat sidebargrip"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
<TextBlock Text="&#xE5CC;" FontFamily="{StaticResource IconFont}" FontSize="18"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
<!-- ============ SNIPS (the terminal surface only) ============ --> <!--
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}"> The cross-surface button, stretched across the column rather than sized to its own caption: it
is the one action in this panel that is not a list row, and a 90-pixel button floating at the
left of a 300-pixel column would read as unfinished.
-->
<Button Classes="headerghost" HorizontalAlignment="Stretch" Margin="0,4,0,10"
Content="{Binding SessionCrossSurfaceLabel}"
Command="{Binding OpenOtherSurfaceCommand}" />
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" /> <!-- ============ QUICK ACCESS ============ -->
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}"> <ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel"> <DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow" <Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}" Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}" CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}"> ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" <TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
FontSize="11" Foreground="{StaticResource AccentText}" Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
VerticalAlignment="Center" /> <TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" /> TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</Button> </Button>
@@ -90,19 +120,54 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}" <Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor."> ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14" <TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" /> <TextBlock Text="Pin folder" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> <!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
</StackPanel> <TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
</ScrollViewer>
</Border> <ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="11" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Panel>
</UserControl> </UserControl>
@@ -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 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. 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 A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window, 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 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 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 own code-behind rather than shared through a base class — four short handlers, and the day one of the two
+10 -2
View File
@@ -79,8 +79,16 @@
--> -->
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0" <TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" /> Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. --> <!--
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5" CTRL K, not the design's ⌘K — see the remark at the top of this file.
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
the longer label actually needs.
-->
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
Background="{StaticResource KbdChip}" Background="{StaticResource KbdChip}"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium" <TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
@@ -382,8 +382,9 @@
<!-- <!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is. ◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress, prints the very same address beside this screen — in the sidebar's own session block since v5c-4
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
+23 -1
View File
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
/// <param name="AutomaticUpdateChecks"> /// <param name="AutomaticUpdateChecks">
/// Whether this machine looks for a newer build on its own. See the remarks on the property. /// Whether this machine looks for a newer build on its own. See the remarks on the property.
/// </param> /// </param>
/// <param name="SessionSidebarOpen">
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
/// </param>
public sealed record ClientSettings( public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize, 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. 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. 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.
*/
/// <summary>Brings a value inside the range this type will store.</summary> /// <summary>Brings a value inside the range this type will store.</summary>
public static int ClampTerminalFontSize(int pixels) => public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize); Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
@@ -550,16 +550,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
updateScreen = CreateUpdateScreen(updates); updateScreen = CreateUpdateScreen(updates);
// Read straight away rather than at first use, so the value is right before anything can read it — ApplyStoredPreferences();
// 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);
_ = TellRendererTheFontSizeAsync(); _ = TellRendererTheFontSizeAsync();
StartSessionShellTracking(); StartSessionShellTracking();
} }
/// <summary>
/// Takes this machine's own preferences off disk, before anything can read them.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private void ApplyStoredPreferences()
{
var stored = settings.Read();
TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize);
IsSessionSidebarOpen = stored.SessionSidebarOpen;
}
/// <summary> /// <summary>
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline. /// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
/// </summary> /// </summary>
@@ -1003,6 +1017,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
internal event EventHandler? TerminalSessionOpened; internal event EventHandler? TerminalSessionOpened;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Separate from <see cref="TerminalSessionOpened"/> 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 <c>term.focus()</c> at the end of
/// <c>terminal.js</c>'s paste handler, which only ever reaches <c>document.activeElement</c> — 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 <c>MainWindow.FocusTerminalWhenLaidOut</c>.
/// </remarks>
internal event EventHandler? TerminalFocusRequested;
internal bool IsStarting => State == ShellState.Starting; internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer; internal bool IsNeedingServer => State == ShellState.NeedsServer;
@@ -3824,6 +3852,72 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
(IsTerminalSurface && SelectedTab is not null) (IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected); || (IsTransfersShowing && Transfers.IsConnected);
/// <summary>
/// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back.
/// </summary>
/// <remarks>
/// A separate question from <see cref="ShowsQuickAccessSidebar"/>, 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 <c>SessionSidebar.axaml</c> — 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 <see cref="ToggleSessionSidebar"/> and <c>ClientSettings.SessionSidebarOpen</c>.
/// </remarks>
[ObservableProperty]
private bool isSessionSidebarOpen = true;
/// <summary>Opens the session sidebar, or closes it to its rail.</summary>
/// <remarks>
/// 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.
/// </remarks>
[RelayCommand]
private void ToggleSessionSidebar()
{
IsSessionSidebarOpen = !IsSessionSidebarOpen;
_ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen });
}
/// <summary>
/// The label on the sidebar's cross-surface row: where the other half of this host is.
/// </summary>
/// <remarks>
/// 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 <c>SessionSidebar.axaml</c>. 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
/// <see cref="SessionAddress"/> 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.
/// </remarks>
internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal";
/// <summary>Goes to the other half of the session the sidebar is about.</summary>
/// <remarks>
/// The two directions were two commands bound from two usages of the header control, and they still are
/// two methods — <see cref="SelectFilesHostAsync"/> takes a tab and opens an SFTP connection to its host;
/// <see cref="OpenTerminalForFilesHostAsync"/> 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 <see cref="SessionCrossSurfaceLabel"/>, which has to agree with it.
/// </remarks>
[RelayCommand]
private async Task OpenOtherSurfaceAsync()
{
if (IsTerminalSurface)
{
if (SelectedTab is { } tab)
{
await SelectFilesHostAsync(tab).ConfigureAwait(true);
}
return;
}
await OpenTerminalForFilesHostAsync().ConfigureAwait(true);
}
/// <summary> /// <summary>
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins. /// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
/// </summary> /// </summary>
@@ -3896,6 +3990,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (screen.CanInsert) if (screen.CanInsert)
{ {
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true); 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; return;
} }
@@ -3989,14 +4089,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
/// <summary> /// <summary>
/// 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. /// neither surface has one.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// One property reading whichever surface is showing, rather than one binding per surface reading its own /// One property reading whichever surface is showing, rather than one binding per surface reading its own
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on /// source directly — <c>SessionSidebar.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view /// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>, /// 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 <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
/// which is already the account and endpoint actually dialled — nothing here re-derives it. /// which is already the account and endpoint actually dialled — nothing here re-derives it.
/// </remarks> /// </remarks>
internal string? SessionAddress => Surface switch internal string? SessionAddress => Surface switch
@@ -4141,6 +4242,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(SessionIdentityLabel)); OnPropertyChanged(nameof(SessionIdentityLabel));
OnPropertyChanged(nameof(SessionIdentityText)); OnPropertyChanged(nameof(SessionIdentityText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar)); 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));
} }
/// <remarks> /// <remarks>
@@ -78,6 +78,50 @@ body {
height: 100%; 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 { #status {
position: absolute; position: absolute;
left: 0; left: 0;
+25 -1
View File
@@ -417,8 +417,23 @@ function handleFrame(buffer) {
is something only this page sees and a shell that receives a multi-line command inside those 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 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. 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 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'); 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; break;
} }
@@ -122,8 +122,14 @@ internal static class LayoutHarness
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary> /// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
internal const double SessionSidebarWidth = 300; internal const double SessionSidebarWidth = 300;
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary> /*
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.
*/
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary> /// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
internal const double SessionStatusBarHeight = 37; internal const double SessionStatusBarHeight = 37;
@@ -146,12 +152,12 @@ internal static class LayoutHarness
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on /// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the /// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both /// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and /// the top and the bottom of that border, less <see cref="SessionStatusBarHeight"/> for the one fixed
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between. /// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
/// </remarks> /// </remarks>
internal static double SessionScreenHeight => internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness) ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- SessionHeaderHeight - SessionStatusBarHeight; - SessionStatusBarHeight;
/// <summary> /// <summary>
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS /// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
@@ -1,8 +1,10 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Headless; using Avalonia.Headless;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using DodoSSH.Client.App.Views; using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
@@ -272,6 +274,49 @@ public sealed class NavRailTests : IAsyncLifetime
}); });
} }
/// <summary>
/// Every row in the popover rests flat, and the pointer is what fills one.
/// </summary>
/// <remarks>
/// <c>Button.poprow</c> 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.
/// </remarks>
[Fact]
public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
{
await OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
var row = PopoverRow(window, "Settings");
var presenter = row.GetVisualDescendants()
.OfType<ContentPresenter>()
.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 ---- // ---- Helpers ----
private Task OnTheRailAsync(Action<NavRail, Window> body) => private Task OnTheRailAsync(Action<NavRail, Window> body) =>
@@ -1274,7 +1274,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the /// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT /// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> 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 /// 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 <c>SessionHeader</c> 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 <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the /// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width. /// thing squeezing DISCONNECT off the edge at this width.
/// </para> /// </para>
@@ -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;
/// <summary>
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
/// </summary>
/// <remarks>
/// 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 <c>MainWindow.axaml</c> copes with it by asking this
/// control how wide it is rather than by knowing.
/// </remarks>
public sealed class SessionSidebarTests : IAsyncLifetime
{
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
private const double OpenWidth = 300;
/// <inheritdoc cref="OpenWidth" />
private const double RailWidth = 34;
/// <remarks>
/// 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.
/// </remarks>
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;
/// <inheritdoc />
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<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
new ClientPaths(directory),
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
/// <remarks>
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
/// <see cref="LayoutHarness.Unreachable"/> 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.
/// </remarks>
[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();
});
}
/// <remarks>
/// 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.
/// </remarks>
[Fact]
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
{
await OnTheSidebarAsync((sidebar, _) =>
{
var button = sidebar.GetVisualDescendants()
.OfType<Button>()
.First(candidate => candidate.Classes.Contains("headerghost"));
button.Content.ShouldBe("Open SFTP");
});
}
/// <remarks>
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
/// on it is the difference between a panel somebody closed and a panel somebody lost.
/// </remarks>
[Fact]
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
{
await OnTheSidebarAsync((sidebar, window) =>
{
shell.IsSessionSidebarOpen = false;
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
var grip = sidebar.GetVisualDescendants()
.OfType<Button>()
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the grip is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
});
}
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
shell.SelectTabCommand.Execute(shell.Tabs[0]);
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
body(sidebar, window);
}
finally
{
window.Close();
}
},
Token);
}
@@ -101,4 +101,59 @@ public sealed class TitleBarTests : IAsyncLifetime
}, },
Token); Token);
} }
/// <remarks>
/// The chip that says which chord opens the pill beside it. The design's own is 34 pixels wide because
/// the design's own label is ⌘K — two glyphs — and this build substitutes "CTRL K", which at 10.5 mono is
/// wider than that. It shipped clipped: the chip drew "CTRL" and half of the K, which reads as a rendering
/// glitch rather than as a keyboard shortcut.
///
/// Asserted as "the chip is at least as wide as its own text", not against a number. A pixel count would
/// have to be re-derived by hand every time the font, the size or the wording moved, and the thing that
/// actually matters is the relationship between the two.
/// </remarks>
[Fact]
public async Task TheKeyboardChipIsWideEnoughForTheChordItNames()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
var label = bar.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => string.Equals(text.Text, "CTRL K", StringComparison.Ordinal));
var chip = label.GetVisualAncestors().OfType<Border>().First();
// Measured on a copy under an unbounded constraint, not read off the label in the tree.
// A TextBlock's own DesiredSize is already clipped to what it was given, so the laid-out
// one reports 34 inside a 34-pixel chip whether or not the text fits — which is exactly
// the state this test exists to fail on.
var natural = new TextBlock
{
Text = label.Text,
FontFamily = label.FontFamily,
FontSize = label.FontSize,
FontWeight = label.FontWeight,
};
natural.Measure(Size.Infinity);
natural.DesiredSize.Width.ShouldBeGreaterThan(0, "the chord is a real run of text");
chip.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
natural.DesiredSize.Width,
"a chip narrower than its own label draws part of the chord and cuts the rest");
}
finally
{
window.Close();
}
},
Token);
}
} }
@@ -6498,12 +6498,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeTrue();
} }
/// <remarks>
/// v5c-4 retired the session shell's own header row and moved its cross-surface button into the sidebar,
/// where one control is drawn on both surfaces — so the two directions above are reached through one
/// command and one label, resolved by the shell. This is that resolution: the same two outcomes the two
/// tests above assert, from the row a user actually clicks now.
/// </remarks>
[Fact]
public async Task TheSidebarsCrossSurfaceRow_NamesAndOpensWhicheverSurfaceIsNotShowing()
{
await ConnectedHostWithAPinAsync();
shell.IsTerminalSurface.ShouldBeTrue();
shell.SessionCrossSurfaceLabel.ShouldBe("Open SFTP");
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.SessionCrossSurfaceLabel.ShouldBe("Open terminal", "the row turns over with the surface");
var tabsBefore = shell.Tabs.Count;
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "the other direction dials a terminal at the browsed host");
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// Closing the sidebar is a preference about this machine, so it outlives the window — see
/// <c>ClientSettings.SessionSidebarOpen</c>. Asserted against the store rather than against a second
/// shell built over the same profile: what a fresh launch reads is exactly what is on disk, and building
/// another shell here would prove the constructor twice and the storage once.
/// </remarks>
[Fact]
public async Task ClosingTheSidebar_IsRememberedForTheNextLaunch()
{
await ConnectedHostWithAPinAsync();
shell.IsSessionSidebarOpen.ShouldBeTrue("open is the default, and nothing has closed it");
shell.ToggleSessionSidebarCommand.Execute(null);
shell.IsSessionSidebarOpen.ShouldBeFalse();
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeFalse();
shell.ToggleSessionSidebarCommand.Execute(null);
shell.IsSessionSidebarOpen.ShouldBeTrue();
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeTrue("and reopening is remembered too");
}
/// <remarks> /// <remarks>
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second /// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven /// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above /// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches /// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
/// that same mechanism rather than reimplementing it. /// that same mechanism rather than reimplementing it.
///
/// The focus request is asserted here rather than in a test of its own because it is part of what this
/// click does: the row that typed the command took the keyboard with it, and only the window can give it
/// back. See <c>MainWindowViewModel.TerminalFocusRequested</c>.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab() public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
@@ -6515,9 +6571,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
var row = snippets.Visible.ShouldHaveSingleItem(); var row = snippets.Visible.ShouldHaveSingleItem();
var focusRequests = 0;
shell.TerminalFocusRequested += (_, _) => focusRequests++;
await shell.InsertSnippetCommand.ExecuteAsync(row); await shell.InsertSnippetCommand.ExecuteAsync(row);
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads"); snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
focusRequests.ShouldBe(1, "the keyboard goes back to the terminal the command landed in");
}
/// <remarks>
/// The other half of the rule above: nothing was typed, so nothing asks for the keyboard. A snip clicked
/// with no terminal to put it in lands on the snippets screen instead — see
/// <c>MainWindowViewModel.InsertSnippetCommand</c> — and stealing focus into a collapsed WebView on the
/// way would leave that screen unable to be typed on.
/// </remarks>
[Fact]
public async Task InsertingASnippetWithNothingToInsertInto_DoesNotAskForTheTerminal()
{
await UnlockedAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
var row = snippets.Visible.ShouldHaveSingleItem();
var focusRequests = 0;
shell.TerminalFocusRequested += (_, _) => focusRequests++;
await shell.InsertSnippetCommand.ExecuteAsync(row);
shell.Screen.ShouldBe(ShellScreen.Snippets);
focusRequests.ShouldBe(0);
} }
[Fact] [Fact]