Public Access
Compare commits
2
Commits
6fc1e3a7c5
...
9f73893e14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 |
@@ -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.
|
||||||
|
|||||||
@@ -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="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" 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}" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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="" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<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="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" 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="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" 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="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" 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="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,21 +19,84 @@
|
|||||||
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.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
|
||||||
|
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
||||||
|
<!--
|
||||||
|
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
||||||
|
than as one piece of furniture being swapped for another.
|
||||||
|
-->
|
||||||
|
<Border Width="34" Background="{StaticResource Sidebar}"
|
||||||
|
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="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ THE COLUMN ============ -->
|
||||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
|
IsVisible="{Binding IsSessionSidebarOpen}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="6" Margin="16,20">
|
<StackPanel Spacing="6" Margin="16,20">
|
||||||
|
|
||||||
<!-- ============ QUICK ACCESS ============ -->
|
<!-- ============ THE SESSION ============ -->
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
<!--
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
The address, and the button that closes the column. Both on one row, and the address is the
|
||||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
||||||
Foreground="{StaticResource TextGhost}"
|
close button off the edge of a panel whose whole point is that it can be got rid of.
|
||||||
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
||||||
|
<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="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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}" />
|
||||||
|
|
||||||
|
<!-- ============ QUICK ACCESS ============ -->
|
||||||
|
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="x:String">
|
<DataTemplate x:DataType="x:String">
|
||||||
@@ -105,4 +168,6 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</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
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
if (text.includes('\n') || text.includes('\r')) {
|
||||||
session.term.paste(text);
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user