Public Access
Give the window its v5b chrome and each session surface its own shell
This commit is contained in:
+315
-113
@@ -305,6 +305,22 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The titlebar's search pill. A plain <c>Border</c> rather than a styled <c>Button</c> template, because
|
||||||
|
the pill sits inside a <c>Button.flat</c> that already owns the click — see <c>TitleBar.axaml</c> — and
|
||||||
|
what changes under the pointer is the pill's own border, not a fill behind it. Declared on the parent
|
||||||
|
button's <c>:pointerover</c> rather than the border's own, because a <c>Border</c> has no pointer state
|
||||||
|
of its own to key a selector on.
|
||||||
|
-->
|
||||||
|
<Style Selector="Border.searchpill">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SearchPill}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.search:pointerover Border.searchpill">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
|
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
|
||||||
cards. The headings went when the grid became cards and the group cards above it became the thing that
|
cards. The headings went when the grid became cards and the group cards above it became the thing that
|
||||||
@@ -331,178 +347,301 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
</Style>
|
</Style>
|
||||||
<!--
|
<!--
|
||||||
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
|
The nav rail. v5b redraws it against TitleBar.dc.html's sibling NavRail.dc.html rather than against
|
||||||
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
|
the v2 mock this replaces — see the comment block at the top of NavRail.axaml for what moved and why.
|
||||||
row carries, so the window has one vocabulary for selection rather than one per control.
|
The active destination is now a filled accent row rather than a wash with a bar down its edge; the
|
||||||
-->
|
idiom is otherwise the same one the switcher below and a selected list row already use.
|
||||||
<!--
|
|
||||||
A destination in the sidebar. v2 turns these from 54-pixel stacked labels into 32-pixel rows with a
|
TextGhost rather than TextFaint for the resting label: the mock computes rgb(124,127,152) for an
|
||||||
glyph, a word and a count, and the active one becomes a filled rounded row rather than a label with an
|
inactive row, which is TextGhost's own #7C7F98 exactly, not TextFaint's paler #9C9EB4.
|
||||||
accent bar down its left edge. The bar is gone because a filled row at this width already reads as
|
|
||||||
chosen, and the bar was carrying that on its own when there was no room for a fill.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.nav">
|
<Style Selector="Button.nav">
|
||||||
<Setter Property="Height" Value="32" />
|
<Setter Property="Height" Value="35" />
|
||||||
<Setter Property="Padding" Value="10,0" />
|
<Setter Property="Padding" Value="11,8" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
|
<Setter Property="LetterSpacing" Value="0.1" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
<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.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.nav.active">
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The three parts of a sidebar row. Separate classes rather than inline setters because nine rows draw
|
The two parts of a sidebar row: a 19-pixel Material glyph and its word, ten pixels apart. v2's third
|
||||||
them and the day one of the three moves is the day eight of them would not have.
|
column — a count read off the vault — left with the row it decorated; see the remark on
|
||||||
|
<c>Button.nav</c> above for where that number went and why it is not replaced here.
|
||||||
The glyph and the label inherit the row's own foreground, so they light with it; the count does not,
|
|
||||||
because a number that lit with its row would compete with the word beside it for the same emphasis.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="TextBlock.navicon">
|
<Style Selector="TextBlock.navicon">
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||||
<Setter Property="Width" Value="20" />
|
<Setter Property="FontSize" Value="19" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.navlabel">
|
<Style Selector="TextBlock.navlabel">
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.navcount">
|
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
A terminal tab, and since v2 a pill rather than a filing-cabinet tab: 30 tall inside a 42 strip, its
|
The segmented SSH / SFTP / S3 switcher at the rail's head. A track the width of the rail's own content
|
||||||
own rounded outline, and the active one filled instead of marked along an edge. The edge mark is gone
|
column, 2 pixels of padding holding three equal segments — so the three are Buttons with
|
||||||
because a pill has no edge to share with its neighbour — the gap between two of them is the divider
|
HorizontalAlignment="Stretch" inside a Grid of three equal columns, the same "buttons carry no state"
|
||||||
the old top-and-right border was standing in for.
|
reasoning <c>NavRail.axaml</c>'s own remarks give for every row beneath them.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.tab">
|
<Style Selector="Border.navtrack">
|
||||||
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
|
<Setter Property="Height" Value="31" />
|
||||||
<Setter Property="Padding" Value="12,0,8,0" />
|
|
||||||
<Setter Property="Height" Value="30" />
|
|
||||||
<Setter Property="Margin" Value="0,0,6,0" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="Padding" Value="2" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.navseg">
|
||||||
<!--
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
◆ ORDER IS THE BEHAVIOUR HERE. The base pill comes first, then its hover, then the exceptions — and
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
that ordering is the fix for a bug this arrangement had when v2 first drew it.
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Height" Value="27" />
|
||||||
A pill paints its own Background, which the flat tab it replaced did not. That one detail moves where
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
the hover has to live: `Button.flat:pointerover` is declared far above and used to supply it, and the
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
moment `.tab`'s template rule set a Background of its own, the later declaration won and every tab in
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
the strip lost its pointer feedback silently. The `+` lost more than that — it sits below as an
|
<Setter Property="LetterSpacing" Value="0.1" />
|
||||||
exception, so a `.tab` rule declared after it was overriding the very thing that made it an exception,
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
and it drew as a filled outlined pill contradicting the comment above it.
|
|
||||||
|
|
||||||
Avalonia has no specificity; the later declaration wins. The same trap is recorded further down this
|
|
||||||
file for Border.rowmark. Anything added below must be an exception to what is above it, never a
|
|
||||||
restatement of the base.
|
|
||||||
-->
|
|
||||||
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
|
||||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.tab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.navseg /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.navseg.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
|
The rail's foot: the user chip that opens the popover. Flat until the pointer arrives, the same idiom
|
||||||
close box, so it takes its padding back on the right: the base rule is short there to leave room for
|
every other row in this rail follows, at its own height and radius because it is a chip rather than a
|
||||||
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
|
destination — see <c>NavRail.axaml</c> for what it opens and why a <c>Flyout</c> is safe here.
|
||||||
off-centre beside one that has a reason for it.
|
|
||||||
|
|
||||||
Nothing else differs, deliberately. These are tabs and have to read as tabs — the whole point of the
|
|
||||||
strip is that "where the window is" is one row of one kind of control.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.tab.fixed">
|
<Style Selector="Button.navuser">
|
||||||
<Setter Property="Padding" Value="12,0" />
|
<Setter Property="Height" Value="34" />
|
||||||
|
<Setter Property="Padding" Value="13,7" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.navuser:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
|
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
|
||||||
|
draws them as one list and a row that looked different from its neighbours would read as a separator
|
||||||
|
that is not one.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.poprow">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="11,4" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="MinHeight" Value="20" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.poprow:pointerover /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
|
||||||
|
accent already means "press this" everywhere else in the window and a vault switch is a fact, not an
|
||||||
|
action waiting to be taken. See Palette.axaml's remark on Magenta.
|
||||||
|
-->
|
||||||
|
<Style Selector="Border.vaultcheck">
|
||||||
|
<Setter Property="Width" Value="12" />
|
||||||
|
<Setter Property="Height" Value="12" />
|
||||||
|
<Setter Property="CornerRadius" Value="3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Magenta}" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b: SESSION TAB ROW ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
The tab strip's replacement, per <c>Terminal.dc.html</c>/<c>SFTP.dc.html</c>: rounded only at the top,
|
||||||
|
fused visually to the bordered container it sits above rather than a free-floating pill — see
|
||||||
|
<c>SessionTabRow.axaml</c>. It went from a 42-pixel strip spanning the window to a 38-pixel row inside
|
||||||
|
each of the two screens that carry one, which is why the layout budget in <c>LayoutHarness</c> no
|
||||||
|
longer subtracts a tab strip's height from every screen: only these two now pay it, out of their own
|
||||||
|
26-pixel padded column rather than out of the window's own chrome.
|
||||||
|
|
||||||
|
◆ ORDER IS THE BEHAVIOUR HERE, as it was for the pill this replaces. The base tab comes first, then its
|
||||||
|
hover, then <c>.active</c> — each later rule is an exception to what came before it, because Avalonia
|
||||||
|
has no specificity and settles two rules matching one element by declaration order alone.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.sesstab">
|
||||||
|
<Setter Property="Padding" Value="16,10,12,10" />
|
||||||
|
<Setter Property="Height" Value="38" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Bottom" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="9,9,0,0" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="13.5" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sesstab /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="9,9,0,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sesstab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The tab whose pane the container below is showing: DeepChrome body — the same value the container's
|
||||||
|
own host header and status bar are painted in, which is what fuses the two into one shape rather than
|
||||||
|
a tab floating above a separate box — and a 2px top border in one of two colours. Which colour is set
|
||||||
|
by the row's own <c>Classes="sftp"</c>, from the usage site in <c>MainWindow.axaml</c>: unmarked is the
|
||||||
|
terminal row's <c>TerminalTabAccent</c>, and <c>.sftp</c> is the SFTP row's <c>Magenta</c> — see the
|
||||||
|
remark on both keys in <c>Palette.axaml</c>. Marked on <c>TerminalTabViewModel.IsSelected</c> rather
|
||||||
|
than <c>IsShowing</c>, unlike the strip this replaces: the SFTP row's own active tab is not "the pane
|
||||||
|
the terminal surface is showing" at all, so the narrower flag would leave it permanently dark. See
|
||||||
|
<c>MainWindowViewModel.SelectFilesHostCommand</c>.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.sesstab.active">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DeepChrome}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector=".sftp Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Magenta}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The button that opens a connection, reusing the strip's own shape rather than a new one: no active
|
||||||
state, because it is never the thing showing, and no outline, because it is not one of the things
|
state, because it is never the thing showing, and no outline, because it is not one of the things
|
||||||
being chosen between. Both of those are cleared rather than merely omitted — the base rule above sets
|
being chosen between.
|
||||||
a fill and a border, so an exception has to say so.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.tab.plus">
|
<Style Selector="Button.sesstab.plus">
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="Width" Value="28" />
|
<Setter Property="Width" Value="28" />
|
||||||
<Setter Property="Height" Value="28" />
|
<Setter Property="Height" Value="28" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.sesstab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<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.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.sesstab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Button.tab.active /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
|
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
|
||||||
because they do two things, drawn as one pill because they are one destination — so the pair meets in
|
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||||
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
|
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||||
are.
|
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||||
|
|
||||||
Both the Button and its ContentPresenter carry a CornerRadius above, so both have to be squared here:
|
|
||||||
setting only one leaves a rounded outline inside a square hit area, which shows as a hairline of the
|
|
||||||
strip's background cutting through the join.
|
|
||||||
|
|
||||||
After Button.tab.fixed rather than beside it, because .caret takes that rule's padding back to zero
|
|
||||||
and Avalonia has no specificity — the later declaration is the one that wins. See the ordering note
|
|
||||||
above.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.tab.split">
|
<Style Selector="Button.headerghost">
|
||||||
<Setter Property="Margin" Value="0" />
|
<Setter Property="Height" Value="32" />
|
||||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="16,0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="FontSize" Value="12.5" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.headerghost /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="1,1,0,1" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.tab.caret">
|
<Style Selector="Button.headerghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.tab.caret /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
<!--
|
||||||
|
── v5b: THE SESSION SIDEBAR'S ROWS ──────────────────────────────────────────────────────────────────
|
||||||
|
QUICK ACCESS's pins and, on the terminal surface, SNIPS — both h33, radius 9, a glyph and a mono
|
||||||
|
label, flat until the pointer finds them. See <c>SessionSidebar.axaml</c>.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.sidebarrow">
|
||||||
|
<Setter Property="Height" Value="33" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sidebarrow /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sidebarrow: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
|
||||||
|
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.sidebaradd">
|
||||||
|
<Setter Property="Height" Value="30" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sidebaradd /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.sidebaradd:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -661,6 +800,34 @@
|
|||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b: THE SFTP PANES' OWN FILE ROWS ───────────────────────────────────────────────────────────────
|
||||||
|
A third list shape, on the same reasoning A LIST OF TILES gives above it: the design draws a file row
|
||||||
|
with 7-pixel rounded corners and a single fill — Track, not Hover or AccentWash — for both hover and
|
||||||
|
selected, and neither the accent wash nor a square-cornered highlight peeking out from behind a rounded
|
||||||
|
row is the mock's own choice. See TransfersScreen.axaml's local and remote listings.
|
||||||
|
|
||||||
|
No selected+unhovered accent strip either, unlike Border.rowmark's own rows: the design has nothing to
|
||||||
|
distinguish "selected" from "hovered" beyond which one is true at the moment, and :selected alone
|
||||||
|
already answers that once the pointer has moved on — a strip drawn on top would be a second, redundant
|
||||||
|
mark for the one state this list bothers to keep after the pointer leaves.
|
||||||
|
|
||||||
|
Declared after ListBox's own base rules, for the reason recorded three times over already in this file:
|
||||||
|
Avalonia has no specificity, and an exception declared above the rule it excepts does nothing at all.
|
||||||
|
-->
|
||||||
|
<Style Selector="ListBox.filerows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.filerows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.filerows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.filerows > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
|
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
|
||||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||||
@@ -914,6 +1081,41 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b: THE SFTP PANE HEADERS' QUIET GLYPHS ─────────────────────────────────────────────────────────
|
||||||
|
Two variants of the icon-only button above, for the two places wave C's SFTP restyle draws one that is
|
||||||
|
not the drawer's own chrome: a pane's UP/REFRESH/DELETE affordances — see TransfersScreen.axaml, whose
|
||||||
|
remote pane keeps DELETE's own destructive colour rather than borrowing the plain one — and a pane's
|
||||||
|
drive picker, which is a chip rather than a square icon and so borrows Border.chip's own geometry
|
||||||
|
instead, for the reason Button.chiptoggle borrows it too.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.paneicon.danger">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.paneicon.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerWash}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.panechip">
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.panechip /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.panechip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The status dot, in one place rather than as a converter in code.
|
The status dot, in one place rather than as a converter in code.
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,14 @@
|
|||||||
Icon="/Assets/dodossh.ico"
|
Icon="/Assets/dodossh.ico"
|
||||||
Width="1180"
|
Width="1180"
|
||||||
Height="760"
|
Height="760"
|
||||||
MinWidth="1016"
|
MinWidth="1081"
|
||||||
MinHeight="574"
|
MinHeight="583"
|
||||||
Background="{StaticResource Canvas}"
|
Background="{StaticResource Canvas}"
|
||||||
SystemDecorations="BorderOnly"
|
SystemDecorations="BorderOnly"
|
||||||
Focusable="True">
|
Focusable="True">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
|
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
|
||||||
status bar.
|
|
||||||
|
|
||||||
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
||||||
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
||||||
@@ -26,6 +25,17 @@
|
|||||||
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
||||||
somewhere nobody was looking.
|
somewhere nobody was looking.
|
||||||
|
|
||||||
|
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
|
||||||
|
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
|
||||||
|
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
|
||||||
|
stays unchanged at 826, because the rail is the only thing beside a page that grew.
|
||||||
|
|
||||||
|
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
|
||||||
|
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
|
||||||
|
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
|
||||||
|
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
|
||||||
|
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
|
||||||
|
|
||||||
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
||||||
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
||||||
near-black application is the one part of the window that would look borrowed.
|
near-black application is the one part of the window that would look borrowed.
|
||||||
@@ -37,12 +47,19 @@
|
|||||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||||
chrome worth having.
|
chrome worth having.
|
||||||
|
|
||||||
TWO SURFACES, ONE RECTANGLE.
|
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
|
||||||
|
|
||||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
|
||||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
|
||||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
|
||||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
|
||||||
|
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
|
||||||
|
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
|
||||||
|
it simply is not drawn as chrome above every screen while it does.
|
||||||
|
|
||||||
|
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
|
||||||
|
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
|
||||||
|
to write the state where both are showing.
|
||||||
|
|
||||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||||
|
|
||||||
@@ -67,7 +84,9 @@
|
|||||||
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
||||||
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
||||||
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
||||||
fires only for its own IsVisible.
|
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
|
||||||
|
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
|
||||||
|
with the control that much further from the Panel that used to be its only parent.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
@@ -77,209 +96,227 @@
|
|||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
|
|
||||||
<!-- The unlocked application. -->
|
<!-- The unlocked application. -->
|
||||||
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
|
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
|
||||||
|
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
|
||||||
It was the other way round for a reason that stopped being true: while every tab was a terminal,
|
to give up its own first row to. See the remark below on where a session's tabs live now.
|
||||||
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
|
|
||||||
would have been a row of tabs above a column of destinations they had nothing to do with.
|
|
||||||
|
|
||||||
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
|
|
||||||
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
|
|
||||||
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
|
|
||||||
showing at all. Above and full width is the arrangement that matches what selects what.
|
|
||||||
-->
|
-->
|
||||||
<views:TerminalTabs Grid.Row="0" />
|
<views:NavRail Grid.Column="0" />
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
<Panel Grid.Column="1">
|
||||||
|
|
||||||
<!--
|
<!-- ============ THE PAGES ============ -->
|
||||||
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
|
<Panel IsVisible="{Binding IsShowingPages}">
|
||||||
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
|
|
||||||
-->
|
|
||||||
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
|
||||||
|
|
||||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
|
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
||||||
|
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
||||||
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
|
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
||||||
connecting card are all layered on top of one another there, which is right for three things
|
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
||||||
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
|
its visibility goes on the wrapper.
|
||||||
and IsVisible="False" collapse to nothing when there is nothing to show, so a page screen or an
|
|
||||||
empty terminal loses no height to a row it never draws — see ShowsPinStrip, which is false on
|
|
||||||
every page and every tab whose host has no pins.
|
|
||||||
|
|
||||||
ActiveTabPinnedPaths is MainWindowViewModel's, not the vault's: which tab is selected and
|
|
||||||
whether its session is live are the shell's business, and RefreshConnectedHosts is where the
|
|
||||||
two lists — Tabs and Vault.Hosts — are already walked together to paint the status dots. This
|
|
||||||
rides along on the same walk rather than opening a subscription of its own.
|
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
|
<Panel IsVisible="{Binding IsHostsScreen}">
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
||||||
IsVisible="{Binding ShowsPinStrip, FallbackValue=False}">
|
|
||||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="x:String">
|
|
||||||
<!--
|
|
||||||
A single level of $parent[ItemsControl] is enough to reach back to the shell: this
|
|
||||||
ItemsControl's own DataContext is MainWindowViewModel, the window's root, so one hop up
|
|
||||||
from the chip's string DataContext lands on it directly — no #Board-style named-element
|
|
||||||
indirection needed, because there is no second level of template nesting here.
|
|
||||||
-->
|
|
||||||
<Button Classes="ghost" Padding="8,4" ToolTip.Tip="{Binding}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="12" />
|
|
||||||
<TextBlock FontFamily="{StaticResource MonoFont}" Text="{Binding}" FontSize="11.5"
|
|
||||||
MaxWidth="220" TextTrimming="CharacterEllipsis" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Panel Grid.Row="1">
|
|
||||||
|
|
||||||
<!-- ============ THE PAGES ============ -->
|
|
||||||
<Panel IsVisible="{Binding IsShowingPages}">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
|
||||||
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
|
||||||
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
|
||||||
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
|
||||||
its visibility goes on the wrapper.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsHostsScreen}">
|
|
||||||
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ SFTP ============ -->
|
|
||||||
<!--
|
|
||||||
Inside this Panel although it is a tab rather than a rail screen, and that is not an
|
|
||||||
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
|
|
||||||
occlusion question and is true of all three fixed tabs; which of them is showing is the
|
|
||||||
separate question each child below answers. Keeping the two apart is what lets the terminal
|
|
||||||
stay collapsed under one rule rather than under four.
|
|
||||||
|
|
||||||
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
|
|
||||||
this screen is laid out at the full window width.
|
|
||||||
|
|
||||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
|
||||||
visibility is the shell's business and its data context is the transfers view model, and
|
|
||||||
putting both on one element resolves IsVisible against that view model, where
|
|
||||||
IsTransfersScreen does not exist.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
|
||||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ S3 ============ -->
|
|
||||||
<!--
|
|
||||||
The same screen as SFTP above, over the same view model, because an object store and an
|
|
||||||
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
|
||||||
What differs is which picker is offered, and that is decided by the destination rather than
|
|
||||||
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsBucketsScreen}">
|
|
||||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ KEYCHAIN ============ -->
|
|
||||||
<!--
|
|
||||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
|
||||||
element's visibility is the shell's business and its data context is the vault, and put both
|
|
||||||
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
|
||||||
not exist.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsKeychainScreen}">
|
|
||||||
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ HOST KEYS ============ -->
|
|
||||||
<!--
|
|
||||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
|
||||||
view model, where IsKnownHostsScreen does not exist.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
|
||||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ SNIPPETS ============ -->
|
|
||||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
||||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
|
||||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ LOGS ============ -->
|
|
||||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
|
||||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
|
||||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ VAULTS ============ -->
|
|
||||||
<!--
|
|
||||||
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
|
||||||
business and the data context is the vaults view model, and both on one element would resolve
|
|
||||||
IsVaultsScreen against a type that does not have it.
|
|
||||||
|
|
||||||
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
|
||||||
is one vault's contents and is what the keychain and hosts screens above draw.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsVaultsScreen}">
|
|
||||||
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- ============ PREFERENCES ============ -->
|
|
||||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
|
||||||
|
|
||||||
<!-- ============ IMPORT ============ -->
|
|
||||||
<!--
|
|
||||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
|
||||||
the others whose data context is their own view model.
|
|
||||||
-->
|
|
||||||
<Panel IsVisible="{Binding IsImportScreen}">
|
|
||||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ SFTP ============ -->
|
||||||
<!--
|
<!--
|
||||||
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
|
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
|
||||||
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
|
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
|
||||||
has a session or it does not — which is what makes drawing it here safe under the occlusion
|
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
|
||||||
rule, the same way the page area is. See ConnectingCard.axaml.
|
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
|
||||||
-->
|
a host header and a status bar around the screen's own content, and a 300px sidebar.
|
||||||
<views:ConnectingCard x:Name="ConnectingPane"
|
|
||||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
|
||||||
|
|
||||||
|
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
|
||||||
|
not the WebView", which is the occlusion question, and which mode the switcher is on is this
|
||||||
|
wrapper's own separate question.
|
||||||
|
|
||||||
|
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
|
||||||
|
this application, and building one is out of this wave's scope; see the notes' own open
|
||||||
|
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
|
||||||
|
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
|
||||||
|
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
|
||||||
|
exists that does not.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="26">
|
||||||
|
<views:SessionTabRow Grid.Row="0" Classes="sftp"
|
||||||
|
TabCommand="{Binding SelectFilesHostCommand}" />
|
||||||
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||||
|
<views:SessionHeader Grid.Row="0"
|
||||||
|
OpenLabel="Open terminal"
|
||||||
|
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
|
||||||
|
EmptyText="{Binding Transfers.Status}" />
|
||||||
|
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
|
||||||
|
<views:SessionStatusBar Grid.Row="2" />
|
||||||
|
</Grid>
|
||||||
|
<!--
|
||||||
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||||
|
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
|
||||||
|
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
|
||||||
|
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
|
||||||
|
-->
|
||||||
|
<views:SessionSidebar Grid.Column="1"
|
||||||
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ S3 ============ -->
|
||||||
<!--
|
<!--
|
||||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
The same screen as SFTP above, over the same view model, because an object store and an
|
||||||
process tree, so twenty tabs would cost twenty of them.
|
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
||||||
|
What differs is which picker is offered, and that is decided by the destination rather than
|
||||||
|
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
|
||||||
|
calls it now; see NavRail.axaml.
|
||||||
|
|
||||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
|
||||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
|
||||||
|
could show. Wave B's own scope is the terminal and SFTP surfaces; S3 stays the plain screen it
|
||||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
already was, and this is the recorded reason rather than an oversight.
|
||||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
|
||||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
|
||||||
does.
|
|
||||||
-->
|
-->
|
||||||
<NativeWebView x:Name="Terminal"
|
<Panel IsVisible="{Binding IsBucketsScreen}">
|
||||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ KEYCHAIN ============ -->
|
||||||
|
<!--
|
||||||
|
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||||
|
element's visibility is the shell's business and its data context is the vault, and put both
|
||||||
|
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
||||||
|
not exist.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsKeychainScreen}">
|
||||||
|
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ HOST KEYS ============ -->
|
||||||
|
<!--
|
||||||
|
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||||
|
view model, where IsKnownHostsScreen does not exist.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||||
|
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ SNIPPETS ============ -->
|
||||||
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||||
|
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||||
|
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ LOGS ============ -->
|
||||||
|
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||||
|
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||||
|
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ VAULTS ============ -->
|
||||||
|
<!--
|
||||||
|
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
||||||
|
business and the data context is the vaults view model, and both on one element would resolve
|
||||||
|
IsVaultsScreen against a type that does not have it.
|
||||||
|
|
||||||
|
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
||||||
|
is one vault's contents and is what the keychain and hosts screens above draw.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsVaultsScreen}">
|
||||||
|
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ PREFERENCES ============ -->
|
||||||
|
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||||
|
|
||||||
|
<!-- ============ IMPORT ============ -->
|
||||||
|
<!--
|
||||||
|
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||||
|
the others whose data context is their own view model.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsImportScreen}">
|
||||||
|
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ TERMINAL ============ -->
|
||||||
|
<!--
|
||||||
|
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
|
||||||
|
around the real terminal control rather than around a page.
|
||||||
|
|
||||||
|
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
|
||||||
|
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
|
||||||
|
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
|
||||||
|
a padded column, a bordered container and a header row rather than sitting directly beside the
|
||||||
|
page area the way it used to.
|
||||||
|
|
||||||
|
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
|
||||||
|
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
|
||||||
|
called out as unverified, and nesting the control deeper without touching its own binding is not
|
||||||
|
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
|
||||||
|
the WebView's own visibility flip together on every surface change, driven by the same property
|
||||||
|
change rather than one waiting on the other.
|
||||||
|
|
||||||
|
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
|
||||||
|
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
|
||||||
|
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
|
||||||
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||||
|
<views:SessionHeader Grid.Row="0"
|
||||||
|
OpenLabel="Open SFTP"
|
||||||
|
OpenCommand="{Binding SelectFilesHostCommand}"
|
||||||
|
OpenCommandParameter="{Binding SelectedTab}"
|
||||||
|
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
|
||||||
|
|
||||||
|
<Panel Grid.Row="1" Background="{StaticResource Pane}">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||||
|
exist yet, or never will. Exclusive with the WebView below by construction — a
|
||||||
|
selected tab either has a session or it does not — which is what makes drawing it here
|
||||||
|
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
|
||||||
|
-->
|
||||||
|
<views:ConnectingCard x:Name="ConnectingPane"
|
||||||
|
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||||
|
browser process tree, so twenty tabs would cost twenty of them.
|
||||||
|
|
||||||
|
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
|
||||||
|
IsVisible then falls back to its default of true, and the occlusion comes back
|
||||||
|
silently. Not reachable at runtime — the DataContext is set before the window is shown
|
||||||
|
— but it is what the previewer does.
|
||||||
|
-->
|
||||||
|
<NativeWebView x:Name="Terminal"
|
||||||
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||||
|
</Grid>
|
||||||
|
<!--
|
||||||
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
||||||
|
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
|
||||||
|
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
|
||||||
|
"Auto" column it sits in, so the pane column takes the width back.
|
||||||
|
-->
|
||||||
|
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
|
||||||
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
|
||||||
|
</Panel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -7,159 +7,295 @@
|
|||||||
<!--
|
<!--
|
||||||
The window's destinations, down the left edge.
|
The window's destinations, down the left edge.
|
||||||
|
|
||||||
Still called NavRail although v2 makes it 190 pixels wide and gives every entry a word: the type is
|
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
|
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
|
||||||
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
|
255. The counts v2 added beside every row are gone — the mock's own row is an icon and a word, nothing
|
||||||
|
else, and this pass follows it rather than keeping a feature the mock never had; nowhere else on screen
|
||||||
|
states the number instead, so it is simply not drawn any more. And two things this rail used to answer
|
||||||
|
to the tab strip now answer to the rail itself:
|
||||||
|
|
||||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
|
||||||
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
|
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
|
||||||
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
|
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
|
||||||
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
|
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
|
||||||
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
|
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
|
||||||
is a question you would otherwise have to open the screen to answer.
|
three-way here rather than two live segments and one dimmed — the mock leaves S3 unstyled as future
|
||||||
|
work, and this application already has bucket browsing, so it is wired like its two neighbours. See
|
||||||
|
<c>MainWindowViewModel.IsSshShowing</c>, <c>IsTransfersShowing</c> and <c>IsBucketsShowing</c>.
|
||||||
|
|
||||||
A count is drawn only where one is real. Logs and Preferences have none — a log has no total until it is
|
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
|
||||||
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
|
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
|
||||||
number on every row; a zero beside Logs would be a fact this application never computed.
|
the v3 file carried, and <c>MainWindowViewModel.IsVaultsTab</c>, which still exists and still answers
|
||||||
|
the question it always did. What changed is <c>MainWindow.axaml</c>: the rail is part of the window's
|
||||||
|
own furniture now, the same as the titlebar and the status bar, so it stays up beside SFTP and S3 and
|
||||||
|
beside an open terminal — which is exactly what makes the switcher above worth having here at all.
|
||||||
|
|
||||||
── SFTP AND S3 ARE NOT HERE, and that is the tab strip's doing. ──────────────────────────────────────
|
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
|
||||||
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
|
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
|
||||||
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
|
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
|
||||||
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
|
which read the same three flags the switcher above lights — so the row and the segment can never name
|
||||||
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
|
two different modes between them.
|
||||||
in. See MainWindowViewModel.IsVaultsTab.
|
|
||||||
|
|
||||||
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
|
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
|
||||||
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
|
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||||
counting buckets is going anyway.
|
in identity this application actually has — a display name and, where the server sent one, an email —
|
||||||
|
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
|
||||||
|
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
|
||||||
|
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
|
||||||
|
|
||||||
The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
|
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||||
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
|
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||||
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
|
Button carries no state to disagree with the screen that is actually showing.
|
||||||
window, and the button's own comment says which is which.
|
|
||||||
|
|
||||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
|
||||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
|
||||||
anything. Buttons carry no state and cannot disagree with the screen that is showing.
|
|
||||||
|
|
||||||
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
|
|
||||||
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
|
|
||||||
back — and an entry lit while a terminal filled the window would be pointing at a screen that is not
|
|
||||||
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
|
|
||||||
mark, in the strip, and two "you are here" marks is one too many.
|
|
||||||
|
|
||||||
The design pins a "Team vault" card to the foot of this list, beside a Team entry in the list itself.
|
|
||||||
Two routes to one screen, one of them carrying a seat count nothing here can produce, so what sits at
|
|
||||||
the foot is Preferences — which is where it already was, and which is the one entry that is about the
|
|
||||||
machine rather than about the keychain.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Width="190" Background="{StaticResource Sidebar}"
|
<Border Width="255" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
<DockPanel LastChildFill="False">
|
<!--
|
||||||
|
Border rather than Chip for the right-hand rule, although the mock's own value — rgb(26,26,40) — is
|
||||||
|
Chip's exact #1A1A28. Chip is the fill behind a tag, and reusing it here as a line would answer a
|
||||||
|
later "why does a rail border share a key with a bucket chip" with "it doesn't, they just happen to
|
||||||
|
match" — where Border, at #1E1E2C, is one shade off and reads identically at one pixel wide.
|
||||||
|
-->
|
||||||
|
<DockPanel LastChildFill="False" Margin="14">
|
||||||
|
|
||||||
<StackPanel DockPanel.Dock="Top" Margin="8,10,8,0" Spacing="2">
|
<StackPanel DockPanel.Dock="Top" Spacing="18">
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
|
||||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="▤" />
|
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Hosts" />
|
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Hosts.Count}" />
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
|
The segmented switcher. Three equal columns in a Grid rather than a StackPanel with Width="*" on
|
||||||
with the list under it. It no longer does: buckets have their own entry above, so this number and
|
each child — Avalonia gives a StackPanel's children their desired size, not an even split, and the
|
||||||
this screen now count the same things.
|
mock's three segments are exactly a third each.
|
||||||
-->
|
-->
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
<Border Classes="navtrack">
|
||||||
Command="{Binding ShowScreenCommand}"
|
<Grid ColumnDefinitions="*,*,*">
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding IsSshShowing}"
|
||||||
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
Command="{Binding ShowTerminalCommand}"
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
ToolTip.Tip="The terminal, and every shell you have open">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚿" />
|
<TextBlock Text="SSH" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
|
</Button>
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
|
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding IsTransfersShowing}"
|
||||||
|
Command="{Binding ShowFilesCommand}"
|
||||||
|
CommandParameter="{x:Static vm:RemoteKind.Host}"
|
||||||
|
ToolTip.Tip="Move files to and from a host over SFTP">
|
||||||
|
<TextBlock Text="SFTP" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Classes="navseg" Classes.active="{Binding IsBucketsShowing}"
|
||||||
|
Command="{Binding ShowFilesCommand}"
|
||||||
|
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
|
||||||
|
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
|
||||||
|
<TextBlock Text="S3" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Border>
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
<StackPanel Spacing="8">
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
|
||||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="◈" />
|
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Pins" />
|
|
||||||
<!--
|
|
||||||
The vault's pins, not the screen's VisiblePins — that one is the filtered list, so a sidebar
|
|
||||||
bound to it would count what the Pins screen's own filter box happens to match and would
|
|
||||||
change as somebody typed in it. Every other count here is a total; this one has to be too.
|
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="2" Classes="navcount"
|
|
||||||
Text="{Binding Vault.KnownHostPins.Count}" />
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
<!--
|
||||||
Command="{Binding ShowScreenCommand}"
|
The mode-dependent first row: Terminal, Files or Buckets, matching whichever segment above is
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
lit. Active follows !IsVaultsTab rather than a property of its own — that is already exactly
|
||||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
"the terminal surface, or the files screen, or the buckets screen", which is what this row is.
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
-->
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="❯" />
|
<Button Classes="flat nav" Classes.active="{Binding !IsVaultsTab}"
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
|
Command="{Binding ShowFirstRailItemCommand}"
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
|
ToolTip.Tip="The terminal while SSH is selected, or whichever file picker SFTP or S3 chose">
|
||||||
</Grid>
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
</Button>
|
<TextBlock Classes="navicon" Text="{Binding FirstRailItemIcon}" />
|
||||||
|
<TextBlock Classes="navlabel" Text="{Binding FirstRailItemLabel}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="≡" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
|
<TextBlock Classes="navlabel" Text="Hosts" />
|
||||||
</Grid>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
|
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
|
||||||
the server authorises against; it is no longer something anybody has to make, name or think about,
|
passwords; see ShellScreen.Keychain for the name that did not move with the label.
|
||||||
so the rail names the thing people came for. See VaultsViewModel.
|
-->
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
||||||
|
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Keys" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
It shares a word with the tab strip's first tab, which is a different level of the window: that
|
<!--
|
||||||
tab is "this application rather than SFTP or S3", and this is one of the nine screens under it.
|
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
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
|
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
||||||
Command="{Binding ShowScreenCommand}"
|
-->
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}"
|
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||||
ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
|
Command="{Binding ShowScreenCommand}"
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
|
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||||
<!--
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
No count. The vault list is the session's and could be counted here — but who is in each one
|
<TextBlock Classes="navicon" Text="" />
|
||||||
is read from the server when the screen is opened, not on unlock, and a number naming only
|
<TextBlock Classes="navlabel" Text="Pins" />
|
||||||
half of what the screen is about would be the one figure on this list that has to be
|
</StackPanel>
|
||||||
explained.
|
</Button>
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
|
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||||
</Grid>
|
Command="{Binding ShowScreenCommand}"
|
||||||
</Button>
|
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||||
|
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Snips" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||||
|
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Logs" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
|
<!--
|
||||||
Classes.active="{Binding IsPreferencesShowing}"
|
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
|
||||||
Command="{Binding ShowScreenCommand}"
|
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
|
||||||
ToolTip.Tip="Preferences, and this machine's device key">
|
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
switcher replaced, which sat directly above that rectangle and had to select a page before opening
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
|
one for exactly that reason.
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
|
-->
|
||||||
|
<Button x:Name="UserChip" DockPanel.Dock="Bottom" Classes="flat navuser" Click="OnUserChipPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0" Width="20" Height="20" CornerRadius="60"
|
||||||
|
Background="{StaticResource AvatarGradient}">
|
||||||
|
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="7.5" LetterSpacing="0.2"
|
||||||
|
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="10,0,0,0" Spacing="4">
|
||||||
|
<TextBlock FontWeight="Bold" FontSize="10.5" LetterSpacing="0.1"
|
||||||
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock FontSize="8" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
Text="▼" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<FlyoutBase.AttachedFlyout>
|
||||||
|
<Flyout Placement="TopEdgeAlignedLeft">
|
||||||
|
<StackPanel Width="227" Spacing="8">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="{Binding Email}"
|
||||||
|
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One row per readable vault — the strip's old "SHOW ITEMS FROM" chips, restyled: a 14-pixel
|
||||||
|
initial square (Chip's own fill, since no per-vault colour exists to draw honestly) and a
|
||||||
|
magenta check where the vault's items are shown. Toggling one leaves the Flyout open, the
|
||||||
|
same as the chips it replaces did — this is a switch to flip, not a place to leave from.
|
||||||
|
-->
|
||||||
|
<ItemsControl ItemsSource="{Binding VaultToggles}" IsVisible="{Binding HasVaultSwitches}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultToggleViewModel">
|
||||||
|
<Button Classes="poprow"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<Border Grid.Column="0" Width="14" Height="14" CornerRadius="2"
|
||||||
|
Background="{StaticResource Chip}">
|
||||||
|
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="9"
|
||||||
|
Foreground="{StaticResource TextDim}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Margin="10,0" FontSize="10"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding Display}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Grid.Column="2" Classes="vaultcheck" IsVisible="{Binding IsShown}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="10"
|
||||||
|
Foreground="White"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Classes="poprow" Click="OnPopoverNewVaultPressed"
|
||||||
|
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Text="New vault" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text=""
|
||||||
|
FontSize="12" Foreground="{StaticResource TextGhost}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Settings and Preferences both land on the one screen this application actually has — the
|
||||||
|
mock's own Settings area is a family of screens that do not exist yet; see the decided
|
||||||
|
deviation in design-notes/v5b-fidelity-notes.md. Two doors to one room rather than one door
|
||||||
|
removed, so neither word on the mock goes unanswered.
|
||||||
|
-->
|
||||||
|
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The existing sign-out flow, with its own confirm card — see
|
||||||
|
MainWindowViewModel.SignOutFromPopover for why this goes through Preferences rather than
|
||||||
|
calling SignOutCommand directly from wherever the popover happened to be opened.
|
||||||
|
-->
|
||||||
|
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Flyout>
|
||||||
|
</FlyoutBase.AttachedFlyout>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,9 +1,85 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>The five destinations down the left edge of the unlocked window.</summary>
|
/// <summary>The window's destinations, down the left edge — the switcher, the six rail rows and the user chip.</summary>
|
||||||
internal sealed partial class NavRail : UserControl
|
internal sealed partial class NavRail : UserControl
|
||||||
{
|
{
|
||||||
public NavRail() => InitializeComponent();
|
public NavRail() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>Opens the user popover.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A handler rather than relying on the click that opening a <c>Flyout</c> answers to on its own: a
|
||||||
|
/// named method is a thing a test can call directly, where an implicit open is not.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnUserChipPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Control chip)
|
||||||
|
{
|
||||||
|
FlyoutBase.ShowAttachedFlyout(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hides the popover, whatever handler is about to navigate.</summary>
|
||||||
|
private void ClosePopover()
|
||||||
|
{
|
||||||
|
if (this.FindControl<Button>("UserChip") is { } chip)
|
||||||
|
{
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)?.Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Leaves for the vaults screen with the new-vault form open, shutting the popover behind it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The popover is closed first: the command navigates, and a popup left open would be hanging over a
|
||||||
|
/// screen it has nothing to do with. A <c>Flyout</c> does not close on its own when something inside it
|
||||||
|
/// is pressed — which is what the vault switches above it want, and not what this wants.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnPopoverNewVaultPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.ShowNewVaultCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Settings and Preferences both land here — see the remark in the markup.</summary>
|
||||||
|
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
|
||||||
|
/// <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.SignOutFromPopoverCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<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.SessionSidebar"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's session sidebar ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
300 pixels, Sidebar background, a 1px left border — the design's own right-hand column on both the
|
||||||
|
terminal and the SFTP screen. It replaces the pin chip strip that used to sit above the terminal: see
|
||||||
|
MainWindowViewModel.ActiveTabPinnedPaths and OpenPinnedPathCommand, both reused here unchanged, and
|
||||||
|
MainWindow.axaml for where the old strip's Border used to live.
|
||||||
|
|
||||||
|
QUICK ACCESS is on both surfaces. SNIPS is the terminal's own — gated on <see cref="ShowsSnips"/>, which
|
||||||
|
the terminal usage in MainWindow.axaml sets true and the SFTP usage leaves false, rather than a second
|
||||||
|
copy of this file: the two sections share nothing surface-specific except which one is drawn at all.
|
||||||
|
|
||||||
|
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||||
|
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||||
|
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="6" Margin="16,20">
|
||||||
|
|
||||||
|
<!-- ============ QUICK ACCESS ============ -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<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"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="{Binding}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="" 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}"
|
||||||
|
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||||
|
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||||
|
|
||||||
|
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||||
|
|
||||||
|
<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="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b session sidebar: QUICK ACCESS on both surfaces, SNIPS on the terminal's own. See the remark at
|
||||||
|
/// the top of SessionSidebar.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionSidebar : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>Whether the SNIPS section draws below QUICK ACCESS.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set from the usage site rather than inferred from a surface flag on the shell, for the same reason
|
||||||
|
/// <see cref="SessionTabRow.TabCommand"/> is: which sections a particular instance of this control shows
|
||||||
|
/// is a fact about where it was placed in <c>MainWindow.axaml</c>, not one this control can read off its
|
||||||
|
/// own <c>DataContext</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<bool> ShowsSnipsProperty =
|
||||||
|
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
|
||||||
|
|
||||||
|
public SessionSidebar() => InitializeComponent();
|
||||||
|
|
||||||
|
internal bool ShowsSnips
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsSnipsProperty);
|
||||||
|
set => SetValue(ShowsSnipsProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<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.SessionStatusBar"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||||
|
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
||||||
|
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
|
||||||
|
key/credential name; none of the three is reachable from this shell today — see the wave B report this
|
||||||
|
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
|
||||||
|
them is drawn, omitted rather than faked, per this project's honesty rule.
|
||||||
|
|
||||||
|
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
||||||
|
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
||||||
|
the same state the header answers with its own empty-state sentence; the elapsed timer, off
|
||||||
|
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
|
||||||
|
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
|
||||||
|
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
|
||||||
|
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
|
||||||
|
to it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
|
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||||
|
IsVisible="{Binding IsSessionConnected}" />
|
||||||
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
|
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||||
|
IsVisible="{Binding !IsSessionConnected}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
|
||||||
|
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="UTF-8"
|
||||||
|
IsVisible="{Binding #Root.ShowsEncoding}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
|
||||||
|
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionStatusBar : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether "UTF-8" is drawn on the right.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set true only by the terminal surface's own usage in <c>MainWindow.axaml</c>. It is a fact about this
|
||||||
|
/// client's renderer and its write path — see the remark on <c>SessionStatusBar.axaml</c> — and has
|
||||||
|
/// nothing to do with an SFTP session, which moves bytes rather than decoded text.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<bool> ShowsEncodingProperty =
|
||||||
|
AvaloniaProperty.Register<SessionStatusBar, bool>(nameof(ShowsEncoding));
|
||||||
|
|
||||||
|
public SessionStatusBar() => InitializeComponent();
|
||||||
|
|
||||||
|
internal bool ShowsEncoding
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsEncodingProperty);
|
||||||
|
set => SetValue(ShowsEncodingProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<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.SessionTabRow"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's in-screen tab row, the replacement for TerminalTabs ───────────────────────────────────────────
|
||||||
|
One pill per open terminal and the button that opens another — the same list <c>TerminalTabs</c> drew
|
||||||
|
above the whole window — now drawn inside each of the two screens the design gives a tab row: the
|
||||||
|
terminal surface and the SFTP surface. See <c>MainWindow.axaml</c> for where the strip itself went and
|
||||||
|
<c>Palette.axaml</c>'s v5b remark for why the row is 38 tall rather than the old strip's 42.
|
||||||
|
|
||||||
|
One control rather than two copies of the same markup, because the two rows share everything except two
|
||||||
|
things: which colour marks the active tab, and what a click on a tab actually does. Both are handed in
|
||||||
|
from the usage site rather than branched on a mode property here.
|
||||||
|
|
||||||
|
◆ THE COLOUR is <c>Classes="sftp"</c> on this control's own usage in <c>MainWindow.axaml</c> — unmarked
|
||||||
|
is the terminal row and reads <c>TerminalTabAccent</c>; <c>.sftp</c> reads <c>Magenta</c>. Both are
|
||||||
|
<c>App.axaml</c> selectors keyed off that class on this element, which is why the two rows need no
|
||||||
|
binding of their own for it: <c>Button.sesstab.active</c> and <c>.sftp Button.sesstab.active</c> are
|
||||||
|
the whole of it.
|
||||||
|
|
||||||
|
◆ THE CLICK is <see cref="TabCommand"/>, a plain <c>ICommand</c> this control exposes rather than reads
|
||||||
|
off the shell — the terminal row binds it to <c>SelectTabCommand</c> and the SFTP row to
|
||||||
|
<c>SelectFilesHostCommand</c>, and neither of those is a decision this control has any business making.
|
||||||
|
Every tab button's own <c>CommandParameter</c> is the tab itself, exactly as the strip's was.
|
||||||
|
|
||||||
|
The close box, the middle-click gesture and the "+" are not parameterised: closing a tab ends its shell
|
||||||
|
regardless of which screen it was clicked from, and "+" always opens the same palette. See
|
||||||
|
<c>MainWindowViewModel.CloseTabCommand</c> and <c>ToggleSearchCommand</c>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="38">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Marked on IsSelected rather than IsShowing, unlike the old strip: IsShowing is "the terminal
|
||||||
|
surface is showing this tab's pane", which the SFTP row's own active tab is never true of. A
|
||||||
|
selected tab survives navigating away from the terminal surface — that is the whole point of
|
||||||
|
a selection outliving a screen — so both rows agree on what "active" means without either
|
||||||
|
one needing a flag scoped to just one surface. See App.axaml's own remark on Button.sesstab.
|
||||||
|
-->
|
||||||
|
<Button Classes="sesstab"
|
||||||
|
Classes.active="{Binding IsSelected}"
|
||||||
|
Command="{Binding #Root.TabCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
PointerPressed="OnTabPointerPressed"
|
||||||
|
ToolTip.Tip="{Binding Address}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||||
|
<!--
|
||||||
|
Two states, as the strip's own dots always were: green while the shell behind this tab is
|
||||||
|
running, grey while it is connecting and once it has ended. The design's third, amber,
|
||||||
|
state has no meaning here — nothing in this application checks whether a host is merely
|
||||||
|
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
|
||||||
|
-->
|
||||||
|
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
||||||
|
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Opens the quick-connect palette, on both rows: "the existing new-connection/quick-connect meaning"
|
||||||
|
the notes ask this button to keep. See TerminalTabs' own remark, carried over unchanged, on why
|
||||||
|
this is a palette and never a flyout menu over the terminal's own rectangle.
|
||||||
|
-->
|
||||||
|
<Button Classes="sesstab plus"
|
||||||
|
Command="{Binding ToggleSearchCommand}"
|
||||||
|
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||||
|
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b in-screen tab row: one pill per open terminal, and the "+" that opens another — parameterised by
|
||||||
|
/// <see cref="TabCommand"/> so the terminal surface and the SFTP surface can each wire a click to a different
|
||||||
|
/// meaning over the same list. See the remark at the top of SessionTabRow.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionTabRow : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>What a left click on a tab runs, with the tab itself as the command parameter.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A plain <see cref="ICommand"/> rather than a bound property read off the shell, because which command
|
||||||
|
/// that is is the one thing this control cannot decide for itself — the terminal surface wants
|
||||||
|
/// <c>SelectTabCommand</c> and the SFTP surface wants <c>SelectFilesHostCommand</c>, and only the caller
|
||||||
|
/// in <c>MainWindow.axaml</c> knows which screen this instance is on.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<ICommand?> TabCommandProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTabRow, ICommand?>(nameof(TabCommand));
|
||||||
|
|
||||||
|
public SessionTabRow() => InitializeComponent();
|
||||||
|
|
||||||
|
internal ICommand? TabCommand
|
||||||
|
{
|
||||||
|
get => GetValue(TabCommandProperty);
|
||||||
|
set => SetValue(TabCommandProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes a tab on a middle click. See the identical remark on the strip this control replaced,
|
||||||
|
/// <c>TerminalTabs.axaml.cs</c>, for why this is <c>PointerUpdateKind</c> rather than
|
||||||
|
/// <c>IsMiddleButtonPressed</c>, why it fires on press rather than release, and why it is wired on the
|
||||||
|
/// tab's own template root rather than on the row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not parameterised like <see cref="TabCommand"/>: closing a tab ends its shell regardless of which
|
||||||
|
/// screen the middle click landed on, so both rows want the same answer — <c>CloseTabCommand</c>, read
|
||||||
|
/// directly off this control's own <see cref="StyledElement.DataContext"/>, which is the shell on both.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
||||||
|
|| DataContext is not MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
||||||
|
is not PointerUpdateKind.MiddleButtonPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
shell.CloseTabCommand.Execute(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,314 +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.TerminalTabs"
|
|
||||||
x:DataType="vm:MainWindowViewModel">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The window's tab strip: three fixed tabs, then one per open terminal.
|
|
||||||
|
|
||||||
── IT IS NOT ONLY TERMINALS ANY MORE, and the type is still called TerminalTabs. ─────────────────────
|
|
||||||
Vaults, SFTP and S3 sit at the head of the strip and are always there. The name stays because the strip
|
|
||||||
is named in MainWindow, in the layout harness's height budget and in its own suite, and renaming a type
|
|
||||||
to track what it grew into is a rename across four files that leaves the product identical. What the
|
|
||||||
name now under-describes is written here instead.
|
|
||||||
|
|
||||||
── THE THREE FIXED TABS ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
None of the three can be closed, and that is the difference between them and a terminal rather than a
|
|
||||||
styling choice. A terminal tab owns a shell and closing it ends that shell; these three own nothing —
|
|
||||||
they are three places this window goes, and a close box on one would be asking whether to destroy a
|
|
||||||
destination.
|
|
||||||
|
|
||||||
Vaults is first and is the only one with anything under it: the nav rail, and whichever of its screens
|
|
||||||
the rail points at. SFTP and S3 were rail entries until this strip existed, and they moved because they
|
|
||||||
are the two destinations you *stay in* while something runs. The rail is drawn only under Vaults; see
|
|
||||||
MainWindowViewModel.IsVaultsTab for why that is expressed as a page test rather than as a surface.
|
|
||||||
|
|
||||||
S3 carries no count although the rail entry it replaces did. There is room for one, and a number on two
|
|
||||||
of five tabs reads as a fact about those two rather than as the tab's own state — a terminal tab has
|
|
||||||
nothing to count, and the eye reads the strip left to right expecting the same shape.
|
|
||||||
|
|
||||||
── THE TERMINAL TABS ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
Every one is one pane in the one WebView, so switching is a single frame telling the page which pane to
|
|
||||||
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
|
|
||||||
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
|
|
||||||
one is the only thing in this application that deliberately ends a session.
|
|
||||||
|
|
||||||
The strip spans the whole window rather than one screen, which is what it is for: a connection you
|
|
||||||
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
|
|
||||||
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
|
|
||||||
|
|
||||||
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
|
|
||||||
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
|
|
||||||
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
|
|
||||||
|
|
||||||
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
|
|
||||||
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
|
|
||||||
that state.
|
|
||||||
|
|
||||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
Tabs became pills: taller, rounded, each with its own outline, on the sidebar's surface rather than the
|
|
||||||
chrome's. The design puts a "Hosts" pill at the head of this strip and hides the sidebar while a session
|
|
||||||
is showing, so that pill is the only way back. The three fixed tabs are that idea taken at its word and
|
|
||||||
one step further: the rail is not hidden, but it belongs to the Vaults tab, and the head of the strip is
|
|
||||||
where you go to get back to it.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<Border Height="42" Background="{StaticResource Sidebar}"
|
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Everything in one scrolling row: the three fixed tabs, a rule, the terminals, the button that opens
|
|
||||||
another, then the sentence for when there are none. The strip stays rather than collapsing — a row of
|
|
||||||
chrome that appears and disappears would move every screen up and down by 42 pixels each time the last
|
|
||||||
tab closed.
|
|
||||||
-->
|
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The three that are always here. Buttons with no close box, marked active from the shell's own
|
|
||||||
state rather than holding a selection of their own — the same reason the terminal tabs below are
|
|
||||||
buttons and not a TabStrip.
|
|
||||||
|
|
||||||
Each is lit by a different property and the three are exclusive by construction: IsVaultsTab is
|
|
||||||
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
|
|
||||||
IsBucketsShowing that both heads already use. Nothing here can light two at once.
|
|
||||||
-->
|
|
||||||
<!--
|
|
||||||
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
|
|
||||||
one button with a menu, because the tab's job is to go somewhere and that must stay a single
|
|
||||||
click — a tab you cannot press without being asked a question is not a tab.
|
|
||||||
|
|
||||||
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
|
|
||||||
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
|
|
||||||
rectangle, and whether a popup dropping into it composites above a native child window is not
|
|
||||||
something this project treats as settled without a screenshot.
|
|
||||||
|
|
||||||
What makes the question not arise here is the order in the handler. OnVaultMenuPressed selects
|
|
||||||
the Vaults tab *first*, which sets the shell's surface to a page and collapses the renderer — so
|
|
||||||
by the time the flyout opens there is no native child window under it. Exactly the move
|
|
||||||
QuickConnect already makes. It is also the behaviour a user expects: the caret belongs to the
|
|
||||||
Vaults tab, so pressing it going to Vaults is not a surprise.
|
|
||||||
|
|
||||||
The handler is explicit rather than Button.Flyout's own open, so that ordering is a thing the
|
|
||||||
code states and the headless suite can assert, rather than a thing the framework happens to do.
|
|
||||||
-->
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
|
||||||
|
|
||||||
<Button Classes="flat tab fixed split" Classes.active="{Binding IsVaultsTab}"
|
|
||||||
Command="{Binding ShowVaultsCommand}"
|
|
||||||
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="Vaults" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button x:Name="VaultMenu" Classes="flat tab fixed caret" Width="22"
|
|
||||||
Classes.active="{Binding IsVaultsTab}"
|
|
||||||
Click="OnVaultMenuPressed"
|
|
||||||
ToolTip.Tip="Choose which vaults this window shows, or make a new one">
|
|
||||||
<TextBlock Text="⌄" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<FlyoutBase.AttachedFlyout>
|
|
||||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
|
||||||
<StackPanel Width="230" Spacing="8">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Chips rather than checkable menu items. Nothing in this application uses a checkable
|
|
||||||
MenuItem, and binding one needs an ItemContainerTheme to reach ToggleType and IsChecked
|
|
||||||
plus a composed collection to put a fixed entry after a bound one — where the chip
|
|
||||||
toggle beside every host's tags already says on-and-off in this window's own language.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="label" Text="SHOW ITEMS FROM"
|
|
||||||
IsVisible="{Binding HasVaultSwitches}" />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding VaultToggles}"
|
|
||||||
IsVisible="{Binding HasVaultSwitches}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:VaultToggleViewModel">
|
|
||||||
<Button Classes="chiptoggle" Classes.worn="{Binding IsShown}"
|
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
|
||||||
Margin="0,0,0,4"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<TextBlock Text="{Binding Display}" FontSize="11"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
|
||||||
</Button>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A hint rather than a disabled switch, because the personal vault's chip is drawn lit and
|
|
||||||
pressing it says the same thing in the status bar. One sentence under the list is where
|
|
||||||
somebody looks when a chip does not move.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding HasVaultSwitches}"
|
|
||||||
Text="Switching a vault off only stops it being listed here. It still syncs, and hosts that authenticate with its keys still connect." />
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}"
|
|
||||||
IsVisible="{Binding HasVaultSwitches}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A handler rather than a Command binding, because this one navigates and the menu has to
|
|
||||||
shut on the way. A Flyout stays open when something inside it is pressed — which is
|
|
||||||
right for the chips above, where switching two vaults off is one visit — and wrong for
|
|
||||||
the one entry that leaves.
|
|
||||||
-->
|
|
||||||
<Button Classes="ghost" HorizontalAlignment="Stretch"
|
|
||||||
HorizontalContentAlignment="Left"
|
|
||||||
Content="New vault…"
|
|
||||||
Click="OnNewVaultPressed"
|
|
||||||
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" />
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</Flyout>
|
|
||||||
</FlyoutBase.AttachedFlyout>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
|
|
||||||
Command="{Binding ShowFilesCommand}"
|
|
||||||
CommandParameter="{x:Static vm:RemoteKind.Host}"
|
|
||||||
ToolTip.Tip="Move files to and from a host over SFTP">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="⇅" FontSize="13" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="SFTP" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The same screen as SFTP over the same view model — an object store and an SFTP host are both an
|
|
||||||
IRemoteFileStore — and a separate tab anyway, because which picker is offered is decided by the
|
|
||||||
destination rather than by a toggle inside the screen. See ShowFiles, which also explains why
|
|
||||||
pressing this while an SFTP session is open refuses instead of arriving.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsBucketsShowing}"
|
|
||||||
Command="{Binding ShowFilesCommand}"
|
|
||||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
|
|
||||||
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="◳" FontSize="13" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="S3" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
What separates the fixed tabs from the terminals. Without it the strip is five pills of the same
|
|
||||||
shape and the user has to read all five to learn that three of them are places and two are
|
|
||||||
machines. It is a rule rather than a gap because a gap at this width reads as the strip having
|
|
||||||
been laid out carelessly.
|
|
||||||
-->
|
|
||||||
<Border Width="1" Height="18" Margin="6,0,10,0" VerticalAlignment="Center"
|
|
||||||
Background="{StaticResource Border}" />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<StackPanel Orientation="Horizontal" />
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
|
|
||||||
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
|
|
||||||
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
|
|
||||||
for the tab to its right.
|
|
||||||
|
|
||||||
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
|
|
||||||
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
|
|
||||||
event handled — so a left press on the cross does not also select the tab. It deliberately
|
|
||||||
does not handle any other button, which is exactly what lets a middle press bubble out of
|
|
||||||
the cross and reach the handler below.
|
|
||||||
|
|
||||||
Marked active on IsShowing rather than on IsSelected, which are not the same question. The
|
|
||||||
selection survives navigating away — that is what makes the strip a way back to a terminal —
|
|
||||||
so a tab lit while preferences filled the window would be a second "you are here" mark
|
|
||||||
pointing at something nobody can see. See TerminalTabViewModel.IsShowing.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat tab"
|
|
||||||
Classes.active="{Binding IsShowing}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
|
||||||
CommandParameter="{Binding}"
|
|
||||||
PointerPressed="OnTabPointerPressed"
|
|
||||||
ToolTip.Tip="{Binding Address}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
|
||||||
<!--
|
|
||||||
Green while the shell behind this tab is running, grey while it is connecting and once
|
|
||||||
it has ended. The pane keeps its scrollback either way, which is usually why somebody is
|
|
||||||
still looking at a tab whose dot has gone out.
|
|
||||||
-->
|
|
||||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
What a tab with no pane has to say for itself: "connecting…" while the handshake runs,
|
|
||||||
and the refusal once one has failed. It is here rather than only on the card because the
|
|
||||||
whole point of not blocking the window is that the user is somewhere else — the strip is
|
|
||||||
the one piece of chrome that is on screen wherever that is.
|
|
||||||
-->
|
|
||||||
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="10.5"
|
|
||||||
MaxWidth="180" TextTrimming="CharacterEllipsis"
|
|
||||||
Foreground="{StaticResource TextFaint}"
|
|
||||||
IsVisible="{Binding !HasSession}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Always drawn, never on hover only. The strip has no other close affordance, and one
|
|
||||||
that appears when the pointer is already over the tab cannot be found by somebody
|
|
||||||
looking for it.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
|
||||||
CommandParameter="{Binding}"
|
|
||||||
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
|
||||||
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
|
|
||||||
honestly, and there is one way to start a connection rather than two that have to agree.
|
|
||||||
|
|
||||||
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
|
|
||||||
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
|
|
||||||
dropping into it composites above a native child window depends on whether Avalonia gives it its
|
|
||||||
own platform window. docs/platform-flags.md records what this project already paid for treating a
|
|
||||||
rendering claim as settled without a screenshot. The palette has no such question — opening it
|
|
||||||
collapses the terminal outright.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat tab plus" Width="30"
|
|
||||||
Command="{Binding ToggleSearchCommand}"
|
|
||||||
ToolTip.Tip="Open a connection · Ctrl+K">
|
|
||||||
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Nothing open, and this is where that is said. It is also the only place near the terminal that can
|
|
||||||
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
|
|
||||||
in it is drawn underneath.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="mono" FontSize="10.5"
|
|
||||||
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
|
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
IsVisible="{Binding !HasTabs}" />
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.Primitives;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
|
||||||
|
|
||||||
/// <summary>The tab strip, above every screen.</summary>
|
|
||||||
internal sealed partial class TerminalTabs : UserControl
|
|
||||||
{
|
|
||||||
public TerminalTabs() => InitializeComponent();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Closes a tab on a middle click.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
|
|
||||||
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
|
|
||||||
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
|
|
||||||
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
|
|
||||||
/// and for every press during a middle drag. The question here is which button caused this press, and
|
|
||||||
/// that is the one thing only <c>PointerUpdateKind</c> answers.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// On press rather than on release, which is what every browser and every terminal does. Matching a
|
|
||||||
/// release to its press would need capture tracking, to buy the ability to change your mind about a
|
|
||||||
/// middle click — a gesture nobody makes by accident and nobody aborts.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
|
||||||
|| DataContext is not MainWindowViewModel shell)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
|
||||||
is not PointerUpdateKind.MiddleButtonPressed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
|
|
||||||
e.Handled = true;
|
|
||||||
|
|
||||||
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
|
|
||||||
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
|
|
||||||
// it. Its failures are the workspace's to report, not this strip's.
|
|
||||||
shell.CloseTabCommand.Execute(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the vault menu, on the Vaults tab.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The tab is selected before the menu opens, and that order is the whole reason this is a handler
|
|
||||||
/// rather than <c>Button.Flyout</c>.</b> Selecting it puts the shell on a page, which collapses the
|
|
||||||
/// renderer — so the popup never has to drop over the WebView's native child window, and the question
|
|
||||||
/// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the
|
|
||||||
/// caret in the markup, and <c>docs/platform-flags.md</c> for what treating such a question as settled
|
|
||||||
/// has already cost this project.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at
|
|
||||||
/// Vaults is the same gesture as pressing the tab, with a menu on the end.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private void OnVaultMenuPressed(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel shell || sender is not Control caret)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.ShowVaultsCommand.Execute(null);
|
|
||||||
|
|
||||||
FlyoutBase.ShowAttachedFlyout(caret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Leaves for the teams screen with the new-vault form open, shutting the menu behind it.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The menu is closed first, because the command navigates and a flyout left open would be hanging over
|
|
||||||
/// a screen it has nothing to do with. A <c>Flyout</c> does not close when something inside it is
|
|
||||||
/// pressed — which is what the switches above it want, and not what this wants.
|
|
||||||
/// </remarks>
|
|
||||||
private void OnNewVaultPressed(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel shell)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.FindControl<Button>("VaultMenu") is { } caret)
|
|
||||||
{
|
|
||||||
FlyoutBase.GetAttachedFlyout(caret)?.Hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.ShowNewVaultCommand.Execute(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,86 +9,83 @@
|
|||||||
|
|
||||||
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
|
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
|
||||||
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
|
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
|
||||||
is that a 38-pixel grey system bar above a near-black application is the one part of the window that
|
is that a 53-pixel grey system bar above a near-black application is the one part of the window that
|
||||||
would look borrowed.
|
would look borrowed.
|
||||||
|
|
||||||
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
|
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
|
||||||
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
|
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
|
||||||
overlapping it would be painted underneath and its close button would not be clickable.
|
overlapping it would be painted underneath and its close button would not be clickable.
|
||||||
|
|
||||||
|
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Redrawn against TitleBar.dc.html rather than the v3 mock this replaced, and three things left with the
|
||||||
|
redraw. The vault chip and the account name are gone from here — both now live on the rail's user chip,
|
||||||
|
which is where the design's own "who is signed in" lives too, one destination lower than the window's
|
||||||
|
own name. 44 pixels became 53, which is the design's own height and not a number this file chose; see
|
||||||
|
<c>LayoutHarness.TitleBarHeight</c> and the matching 9-pixel rise in <c>MainWindow.axaml</c>'s own
|
||||||
|
<c>MinHeight</c>, which is what keeps every screen the exact height it was designed against despite the
|
||||||
|
bar above it growing.
|
||||||
|
|
||||||
|
The design's kbd chip reads ⌘K; this one reads CTRL K, because a Windows build is not where the ⌘ key
|
||||||
|
lives — the same substitution the search box's own tooltip already made before this pass touched it.
|
||||||
|
SYNCED stays, on the right, past the window buttons' own left edge: a documented deviation from a design
|
||||||
|
whose titlebar has no home for it at all — see design-notes/v5b-fidelity-notes.md.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="44" Background="{StaticResource Chrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0,10,0">
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
<!--
|
<!--
|
||||||
Filled rather than outlined since v2, and rounded. The same mark the phone's header draws and the
|
24 pixels and radius 7, the design's own tile — up from v3's 20/6, which was this bar's own
|
||||||
same one the launcher icon carries, so the three cannot drift.
|
approximation before there was a mock to measure against. Filled rather than outlined, and the
|
||||||
|
same mark the phone's header draws and the launcher icon carries, so the three cannot drift.
|
||||||
-->
|
-->
|
||||||
<Border Width="20" Height="20" CornerRadius="6" Background="{StaticResource Accent}">
|
<Border Width="24" Height="24" CornerRadius="7" Background="{StaticResource Accent}">
|
||||||
<TextBlock Classes="mono" Text=">_" FontSize="10" FontWeight="Bold"
|
<TextBlock Classes="mono" Text=">_" FontSize="10" FontWeight="Bold"
|
||||||
Foreground="{StaticResource AccentInk}"
|
Foreground="{StaticResource AccentInk}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<!--
|
<!--
|
||||||
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
19 bold at -0.2 tracking, the design's own numbers. Named, because a nightly says so here — see
|
||||||
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
the code-behind, where the release build is exactly what this markup says and nothing changes
|
||||||
|
for it.
|
||||||
-->
|
-->
|
||||||
<!--
|
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="19" FontWeight="Bold"
|
||||||
Named, because a nightly says so here. See the code-behind: the release build is what this
|
LetterSpacing="-0.2" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
markup says and nothing changes for it.
|
|
||||||
-->
|
|
||||||
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
|
||||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
|
||||||
<!--
|
|
||||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
|
||||||
server has team tables and no endpoint that reads them, so the only name this application can
|
|
||||||
truthfully print is the one on the vault it has open. The account is beside it because a machine
|
|
||||||
can be enrolled to one account at a time and knowing which is the point of the chip.
|
|
||||||
-->
|
|
||||||
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
|
|
||||||
<TextBlock Text="{Binding Vault.VaultName}" />
|
|
||||||
</Border>
|
|
||||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11"
|
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
|
||||||
TextTrimming="CharacterEllipsis" MaxWidth="220" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
|
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
|
||||||
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
|
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
|
||||||
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
|
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
|
||||||
|
|
||||||
|
514 wide and centred, which is what the design states for this box specifically — MaxWidth rather
|
||||||
|
than Width, so the button is free to shrink at the window's minimum instead of arranging outside its
|
||||||
|
own parent when there is no room for all 514 of it.
|
||||||
-->
|
-->
|
||||||
<!--
|
<Button Grid.Column="1" Classes="flat search" MaxWidth="514" Height="35"
|
||||||
Stretch-to-a-maximum, not a fixed width. The design draws this box at exactly 380 and centred, and
|
|
||||||
stating that as a Width on the Border is what makes it wrong: the Button around it is free to
|
|
||||||
shrink when the account name or the vault chip beside it is long, and a Border that will not shrink
|
|
||||||
with it arranges outside its own parent — over the name on one side and over the window buttons on
|
|
||||||
the other. MaxWidth on the stretching button gives the same 380 whenever there is room and gives
|
|
||||||
way when there is not.
|
|
||||||
-->
|
|
||||||
<Button Grid.Column="1" Classes="flat" MaxWidth="380" Height="28" Margin="16,0"
|
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
|
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
|
||||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
<Border Classes="searchpill" CornerRadius="10" Padding="16,0,8,0">
|
||||||
BorderThickness="1" CornerRadius="8" Padding="10,0">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" Text=""
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
FontSize="15" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
<!--
|
<!--
|
||||||
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
|
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
|
||||||
wider promise of running a command — a snippet is inserted from its own screen, and a box that
|
wider promise of running a command — a snippet is inserted from its own screen, and a box that
|
||||||
offered to run one would be offering something this palette does not do.
|
offered to run one would be offering something this palette does not do.
|
||||||
-->
|
-->
|
||||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13" Margin="8,0"
|
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
|
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
|
||||||
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
|
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
|
||||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
|
Background="{StaticResource KbdChip}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -112,20 +109,28 @@
|
|||||||
<Border Width="1" Height="16" Background="{StaticResource Border}"
|
<Border Width="1" Height="16" Background="{StaticResource Border}"
|
||||||
IsVisible="{Binding IsUnlocked}" />
|
IsVisible="{Binding IsUnlocked}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The window controls, restyled to the design's own quiet Material glyphs: remove, crop_square,
|
||||||
|
close, rather than the dashes and box this bar drew before there was a mock to measure them
|
||||||
|
against. What each does is unchanged — see the code-behind.
|
||||||
|
-->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="2">
|
<StackPanel Orientation="Horizontal" Spacing="2">
|
||||||
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||||
ToolTip.Tip="Minimise">
|
ToolTip.Tip="Minimise">
|
||||||
<TextBlock Text="–" FontSize="13" Foreground="{StaticResource TextDim}"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
||||||
ToolTip.Tip="Maximise">
|
ToolTip.Tip="Maximise">
|
||||||
<TextBlock Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
||||||
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
|
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
|
||||||
<TextBlock Text="✕" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -35,40 +35,71 @@
|
|||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<!--
|
<!--
|
||||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
A directory used to be marked by colour alone — this application shipped no icon set when that rule
|
||||||
described as deliberately rare. This is the one place it is spent.
|
was written. It ships one now: v5b embeds Material Icons as IconFont, already spent on the nav rail,
|
||||||
|
the drawer and the session shell's own sidebar, and per SFTP.dc.html a file row draws a 14px glyph of
|
||||||
|
its own — folder for a directory, and the closest classic Material Icons has to the design's own
|
||||||
|
Material-Symbols-only "draft": insert_drive_file, a plain document rather than a page with a folded
|
||||||
|
corner. Recorded as a deviation rather than silently swapped, because "draft" is simply not a glyph
|
||||||
|
this font contains.
|
||||||
|
|
||||||
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
|
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
|
||||||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
|
||||||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
|
||||||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
screen no longer borrows it.
|
||||||
world-writable executable show both facts instead of one winning an argument.
|
|
||||||
|
|
||||||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
A second colour still comes from the mode, and it still overrides the first rather than competing with
|
||||||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
|
||||||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
the row beside it is asking for the folder colours or the file ones. PERMS keeps its own, separate
|
||||||
|
amber for a mode anyone may write to, on the same reasoning as before: NAME says what a row is, PERMS
|
||||||
|
says what is notable about how it is set, and the two never compete for one TextBlock.
|
||||||
-->
|
-->
|
||||||
<Style Selector="TextBlock.entry">
|
<Style Selector="TextBlock.entry">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.entry.dir">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
</Style>
|
</Style>
|
||||||
<!--
|
<!--
|
||||||
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
|
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
|
||||||
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
|
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
|
||||||
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
|
execute bit off a file's own mode and nothing else's.
|
||||||
hue two steps apart, which is a shade rather than a distinction.
|
|
||||||
|
|
||||||
So an executable is Live green now. It is the one use of that colour that is not about a session, and
|
|
||||||
it earns it on the same grounds — it is a fact about the file rather than something to press, and it
|
|
||||||
is the colour this marker already was before the accent moved out from under it.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="TextBlock.entry.dir">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.entry.exec">
|
<Style Selector="TextBlock.entry.exec">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The glyph beside NAME. One quiet colour regardless of folder or file — the design's own renderVals
|
||||||
|
gives both the same default iconFg — because the glyph itself already says which one a row is; the
|
||||||
|
colour underneath it is furniture, on the same TextFaint-family step TextBlock.fieldglyph already uses
|
||||||
|
for a mark meant to be skipped rather than read.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.entryicon">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
SIZE and MODIFIED, both quieter than the design's own literal values ask for — rgb(93,95,116) and
|
||||||
|
rgb(110,112,137) sit a step below every text colour this palette already names. Per
|
||||||
|
design-notes/v5b-fidelity-notes.md's own instruction for values this close, the nearest existing steps
|
||||||
|
are reused rather than two new keys added for a difference nobody would see: TextGhost for SIZE, which
|
||||||
|
the design draws a shade darker than MODIFIED, and TextFaint — one step lighter — for MODIFIED, which
|
||||||
|
keeps the two in the same order the design puts them in even though neither is the design's exact hex.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.entrysize">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.entrydate">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
||||||
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
||||||
@@ -84,6 +115,40 @@
|
|||||||
<Style Selector="TextBlock.perms.loose">
|
<Style Selector="TextBlock.perms.loose">
|
||||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b: the TRANSFERS strip's own progress track ────────────────────────────────────────────────────
|
||||||
|
5px, 3px radius, Chip's own fill under an AccentGradient indicator — the design's own slim bar. A real
|
||||||
|
0% is what a queued transfer draws with this: the indicator's width is nothing, which reads exactly as
|
||||||
|
the design's own queued sample row does (a bar the same colour as its own track). No indeterminate
|
||||||
|
animation is drawn for it — a queued transfer really is at zero, so zero is the honest thing to show,
|
||||||
|
not a state this screen has to invent furniture for.
|
||||||
|
-->
|
||||||
|
<Style Selector="ProgressBar.transferbar">
|
||||||
|
<Setter Property="Height" Value="5" />
|
||||||
|
<Setter Property="MinHeight" Value="5" />
|
||||||
|
<Setter Property="CornerRadius" Value="3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentGradient}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The strip's own right-aligned status word. Quiet by default — a stopped transfer's "stopped" gets no
|
||||||
|
rule of its own and falls through to this — and three states get their own colour: in motion, kept, and
|
||||||
|
refused. See TransferRowViewModel.StatusWord for what word each state actually prints.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.transferstatus">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.transferstatus.running">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.transferstatus.done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.transferstatus.failed">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid RowDefinitions="*,Auto">
|
<Grid RowDefinitions="*,Auto">
|
||||||
@@ -99,14 +164,34 @@
|
|||||||
-->
|
-->
|
||||||
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
<!--
|
||||||
BorderThickness="0,0,0,1">
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Per SFTP.dc.html: a 10px tracked-out label beside the current path in mono 13.5, and the pane's
|
||||||
|
affordances restyled to quiet glyphs rather than dropped. "THIS MACHINE" becomes the design's own
|
||||||
|
"LOCAL" — nothing in this file's own comments ever argued for that particular wording over the
|
||||||
|
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||||
|
recorded where it is drawn.
|
||||||
|
|
||||||
|
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
||||||
|
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
||||||
|
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||||
|
faithfully carried over rather than a functional difference invented to justify it.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||||
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding LocalPath}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||||
<!--
|
<!--
|
||||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
|
||||||
|
rather than the pane's own quiet icon buttons, because a drive is named rather than a single
|
||||||
|
glyph — "C:" has no honest Material Icons equivalent.
|
||||||
-->
|
-->
|
||||||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
@@ -116,14 +201,20 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||||
<Button Classes="ghost" Content="{Binding Name}"
|
<Button Classes="panechip" Content="{Binding Name}"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||||
CommandParameter="{Binding Path}" />
|
CommandParameter="{Binding Path}" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
|
||||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
ToolTip.Tip="Up one directory">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon" Command="{Binding RefreshLocalCommand}"
|
||||||
|
ToolTip.Tip="Refresh">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -150,25 +241,37 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
<!--
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
glyph centred with no reserved width drifts as the font's own advance width varies between folder
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
and insert_drive_file. 60/56 for SIZE/MODIFIED are narrower than the design's literal 80/78 — see
|
||||||
|
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||||
|
design's own 1920-pixel canvas.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
<!--
|
||||||
|
h38, radius 7, Track for hover and selected — see ListBox.filerows in App.axaml — and no rowmark:
|
||||||
|
the design has nothing to distinguish "selected" from "hovered" beyond which one is currently true,
|
||||||
|
and :selected alone already carries that once the pointer moves on.
|
||||||
|
-->
|
||||||
|
<ListBox Grid.Row="3" x:Name="LocalList" Classes="filerows" ItemsSource="{Binding LocalEntries}"
|
||||||
SelectedItem="{Binding SelectedLocalEntry}">
|
SelectedItem="{Binding SelectedLocalEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
||||||
<Border Grid.Column="0" Classes="rowmark" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Text="{Binding Name}" FontSize="12"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
@@ -219,26 +322,41 @@
|
|||||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||||
DragDrop.AllowDrop="True">
|
DragDrop.AllowDrop="True">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
<!--
|
||||||
BorderThickness="0,0,0,1">
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
HOST/BUCKET keep their own words rather than adopting the design's generic "REMOTE": the comment
|
||||||
|
this replaced already carried a documented honesty reason — the pair is the difference between a
|
||||||
|
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||||
|
the style moves to the design's 10px tracked label, not the wording.
|
||||||
|
|
||||||
|
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||||
|
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<!--
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
Which kind of remote this pane is for, in the place the local pane names itself. It is the
|
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||||
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
|
IsVisible="{Binding ShowsHostPicker}" />
|
||||||
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
|
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||||
difference between a directory tree and a flat namespace with inferred folders in it.
|
IsVisible="{Binding ShowsBucketPicker}" />
|
||||||
-->
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding ShowsHostPicker}" />
|
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
|
ToolTip.Tip="{Binding RemotePath}" />
|
||||||
IsVisible="{Binding ShowsBucketPicker}" />
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
IsEnabled="{Binding IsConnected}" />
|
</Button>
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||||
IsEnabled="{Binding CanDeleteRemote}" />
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||||
|
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -246,27 +364,22 @@
|
|||||||
<!--
|
<!--
|
||||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||||||
|
|
||||||
A row of its own rather than three more cells in the header above, and the reason is arithmetic
|
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
|
||||||
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
|
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
||||||
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
|
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||||
off the edge. The layout suite would have caught it — which is the point of stating the number
|
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||||
here, so the next thing added to either row is measured against it rather than tried.
|
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||||
|
170-pixel chip this row used to carry would have taken most of that on its own.
|
||||||
|
|
||||||
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
|
The status line still is not here, for the reason the comment this replaced gave: there is no room
|
||||||
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
|
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
|
||||||
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
|
the queue's own strip.
|
||||||
queue's own strip, which has the width for one.
|
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
|
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
|
||||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||||
IsVisible="{Binding IsConnected}">
|
IsVisible="{Binding IsConnected}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
|
||||||
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
|
Command="{Binding DisconnectCommand}" />
|
||||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
|
||||||
</Border>
|
|
||||||
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
|
|
||||||
Command="{Binding DisconnectCommand}" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -277,6 +390,11 @@
|
|||||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||||
host is bytes with nothing behind them.
|
host is bytes with nothing behind them.
|
||||||
|
|
||||||
|
A WrapPanel for the two buttons rather than a horizontal StackPanel — v5b's own change, made once
|
||||||
|
the session shell's sidebar could take this pane down to 204 pixels: "DELETE ON THE HOST" and
|
||||||
|
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
||||||
|
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||||
@@ -289,11 +407,12 @@
|
|||||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<WrapPanel>
|
||||||
<Button Classes="danger" Content="DELETE ON THE HOST"
|
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
|
||||||
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||||
</StackPanel>
|
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -324,37 +443,48 @@
|
|||||||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||||||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||||||
"that name is already taken" is a dead end.
|
"that name is already taken" is a dead end.
|
||||||
|
|
||||||
|
v5b narrows the box from 140 to 90: at the session shell's own 204-pixel width once QUICK ACCESS
|
||||||
|
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||||
|
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||||
|
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||||
-->
|
-->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||||
IsVisible="{Binding IsConnected}">
|
IsVisible="{Binding IsConnected}">
|
||||||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
<!--
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
|
-->
|
||||||
|
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
||||||
<Border Grid.Column="0" Classes="rowmark" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Classes.exec="{Binding IsExecutable}"
|
Classes.exec="{Binding IsExecutable}"
|
||||||
Text="{Binding Name}" FontSize="12"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||||
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
|
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
@@ -392,10 +522,11 @@
|
|||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
|
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
|
||||||
application ships no icon set — see the note on colour at the top of this file — so a glyph in
|
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
|
||||||
a rounded square is what an icon is here, and ▤ is already the one that means "a place things
|
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
|
||||||
are kept".
|
pane headers, the rows and the TRANSFERS strip and not this card — and ▤ already means "a place
|
||||||
|
things are kept" without borrowing from a font built for a different visual language.
|
||||||
-->
|
-->
|
||||||
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
|
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
|
||||||
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||||
@@ -545,94 +676,126 @@
|
|||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ The queue ============ -->
|
<!--
|
||||||
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
============ The TRANSFERS strip ============
|
||||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
<Grid RowDefinitions="Auto,*">
|
DeepChrome and a top border, per SFTP.dc.html, in place of the plain Sidebar fill this used to carry —
|
||||||
|
the same surface the session shell's own header and status bar are painted in, which is what fuses the
|
||||||
|
strip into that frame rather than leaving it read as another pane. Two Borders share this row and
|
||||||
|
exactly one is ever visible: the design's own header — TRANSFERS plus a count chip — with the queue
|
||||||
|
underneath while there is one, and a single quiet line otherwise. That is the "collapse" wave C's own
|
||||||
|
brief asks for: not zero height, because the honest sentence that used to live inside the full strip
|
||||||
|
still has somewhere to be read, but far short of the full header-plus-rows shape.
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="12,7">
|
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
|
||||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
|
be exactly the fabricated fact this project's honesty rule forbids. Recorded as a deviation rather than
|
||||||
|
silently dropped.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,1,0,0" MaxHeight="196" IsVisible="{Binding HasTransfers}">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16,12">
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||||
|
<!--
|
||||||
|
h18, radius 5, Chip fill with no border — the design's own count chip, distinct from Border.chip's
|
||||||
|
usual bordered-and-unfilled shape elsewhere in this application. ActiveTransfersLabel is
|
||||||
|
"N active", off TransfersViewModel.ActiveTransfers: queued counts as active there for the reason
|
||||||
|
its own remark gives, which matches what this chip is naming.
|
||||||
|
-->
|
||||||
|
<Border Grid.Column="1" Height="18" CornerRadius="5" Background="{StaticResource Chip}"
|
||||||
|
Padding="8,0" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="10.5" FontWeight="Medium"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
Text="{Binding ActiveTransfersLabel}" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
|
◆ THE STATUS LINE, in the state where the remote pane has no room for one — unchanged in meaning
|
||||||
|
from the strip this replaced, only in paint. Right-aligned beside the queue's own "one at a time"
|
||||||
|
policy sentence, which stays for the reason it always did: the queue refuses to overwrite, and
|
||||||
|
nothing else on this screen says so.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,10">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="10.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Margin="16,0,0,0"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
|
||||||
|
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
It is here rather than beside DISCONNECT because this row spans the window and that one spans
|
<ScrollViewer Grid.Row="2">
|
||||||
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
|
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||||
width. Only while something is open — the other half of the time it is inside the invitation
|
<ItemsControl.ItemTemplate>
|
||||||
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||||
|
<!--
|
||||||
|
Per SFTP.dc.html: a 320-pixel ellipsized "source → destination" label, a slim progress track
|
||||||
|
and a right-aligned status word — see TransferRowViewModel.Label/StatusWord. STOP, RETRY/
|
||||||
|
RESUME and DISCARD are the app's own, kept per wave C's brief rather than dropped for having
|
||||||
|
no equivalent in the design's static mock: icon-only now, restyled to the pane headers' own
|
||||||
|
quiet-glyph idiom, with a tooltip carrying the word the button used to print.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="320,*,64,Auto" Margin="0,5" ColumnSpacing="14">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
Right-aligned in a free column, so it reads as this row's other end rather than as a third
|
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
|
||||||
clause of the sentence to its left.
|
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||||
-->
|
ToolTip.Tip="{Binding Progress}" />
|
||||||
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
|
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
||||||
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
|
|
||||||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<TextBlock Grid.Column="2" Classes="mono transferstatus"
|
||||||
<StackPanel>
|
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
|
||||||
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
|
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
|
||||||
IsVisible="{Binding !HasTransfers}"
|
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
|
||||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
|
||||||
<ItemsControl.ItemTemplate>
|
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
|
||||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
ToolTip.Tip="Stop this transfer"
|
||||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
|
CommandParameter="{Binding}">
|
||||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
</Button>
|
||||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
|
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
ToolTip.Tip="{Binding RetryLabel}"
|
||||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
|
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||||
Foreground="{StaticResource TextFaint}"
|
CommandParameter="{Binding}">
|
||||||
TextTrimming="CharacterEllipsis" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||||
</StackPanel>
|
</Button>
|
||||||
|
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
|
||||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
ToolTip.Tip="Discard this row"
|
||||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||||
Foreground="{StaticResource Accent}"
|
CommandParameter="{Binding}">
|
||||||
Background="{StaticResource Raised}" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||||
|
</Button>
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
|
</StackPanel>
|
||||||
Margin="10,0" VerticalAlignment="Center"
|
</Grid>
|
||||||
TextTrimming="CharacterEllipsis"
|
</DataTemplate>
|
||||||
Foreground="{StaticResource TextDim}" />
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
|
||||||
<Border Classes="chip">
|
|
||||||
<TextBlock Text="{Binding StateLabel}" />
|
|
||||||
</Border>
|
|
||||||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
|
||||||
CommandParameter="{Binding}" />
|
|
||||||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
|
||||||
CommandParameter="{Binding}" />
|
|
||||||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
|
||||||
CommandParameter="{Binding}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
|
||||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
|
||||||
IsVisible="{Binding HasTransfers}" />
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
||||||
|
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The collapsed shape: no header, no count chip, no rows — just the honest sentence the full strip
|
||||||
|
carried before anything was ever queued, kept per wave C's own brief rather than dropped along with the
|
||||||
|
rest of the strip's furniture.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,1,0,0" Padding="16,12" IsVisible="{Binding !HasTransfers}">
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||||||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry
|
|||||||
_ => "changed",
|
_ => "changed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
|
||||||
|
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
|
||||||
|
|
||||||
|
/// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
|
||||||
|
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
@@ -192,6 +198,24 @@ internal sealed partial class LogsViewModel : ObservableObject
|
|||||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||||
+ "names of the fields that changed, never their contents.";
|
+ "names of the fields that changed, never their contents.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
|
||||||
|
/// actually records.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
|
||||||
|
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
|
||||||
|
/// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
|
||||||
|
/// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
|
||||||
|
/// error over this fact when there is one to show.
|
||||||
|
/// </remarks>
|
||||||
|
internal string SectionFact => Section is LogSection.Connections
|
||||||
|
? "an entry is written once, when a connection closes"
|
||||||
|
: "one row per write, per device";
|
||||||
|
|
||||||
|
/// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
|
||||||
|
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
|
||||||
|
|
||||||
/// <summary>Shows one of the two logs.</summary>
|
/// <summary>Shows one of the two logs.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowSection(LogSection section) => Section = section;
|
private void ShowSection(LogSection section) => Section = section;
|
||||||
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(ShowsConnections));
|
OnPropertyChanged(nameof(ShowsConnections));
|
||||||
OnPropertyChanged(nameof(ShowsActivity));
|
OnPropertyChanged(nameof(ShowsActivity));
|
||||||
OnPropertyChanged(nameof(EmptyMessage));
|
OnPropertyChanged(nameof(EmptyMessage));
|
||||||
|
OnPropertyChanged(nameof(SectionFact));
|
||||||
|
OnPropertyChanged(nameof(HeaderStatusLine));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that is open right now.</summary>
|
/// <summary>A connection that is open right now.</summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
|
|||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
|
/// <summary>One vault, as a switch in the rail's user popover.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
|
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
|
||||||
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
|
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
|
||||||
/// settable property would be a second copy of a fact the cache already holds.
|
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
|
||||||
|
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
|
||||||
|
/// of a fact the cache already holds.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="VaultId">The vault.</param>
|
/// <param name="VaultId">The vault.</param>
|
||||||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||||||
@@ -37,6 +40,14 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
|
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
|
||||||
|
|
||||||
|
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
|
||||||
|
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
|
||||||
|
/// letter is the honest half of the same badge.
|
||||||
|
/// </remarks>
|
||||||
|
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
|
||||||
|
|
||||||
/// <summary>Whether this vault can be switched off.</summary>
|
/// <summary>Whether this vault can be switched off.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
|
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
|
||||||
@@ -328,6 +339,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
|
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
|
||||||
private CancellationTokenSource? lastConnectedTick;
|
private CancellationTokenSource? lastConnectedTick;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
|
||||||
|
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
|
||||||
|
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
|
||||||
|
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
|
||||||
|
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
|
||||||
|
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
|
||||||
|
/// case nothing else does: sitting still on a connected screen while a minute passes.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly CancellationTokenSource sessionElapsedTick = new();
|
||||||
|
|
||||||
private IVaultServer? connection;
|
private IVaultServer? connection;
|
||||||
|
|
||||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||||
@@ -449,6 +474,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
||||||
|
|
||||||
_ = TellRendererTheFontSizeAsync();
|
_ = TellRendererTheFontSizeAsync();
|
||||||
|
|
||||||
|
StartSessionShellTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
|
||||||
|
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
|
||||||
|
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
|
||||||
|
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
|
||||||
|
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
|
||||||
|
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
|
||||||
|
/// </remarks>
|
||||||
|
private void StartSessionShellTracking()
|
||||||
|
{
|
||||||
|
transfers.PropertyChanged += OnTransfersPropertyChanged;
|
||||||
|
|
||||||
|
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? accountName;
|
private string? accountName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
|
||||||
|
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
|
||||||
|
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
|
||||||
|
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
|
||||||
|
/// never invented from the subject or the display name the way a naive fallback would.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? email;
|
||||||
|
|
||||||
|
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
|
||||||
|
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
|
||||||
|
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
|
||||||
|
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
|
||||||
|
/// mock's own two-letter shape.
|
||||||
|
/// </remarks>
|
||||||
|
internal string AvatarInitials
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AccountName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = AccountName.Split(
|
||||||
|
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
return words switch
|
||||||
|
{
|
||||||
|
[] => string.Empty,
|
||||||
|
[var only] => only[..1].ToUpperInvariant(),
|
||||||
|
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
|
||||||
|
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
|
||||||
|
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
|
||||||
|
/// did not.
|
||||||
|
/// </remarks>
|
||||||
|
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
|
||||||
|
{
|
||||||
|
AccountName = displayName ?? emailAddress ?? subject;
|
||||||
|
Email = emailAddress;
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private VaultViewModel? vault;
|
private VaultViewModel? vault;
|
||||||
|
|
||||||
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
vault.ManualStatus = string.Empty;
|
vault.ManualStatus = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- The desktop's fixed tabs ----
|
// ---- The rail's own page grouping ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
|
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
|
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
|
||||||
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
|
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
|
||||||
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
|
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
|
||||||
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
|
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
|
||||||
/// which is the screen you are looking at.
|
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
|
||||||
|
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
|
||||||
|
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
|
||||||
|
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
|
||||||
|
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
|
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
|
||||||
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
|
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
|
||||||
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
|
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
|
||||||
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
|
|
||||||
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
|
|
||||||
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> The
|
|
||||||
/// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
|
|
||||||
/// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
|
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
|
||||||
|
|
||||||
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
|
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
|
||||||
private static bool IsVaultsPage(ShellScreen screen) =>
|
private static bool IsVaultsPage(ShellScreen screen) =>
|
||||||
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
||||||
|
|
||||||
/// <summary>
|
// ---- The rail's segmented switcher and its mode-dependent first entry ----
|
||||||
/// Which page the Vaults tab returns to.
|
//
|
||||||
/// </summary>
|
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
|
||||||
/// <remarks>
|
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
|
||||||
/// <para>
|
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
|
||||||
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
|
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
|
||||||
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
|
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
|
||||||
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
|
|
||||||
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
|
|
||||||
/// screen.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
|
|
||||||
/// worth stating because the two look alike. That one would have been a second copy of "which page",
|
|
||||||
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
|
|
||||||
/// remembering its page, the way any tab does — and nothing else reads it.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private ShellScreen vaultsScreen = ShellScreen.Hosts;
|
|
||||||
|
|
||||||
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
|
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
|
||||||
|
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
|
||||||
|
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
|
||||||
|
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
|
||||||
|
/// used to treat both as "not SFTP, not S3".
|
||||||
|
/// </remarks>
|
||||||
|
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
|
||||||
|
|
||||||
|
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
|
||||||
|
/// straight off the same three flags the switcher above lights, so the row and the segment can never
|
||||||
|
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
|
||||||
|
/// for the matching glyph and the command the row runs.
|
||||||
|
/// </remarks>
|
||||||
|
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
|
||||||
|
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
|
||||||
|
/// </remarks>
|
||||||
|
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
|
||||||
|
|
||||||
|
/// <summary>Runs whichever of the three the row is currently naming.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
|
||||||
|
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
|
||||||
|
/// answer to, so asking again here would be a second place those three facts could disagree.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
private void ShowFirstRailItem()
|
||||||
|
{
|
||||||
|
if (IsBucketsShowing)
|
||||||
|
{
|
||||||
|
ShowFiles(RemoteKind.Bucket);
|
||||||
|
}
|
||||||
|
else if (IsTransfersShowing)
|
||||||
|
{
|
||||||
|
ShowFiles(RemoteKind.Host);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowTerminal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Which vaults this window is showing ----
|
// ---- Which vaults this window is showing ----
|
||||||
|
|
||||||
@@ -1299,15 +1431,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
|
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
|
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it — the
|
||||||
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
|
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
|
||||||
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
|
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
|
||||||
/// than the keyring can.
|
/// opened, so it cannot outlive the session any more than the keyring can.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private VaultVisibility? visibility;
|
private VaultVisibility? visibility;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One switch per readable vault, for the menu on the Vaults tab.
|
/// One switch per readable vault, for the rail's user popover.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
|
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
|
||||||
@@ -1779,7 +1911,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
|
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
|
||||||
ServerUrl = profile.ServerUrl;
|
ServerUrl = profile.ServerUrl;
|
||||||
State = ShellState.Locked;
|
State = ShellState.Locked;
|
||||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
||||||
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
.RefreshAsync(ServerUrl, cancellationToken)
|
.RefreshAsync(ServerUrl, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
|
||||||
StatusMessage = outcome.Message;
|
StatusMessage = outcome.Message;
|
||||||
|
|
||||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||||
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CancelSignOut() => IsConfirmingSignOut = false;
|
private void CancelSignOut() => IsConfirmingSignOut = false;
|
||||||
|
|
||||||
|
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
|
||||||
|
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
|
||||||
|
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
|
||||||
|
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
|
||||||
|
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
|
||||||
|
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
|
||||||
|
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
|
||||||
|
/// <see cref="ShowNewVault"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void SignOutFromPopover()
|
||||||
|
{
|
||||||
|
ShowScreen(ShellScreen.Preferences);
|
||||||
|
SignOut();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
LiveSessionCount = workspace.LiveSessionCount;
|
LiveSessionCount = workspace.LiveSessionCount;
|
||||||
|
|
||||||
AccountName = null;
|
AccountName = null;
|
||||||
|
Email = null;
|
||||||
Passphrase = string.Empty;
|
Passphrase = string.Empty;
|
||||||
ConfirmPassphrase = string.Empty;
|
ConfirmPassphrase = string.Empty;
|
||||||
RecoveryCode = null;
|
RecoveryCode = null;
|
||||||
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||||
|
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||||
|
|
||||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
||||||
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
|
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
|
||||||
// object would otherwise leak.
|
// object would otherwise leak.
|
||||||
StopLastConnectedTick();
|
StopLastConnectedTick();
|
||||||
|
|
||||||
|
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
|
||||||
|
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
|
||||||
|
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
|
||||||
|
sessionElapsedTick.Dispose();
|
||||||
|
|
||||||
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
|
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
|
||||||
// vault because the restart path disposes this whole object and then applies the update — so a
|
// vault because the restart path disposes this whole object and then applies the update — so a
|
||||||
// check still running would be writing into a view model the process is about to replace.
|
// check still running would be writing into a view model the process is about to replace.
|
||||||
@@ -2984,13 +3141,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
|
||||||
|
AdoptTab(adopted);
|
||||||
RefreshConnectedHosts();
|
RefreshConnectedHosts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tab.Opened(e.SessionId);
|
tab.Opened(e.SessionId);
|
||||||
|
|
||||||
|
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
||||||
|
// shell's elapsed timer is about a shell that is actually running.
|
||||||
|
tab.StartedAt = clock.GetUtcNow();
|
||||||
|
|
||||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||||
RaiseTerminalState();
|
RaiseTerminalState();
|
||||||
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
|
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||||
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
|
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
|
||||||
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
|
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
|
||||||
/// sites for the list of moments that counts as.
|
/// sites for the list of moments that counts as.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
ActiveTabPinnedPaths.Clear();
|
ActiveTabPinnedPaths.Clear();
|
||||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
RaiseSessionState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
host.IsConnected = Tabs.Any(
|
host.IsConnected = Tabs.Any(
|
||||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||||
|
|
||||||
// The active tab's host, and only when it is actually connected: the strip is for a session that
|
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||||
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||||
if (host.IsConnected
|
if (host.IsConnected
|
||||||
&& SelectedTab is { } selected
|
&& SelectedTab is { } selected
|
||||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||||
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
RaiseSessionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
|
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
|
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
|
||||||
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
|
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
|
||||||
|
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
|
||||||
|
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
|
||||||
|
/// only where they are drawn.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
|
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
|
||||||
|
|
||||||
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
|
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
|
||||||
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
|
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the pin strip draws at all.
|
/// Whether the v5b session sidebar draws at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
|
/// <para>
|
||||||
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
|
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface && HasActiveTabPinnedPaths</c>
|
||||||
/// the rare frame between selecting a terminal tab and navigating away from it.
|
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
|
||||||
|
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
|
||||||
|
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
|
||||||
|
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
|
||||||
|
/// own "hides when no session is active".
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
|
||||||
|
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
|
||||||
|
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
|
||||||
|
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
|
||||||
|
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
|
||||||
|
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
|
internal bool ShowsQuickAccessSidebar =>
|
||||||
|
(IsTerminalSurface && SelectedTab is not null)
|
||||||
|
|| (IsTransfersShowing && Transfers.IsConnected);
|
||||||
|
|
||||||
/// <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>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
|
||||||
|
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
|
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenPinnedPathAsync(string path)
|
private async Task OpenPinnedPathAsync(string path)
|
||||||
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
|
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
|
||||||
|
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
|
||||||
|
/// underneath it agrees what it is editing.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddSnippetFromSidebar()
|
||||||
|
{
|
||||||
|
ShowScreen(ShellScreen.Snippets);
|
||||||
|
SnippetsScreen?.NewCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
|
||||||
|
/// writing to the renderer directly — that command already carries the whole safety story the snippets
|
||||||
|
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
|
||||||
|
/// that runs. A second insert path here would be a second place that story could go stale.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
|
||||||
|
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
|
||||||
|
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
|
||||||
|
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
|
||||||
|
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
|
||||||
|
{
|
||||||
|
if (SnippetsScreen is not { } screen || snip is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.Selected = snip;
|
||||||
|
|
||||||
|
if (screen.CanInsert)
|
||||||
|
{
|
||||||
|
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowScreen(ShellScreen.Snippets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The closest honest affordance rather than a new one: this application has no way to open the host
|
||||||
|
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
|
||||||
|
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
|
||||||
|
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
|
||||||
|
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void PinFolderFromSidebar()
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault
|
||||||
|
|| SelectedTab is not { } tab
|
||||||
|
|| vault.Hosts.FirstOrDefault(
|
||||||
|
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowScreen(ShellScreen.Hosts);
|
||||||
|
vault.SelectedHost = host;
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
|
||||||
|
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
|
||||||
|
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
|
||||||
|
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
|
||||||
|
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
|
||||||
|
/// it and land the remote pane there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
|
||||||
|
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
|
||||||
|
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
|
||||||
|
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
|
||||||
|
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault
|
||||||
|
|| tab is null
|
||||||
|
|| vault.Hosts.FirstOrDefault(
|
||||||
|
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedTab = tab;
|
||||||
|
|
||||||
|
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
|
||||||
|
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
|
||||||
|
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
|
||||||
|
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
|
||||||
|
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
|
||||||
|
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenTerminalForFilesHostAsync()
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The account and endpoint the session shell's header and status bar are about right now, or null when
|
||||||
|
/// neither surface has one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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
|
||||||
|
/// 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"/>,
|
||||||
|
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? SessionAddress => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.Address,
|
||||||
|
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
||||||
|
/// that is still connecting has an address — it is what the connecting card names — but no live shell
|
||||||
|
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool IsSessionConnected => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.IsLive is true,
|
||||||
|
_ when IsTransfersShowing => Transfers.IsConnected,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
|
||||||
|
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
|
||||||
|
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
|
||||||
|
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
|
||||||
|
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
|
||||||
|
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? SessionElapsedText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var startedAt = Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.StartedAt,
|
||||||
|
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startedAt is not { } started)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsed = clock.GetUtcNow() - started;
|
||||||
|
|
||||||
|
if (elapsed < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
// The clock this ran on and the clock the session opened on can disagree by a hair when both
|
||||||
|
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
|
||||||
|
// rather than a session that has not started yet.
|
||||||
|
elapsed = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
|
||||||
|
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
|
||||||
|
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private void RaiseSessionState()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SessionAddress));
|
||||||
|
OnPropertyChanged(nameof(IsSessionConnected));
|
||||||
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
|
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
|
||||||
|
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
|
||||||
|
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
|
||||||
|
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
|
||||||
|
/// fact the header, the status bar or the SFTP tab row's active mark reads.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||||
|
or nameof(TransfersViewModel.IsConnected)
|
||||||
|
or nameof(TransfersViewModel.Remote)
|
||||||
|
or nameof(TransfersViewModel.ConnectedTo))
|
||||||
|
{
|
||||||
|
RaiseSessionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
|
||||||
|
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
|
||||||
|
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
|
||||||
|
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The shell is closing; see DisposeAsync.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnLiveSessionCountChanged(int value)
|
partial void OnLiveSessionCountChanged(int value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasLiveSessions));
|
OnPropertyChanged(nameof(HasLiveSessions));
|
||||||
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
RaiseSurfaceState();
|
RaiseSurfaceState();
|
||||||
|
|
||||||
// What the Vaults tab comes back to; see the field.
|
|
||||||
if (IsVaultsPage(value))
|
|
||||||
{
|
|
||||||
vaultsScreen = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||||
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RaiseSurfaceState()
|
private void RaiseSurfaceState()
|
||||||
{
|
{
|
||||||
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
|
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
|
||||||
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
|
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
|
||||||
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
|
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
|
||||||
// would answer correctly the next time anything else asked it.
|
// off a terminal tab to a page screen, even though the property itself would answer correctly the
|
||||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
// next time anything else asked it.
|
||||||
|
RaiseSessionState();
|
||||||
|
|
||||||
OnPropertyChanged(nameof(IsHostsScreen));
|
OnPropertyChanged(nameof(IsHostsScreen));
|
||||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||||
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsBucketsShowing));
|
OnPropertyChanged(nameof(IsBucketsShowing));
|
||||||
OnPropertyChanged(nameof(IsMoreSurface));
|
OnPropertyChanged(nameof(IsMoreSurface));
|
||||||
|
|
||||||
|
// The rail's switcher and its mode-dependent first row read the same three flags above, so
|
||||||
|
// whatever moved them has to repaint these too — see the remarks on each.
|
||||||
|
OnPropertyChanged(nameof(IsSshShowing));
|
||||||
|
OnPropertyChanged(nameof(FirstRailItemLabel));
|
||||||
|
OnPropertyChanged(nameof(FirstRailItemIcon));
|
||||||
|
|
||||||
RaiseTerminalState();
|
RaiseTerminalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||||
|
|
||||||
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
|
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||||
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
|
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
// page reached by clicking away from a terminal tab.
|
||||||
|
RaiseSessionState();
|
||||||
|
|
||||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||||
|
|||||||
@@ -187,11 +187,34 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
|
|
||||||
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
/// Off while the move panel or the delete confirmation is up, either of which takes their place — the
|
||||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
/// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
|
||||||
/// otherwise still be there offering to open it again.
|
/// that opened one would otherwise still be there offering to open it again.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
|
internal bool ShowsSelectionActions => HasSelection && !IsMoving && !IsConfirmingDelete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Whether the desktop's own delete confirmation card is up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>See <see cref="RequestDelete"/> for why this exists beside <see cref="DeleteAsync"/> rather than in it.</remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
|
||||||
|
private bool isConfirmingDelete;
|
||||||
|
|
||||||
|
/// <summary>Which snippet the open confirmation card is about.</summary>
|
||||||
|
private SnippetRowViewModel? pendingDelete;
|
||||||
|
|
||||||
|
/// <summary>The confirmation card's own question, naming the snippet.</summary>
|
||||||
|
internal string DeleteQuestion => pendingDelete is { } row ? $"Delete \"{row.Label}\"?" : string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The confirmation card's own consequence line — vault-wide reach, a tombstone, no undo — in the same
|
||||||
|
/// words <c>VaultViewModel.HowFarADeletionGoes</c> uses for every other kind of item.
|
||||||
|
/// </summary>
|
||||||
|
internal string DeleteConsequence => pendingDelete is { } row
|
||||||
|
? $"This snippet lives in the {row.VaultName} vault — deleting removes it for everyone who holds "
|
||||||
|
+ "that key. This writes a tombstone; there is no undo."
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -334,6 +357,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes the selected snippet.</summary>
|
/// <summary>Deletes the selected snippet.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unchanged and still reachable on its own — the phone's DELETE row still calls this directly, full
|
||||||
|
/// width and below EDIT and MOVE, with no question in front of it; see the phone's own
|
||||||
|
/// <c>SnippetsScreen.axaml</c> remarks for why a counted confirmation was never drawn there. The
|
||||||
|
/// desktop's own DELETE goes through <see cref="RequestDelete"/> instead — see that command's remarks —
|
||||||
|
/// so this one command now serves two different confirmations rather than the desktop growing a second
|
||||||
|
/// deletion path.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -347,6 +378,55 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
Status = vault.Status;
|
Status = vault.Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Asks before deleting, the way every other keychain item's DELETE already does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Additive rather than a change to <see cref="DeleteAsync"/> itself: that command is what the phone's
|
||||||
|
/// own DELETE row still calls, uncounted, and repurposing it here would leave a phone build with a
|
||||||
|
/// button that arms a card nothing on that screen ever draws — a delete that silently stopped working.
|
||||||
|
/// The desktop's own confirmation lives here instead, mirroring <c>VaultViewModel.PendingDeletion</c>'s
|
||||||
|
/// shape without touching that type: a snippet has no <see cref="DeletionTarget"/> of its own to add
|
||||||
|
/// there, and Snippets.dc.html's own modal copy — vault-wide reach, a tombstone, no undo — is exactly
|
||||||
|
/// what <c>HowFarADeletionGoes</c> already says for every other kind, said here in the same words.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void RequestDelete()
|
||||||
|
{
|
||||||
|
if (Selected is not { } row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelete = row;
|
||||||
|
IsConfirmingDelete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Carries out the deletion the confirm card just agreed to.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (pendingDelete is not { } row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConfirmingDelete = false;
|
||||||
|
pendingDelete = null;
|
||||||
|
|
||||||
|
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = vault.Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
IsConfirmingDelete = false;
|
||||||
|
pendingDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -515,6 +595,15 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
|||||||
CloseMovePanel();
|
CloseMovePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The confirmation card folds away with the selection it was opened about, the same rule the move
|
||||||
|
// panel above follows and for the same reason: a filter or a reload that moved the selection
|
||||||
|
// elsewhere would otherwise leave a "delete this?" card pointed at a row nobody is looking at.
|
||||||
|
if (IsConfirmingDelete && value?.EntityId != pendingDelete?.EntityId)
|
||||||
|
{
|
||||||
|
IsConfirmingDelete = false;
|
||||||
|
pendingDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasSelection));
|
OnPropertyChanged(nameof(HasSelection));
|
||||||
OnPropertyChanged(nameof(CanInsert));
|
OnPropertyChanged(nameof(CanInsert));
|
||||||
OnPropertyChanged(nameof(SelectionRuns));
|
OnPropertyChanged(nameof(SelectionRuns));
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
internal string Address { get; }
|
internal string Address { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is
|
||||||
|
/// none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set by <c>MainWindowViewModel</c> from its own clock at the same moment it calls <see cref="Opened"/>
|
||||||
|
/// or constructs a tab that already carries a session id, rather than read here from
|
||||||
|
/// <see cref="TimeProvider.System"/> directly — a view model with its own notion of "now" is a second
|
||||||
|
/// clock the shell's tests would have no way to control. Left on a tab whose session has since ended:
|
||||||
|
/// the pane still holds the scrollback, and "how long that shell ran" stays a true fact about it after it
|
||||||
|
/// stops being live.
|
||||||
|
/// </remarks>
|
||||||
|
internal DateTimeOffset? StartedAt { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc cref="TerminalTabState" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TerminalTabState state;
|
private TerminalTabState state;
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
|||||||
|
|
||||||
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
||||||
|
|
||||||
|
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
|
||||||
|
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
|
||||||
|
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
|
||||||
|
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
|
||||||
|
/// </remarks>
|
||||||
|
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
||||||
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
||||||
@@ -84,6 +93,9 @@ internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
|||||||
|
|
||||||
internal bool IsFile => !entry.IsDirectory;
|
internal bool IsFile => !entry.IsDirectory;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
|
||||||
|
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
|
||||||
|
|
||||||
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
||||||
|
|
||||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||||
@@ -132,8 +144,64 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
|||||||
? Transfer.RemotePath
|
? Transfer.RemotePath
|
||||||
: Transfer.LocalPath;
|
: Transfer.LocalPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
|
||||||
|
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
|
||||||
|
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
|
||||||
|
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
|
||||||
|
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
|
||||||
|
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
|
||||||
|
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
|
||||||
|
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
|
||||||
|
/// instead.
|
||||||
|
/// </remarks>
|
||||||
|
internal string Label
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
|
||||||
|
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
|
||||||
|
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
|
||||||
|
|
||||||
|
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
|
||||||
|
// translation to name the file at the end of it.
|
||||||
|
var source = System.IO.Path.GetFileName(sourcePath);
|
||||||
|
|
||||||
|
return $"{source} → {destinationDirectory}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal double Percent => Transfer.Fraction * 100;
|
internal double Percent => Transfer.Fraction * 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
|
||||||
|
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
|
||||||
|
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
|
||||||
|
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
|
||||||
|
/// the bar alone cannot state exactly.
|
||||||
|
/// </remarks>
|
||||||
|
internal string StatusWord => Transfer.State switch
|
||||||
|
{
|
||||||
|
TransferState.Queued => "queued",
|
||||||
|
TransferState.Running => string.Create(
|
||||||
|
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
|
||||||
|
TransferState.Completed => "done",
|
||||||
|
TransferState.Cancelled => "stopped",
|
||||||
|
_ => "failed",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
|
||||||
|
internal bool IsInProgress => Transfer.State is TransferState.Running;
|
||||||
|
|
||||||
|
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
|
||||||
|
internal bool IsDone => Transfer.State is TransferState.Completed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the row says about where it has got to.
|
/// What the row says about where it has got to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -192,6 +260,9 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
|||||||
OnPropertyChanged(nameof(Progress));
|
OnPropertyChanged(nameof(Progress));
|
||||||
OnPropertyChanged(nameof(Percent));
|
OnPropertyChanged(nameof(Percent));
|
||||||
OnPropertyChanged(nameof(StateLabel));
|
OnPropertyChanged(nameof(StateLabel));
|
||||||
|
OnPropertyChanged(nameof(StatusWord));
|
||||||
|
OnPropertyChanged(nameof(IsInProgress));
|
||||||
|
OnPropertyChanged(nameof(IsDone));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsFinished));
|
OnPropertyChanged(nameof(IsFinished));
|
||||||
OnPropertyChanged(nameof(CanResume));
|
OnPropertyChanged(nameof(CanResume));
|
||||||
@@ -297,6 +368,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
|
||||||
|
/// connected.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
|
||||||
|
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
|
||||||
|
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
|
||||||
|
/// the first, for no reader that could not already reach it here.
|
||||||
|
/// </remarks>
|
||||||
|
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
|
||||||
|
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
/// <param name="sftp">Opens SFTP sessions.</param>
|
/// <param name="sftp">Opens SFTP sessions.</param>
|
||||||
@@ -342,7 +425,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
// which is the life of the process.
|
// which is the life of the process.
|
||||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||||
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
||||||
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
|
Transfers.CollectionChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasTransfers));
|
||||||
|
OnPropertyChanged(nameof(ActiveTransfersLabel));
|
||||||
|
};
|
||||||
|
|
||||||
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
|
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
|
||||||
// vault on every synchronisation pass, so the invitation these flags decide between has to change
|
// vault on every synchronisation pass, so the invitation these flags decide between has to change
|
||||||
@@ -619,6 +706,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A string of its own rather than a converter in the markup, for the reason every other label in this
|
||||||
|
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
|
||||||
|
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
|
||||||
|
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
|
||||||
|
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
|
||||||
|
/// that shape rather than a chip claiming activity that has already finished.
|
||||||
|
/// </remarks>
|
||||||
|
internal string ActiveTransfersLabel => string.Create(
|
||||||
|
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
|
||||||
|
|
||||||
internal bool HasTransfers => Transfers.Count > 0;
|
internal bool HasTransfers => Transfers.Count > 0;
|
||||||
|
|
||||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||||
@@ -1653,6 +1754,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
|
||||||
|
// rows count as active without the collection itself gaining or losing a row, so the strip's own
|
||||||
|
// count chip needs its own raise here.
|
||||||
|
OnPropertyChanged(nameof(ActiveTransfersLabel));
|
||||||
|
|
||||||
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
|
|||||||
|
|
||||||
internal string Label => key.Secret.Label;
|
internal string Label => key.Secret.Label;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this row has a vault to name.</summary>
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
|
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
|
||||||
internal string Description => key.Secret switch
|
internal string Description => key.Secret switch
|
||||||
{
|
{
|
||||||
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
|
|||||||
|
|
||||||
internal string Label => credential.Secret.Label;
|
internal string Label => credential.Secret.Label;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this row has a vault to name.</summary>
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
/// <summary>What the list shows under the name: the account, never the password.</summary>
|
/// <summary>What the list shows under the name: the account, never the password.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
|
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
|
||||||
@@ -1101,8 +1119,66 @@ internal sealed record VaultItemRowViewModel(
|
|||||||
{
|
{
|
||||||
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
||||||
internal bool HasBadge => Badge.Length > 0;
|
internal bool HasBadge => Badge.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Additive: every other kind of row in this application already carries this fact (see
|
||||||
|
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
|
||||||
|
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
|
||||||
|
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
|
||||||
|
/// </remarks>
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this row has a vault to name.</summary>
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
|
||||||
|
/// nothing does or the kind has no such fact.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
|
||||||
|
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
|
||||||
|
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
|
||||||
|
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
|
||||||
|
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
|
||||||
|
/// showing a region or an endpoint under a column that promises "who uses this".
|
||||||
|
/// </remarks>
|
||||||
|
internal string UsedBySummary { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this row has anything to say about who uses it.</summary>
|
||||||
|
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
|
||||||
|
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
|
||||||
|
/// carries no algorithm field (see docs/design-import-gaps.md).
|
||||||
|
/// </remarks>
|
||||||
|
internal string IconGlyph => Kind switch
|
||||||
|
{
|
||||||
|
VaultItemKind.Key => "",
|
||||||
|
VaultItemKind.Credential => "",
|
||||||
|
VaultItemKind.Tag => "",
|
||||||
|
VaultItemKind.ObjectStore => "",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Label">The host's name.</param>
|
||||||
|
/// <param name="IsConnected">
|
||||||
|
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
|
||||||
|
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
|
||||||
|
/// </param>
|
||||||
|
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
|
||||||
|
|
||||||
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||||||
internal enum DeletionTarget
|
internal enum DeletionTarget
|
||||||
{
|
{
|
||||||
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
|
||||||
|
/// that does nothing, on the same "narrows the list and nothing else" reasoning
|
||||||
|
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
|
||||||
|
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
|
||||||
|
/// so what matches is never a surprise to whoever typed it.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string itemFilter = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The selected row of the vault table.
|
/// The selected row of the vault table.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
|
||||||
|
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
|
||||||
|
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
|
||||||
|
/// no binding concept to scan at all.
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
|
||||||
|
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
|
||||||
|
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
|
||||||
|
{
|
||||||
|
0 => string.Empty,
|
||||||
|
1 => "in use · 1 host",
|
||||||
|
var n => $"in use · {n} hosts",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether the "in use" chip has anything to say.</summary>
|
||||||
|
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
|
||||||
|
|
||||||
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
||||||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||||||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
||||||
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
var unreadable = 0;
|
var unreadable = 0;
|
||||||
var rows = new List<SshKeyRowViewModel>();
|
var rows = new List<SshKeyRowViewModel>();
|
||||||
|
|
||||||
foreach (var vault in session.ReadableVaults)
|
var readableVaults = session.ReadableVaults.ToList();
|
||||||
|
var several = readableVaults.Count > 1;
|
||||||
|
|
||||||
|
foreach (var vault in readableVaults)
|
||||||
{
|
{
|
||||||
var listing = await session.SshKeys
|
var listing = await session.SshKeys
|
||||||
.ListAsync(vault.VaultId, cancellationToken)
|
.ListAsync(vault.VaultId, cancellationToken)
|
||||||
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
unreadable += listing.Unreadable;
|
unreadable += listing.Unreadable;
|
||||||
|
|
||||||
rows.AddRange(listing.Items.Select(
|
rows.AddRange(listing.Items.Select(
|
||||||
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
|
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)
|
||||||
|
{
|
||||||
|
// Only when there is something to tell apart, as the host grid's badge is.
|
||||||
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.Clear();
|
Keys.Clear();
|
||||||
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
var unreadable = 0;
|
var unreadable = 0;
|
||||||
var rows = new List<CredentialRowViewModel>();
|
var rows = new List<CredentialRowViewModel>();
|
||||||
|
|
||||||
foreach (var vault in session.ReadableVaults)
|
var readableVaults = session.ReadableVaults.ToList();
|
||||||
|
var several = readableVaults.Count > 1;
|
||||||
|
|
||||||
|
foreach (var vault in readableVaults)
|
||||||
{
|
{
|
||||||
var listing = await session.Credentials
|
var listing = await session.Credentials
|
||||||
.ListAsync(vault.VaultId, cancellationToken)
|
.ListAsync(vault.VaultId, cancellationToken)
|
||||||
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
unreadable += listing.Unreadable;
|
unreadable += listing.Unreadable;
|
||||||
|
|
||||||
rows.AddRange(listing.Items.Select(
|
rows.AddRange(listing.Items.Select(
|
||||||
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
|
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)
|
||||||
|
{
|
||||||
|
// Only when there is something to tell apart, as the host grid's badge is.
|
||||||
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Credentials.Clear();
|
Credentials.Clear();
|
||||||
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
"TAG",
|
"TAG",
|
||||||
tag.Description,
|
tag.Description,
|
||||||
tag.Badge,
|
tag.Badge,
|
||||||
tag.HasUnsyncedChanges));
|
tag.HasUnsyncedChanges)
|
||||||
|
{
|
||||||
|
// A tag's own "used by" is the host count it already carries — the same fact
|
||||||
|
// Description prints, so the two never disagree.
|
||||||
|
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12047,6 +12180,61 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
|
||||||
|
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
|
||||||
|
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
|
||||||
|
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
|
||||||
|
/// </remarks>
|
||||||
|
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||||||
|
{
|
||||||
|
var names = Hosts
|
||||||
|
.Where(row => row.Resolved.Binding is { } binding
|
||||||
|
&& binding.Kind == kind && binding.EntityId == entityId)
|
||||||
|
.Select(row => row.Label)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return names.Count switch
|
||||||
|
{
|
||||||
|
0 => string.Empty,
|
||||||
|
1 => names[0],
|
||||||
|
2 => $"{names[0]}, {names[1]}",
|
||||||
|
_ => $"{names[0]} +{names.Count - 1}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||||||
|
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
|
||||||
|
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
|
||||||
|
/// the filter has no opinion about which kind a row is.
|
||||||
|
/// </remarks>
|
||||||
|
private void ApplyItemFilter()
|
||||||
|
{
|
||||||
|
var needle = ItemFilter.Trim();
|
||||||
|
|
||||||
|
if (needle.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = VaultItems.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var row = VaultItems[i];
|
||||||
|
|
||||||
|
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
|
||||||
|
{
|
||||||
|
VaultItems.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refills the vault table from the typed lists.
|
/// Refills the vault table from the typed lists.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
"SSH KEY",
|
"SSH KEY",
|
||||||
key.Description,
|
key.Description,
|
||||||
key.Badge,
|
key.Badge,
|
||||||
key.HasUnsyncedChanges));
|
key.HasUnsyncedChanges)
|
||||||
|
{
|
||||||
|
VaultBadge = key.VaultBadge,
|
||||||
|
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12093,12 +12285,17 @@ internal sealed partial class VaultViewModel(
|
|||||||
"PASSWORD",
|
"PASSWORD",
|
||||||
credential.Description,
|
credential.Description,
|
||||||
credential.Badge,
|
credential.Badge,
|
||||||
credential.HasUnsyncedChanges));
|
credential.HasUnsyncedChanges)
|
||||||
|
{
|
||||||
|
VaultBadge = credential.VaultBadge,
|
||||||
|
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AddTagRows();
|
AddTagRows();
|
||||||
AddBucketRows();
|
AddBucketRows();
|
||||||
|
ApplyItemFilter();
|
||||||
|
|
||||||
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
||||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||||
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||||
OnPropertyChanged(nameof(ShowsItemActions));
|
OnPropertyChanged(nameof(ShowsItemActions));
|
||||||
|
|
||||||
|
RebuildSelectedItemUsage(value);
|
||||||
|
|
||||||
// Every kind this table can delete, because one selection covers all of their lists.
|
// Every kind this table can delete, because one selection covers all of their lists.
|
||||||
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||||
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||||
@@ -12168,6 +12367,39 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
|
||||||
|
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
|
||||||
|
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
|
||||||
|
/// </remarks>
|
||||||
|
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
|
||||||
|
{
|
||||||
|
SelectedItemUsedByHosts.Clear();
|
||||||
|
|
||||||
|
ResolvedBindingKind? kind = value?.Kind switch
|
||||||
|
{
|
||||||
|
VaultItemKind.Key => ResolvedBindingKind.SshKey,
|
||||||
|
VaultItemKind.Credential => ResolvedBindingKind.Credential,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind is { } resolvedKind && value is not null)
|
||||||
|
{
|
||||||
|
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
||||||
|
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
||||||
|
{
|
||||||
|
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
|
||||||
|
OnPropertyChanged(nameof(SelectedItemInUseSummary));
|
||||||
|
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
||||||
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
||||||
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
||||||
|
|
||||||
|
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
|
||||||
|
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
|
||||||
|
|
||||||
partial void OnUnreadableItemsChanged(int value)
|
partial void OnUnreadableItemsChanged(int value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasUnreadableItems));
|
OnPropertyChanged(nameof(HasUnreadableItems));
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ internal static class LayoutHarness
|
|||||||
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
|
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
|
||||||
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
|
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
|
||||||
/// than the one a user is allowed to drag to.
|
/// than the one a user is allowed to drag to.
|
||||||
|
///
|
||||||
|
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
|
||||||
|
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
|
||||||
|
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
|
||||||
|
/// by the move, because the minimum grew by exactly what the two grew by.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double MinimumWidth = 1016;
|
internal const double MinimumWidth = 1081;
|
||||||
|
|
||||||
/// <inheritdoc cref="MinimumWidth" />
|
/// <inheritdoc cref="MinimumWidth" />
|
||||||
internal const double MinimumHeight = 574;
|
internal const double MinimumHeight = 583;
|
||||||
|
|
||||||
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
|
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -47,33 +52,95 @@ internal static class LayoutHarness
|
|||||||
internal const double HostDrawerWidth = 320;
|
internal const double HostDrawerWidth = 320;
|
||||||
|
|
||||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||||
internal const double NavRailWidth = 190;
|
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||||
|
internal const double NavRailWidth = 255;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
|
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
|
||||||
/// pixel.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
|
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
|
||||||
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
|
/// their contents, which is what makes stating them here honest. A test holds each control to its own
|
||||||
/// controls to those numbers, so the budget below cannot drift away from what the window actually
|
/// number, so the budget below cannot drift away from what the window actually leaves.
|
||||||
/// leaves.
|
///
|
||||||
|
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
|
||||||
|
/// the matching rise that keeps every screen below it the same size it always measured.
|
||||||
|
///
|
||||||
|
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
|
||||||
|
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
|
||||||
|
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
|
||||||
|
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
|
||||||
|
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
|
||||||
|
/// is now inside the two screens that carry one, at its own 38-pixel height; see
|
||||||
|
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double TitleBarHeight = 44;
|
internal const double TitleBarHeight = 53;
|
||||||
|
|
||||||
/// <inheritdoc cref="TitleBarHeight" />
|
/// <inheritdoc cref="TitleBarHeight" />
|
||||||
internal const double StatusBarHeight = 24;
|
internal const double StatusBarHeight = 24;
|
||||||
|
|
||||||
|
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
|
||||||
|
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
|
||||||
|
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
|
||||||
|
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
|
||||||
|
/// holds the titlebar and the status bar to theirs.
|
||||||
|
/// </remarks>
|
||||||
|
internal const double SessionTabRowHeight = 38;
|
||||||
|
|
||||||
|
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
|
||||||
|
internal const double SessionShellPadding = 26;
|
||||||
|
|
||||||
|
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
||||||
|
internal const double SessionStatusBarHeight = 37;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
|
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
|
||||||
|
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
|
||||||
|
/// </summary>
|
||||||
|
internal const double SessionShellBorderThickness = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
|
||||||
|
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
|
||||||
|
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
|
||||||
|
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
|
||||||
|
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
|
||||||
|
/// this is what closes it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
|
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
|
||||||
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
|
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
|
||||||
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
|
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
|
||||||
/// a fixed cost rather than a conditional one, and the budget can be a constant.
|
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
|
||||||
|
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double TerminalTabsHeight = 42;
|
internal static double SessionScreenHeight =>
|
||||||
|
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
||||||
|
- SessionHeaderHeight - SessionStatusBarHeight;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
||||||
|
/// column showing beside it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
|
||||||
|
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
|
||||||
|
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
|
||||||
|
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
|
||||||
|
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
|
||||||
|
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
|
||||||
|
/// </remarks>
|
||||||
|
internal static double SessionScreenWidth(bool sidebarVisible) =>
|
||||||
|
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
|
||||||
|
- (sidebarVisible ? SessionSidebarWidth : 0);
|
||||||
|
|
||||||
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
|
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -111,12 +178,17 @@ internal static class LayoutHarness
|
|||||||
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
|
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
|
||||||
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
||||||
|
|
||||||
/// <summary>The height a screen actually gets at the window's minimum.</summary>
|
/// <summary>
|
||||||
|
/// The height a full-bleed page screen actually gets at the window's minimum.
|
||||||
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
|
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
|
||||||
/// collapse when there are no tabs.
|
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
|
||||||
|
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
|
||||||
|
/// this constant does not describe; a test measuring either of those two screens has to account for the
|
||||||
|
/// session shell's own geometry rather than reading it off this property.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
|
internal static double ScreenHeight => ContentHeight;
|
||||||
|
|
||||||
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
||||||
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
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>
|
||||||
|
/// How the rail's own switcher, mode-dependent first row and user popover answer a pointer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// v5b moved three things onto this control that used to be tested elsewhere or not at all: the SSH/SFTP/S3
|
||||||
|
/// choice that used to be the tab strip's own fixed tabs (see <c>TerminalTabsTests</c>, which used to hold
|
||||||
|
/// the equivalent of the first two facts below), the mode-dependent first row the design calls its own
|
||||||
|
/// <c>mode</c> prop, and the popover that replaced the strip's vault menu. This suite is this control's
|
||||||
|
/// counterpart to that one — a minimal shell with two tabs and no vault, for the reason
|
||||||
|
/// <c>TerminalTabsTests</c> gives: nothing here reads <c>Vault</c> except the popover's vault switches,
|
||||||
|
/// which this suite therefore does not open — that is <c>VaultVisibilityTests</c>' business, over the
|
||||||
|
/// commands themselves, and this suite would only be re-testing the same command through a slower door.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A <c>UserControl</c> in a bare window, for the same reason the palette's and the strip's suites are one:
|
||||||
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class NavRailTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private ClientCacheFactory caches = null!;
|
||||||
|
private TerminalWorkspace workspace = null!;
|
||||||
|
private MainWindowViewModel shell = null!;
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
caches = ClientCacheFactory.ForMemory($"navrail-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
workspace = new TerminalWorkspace(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
shell = new MainWindowViewModel(
|
||||||
|
ClientPaths.Default,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The switcher's three segments select the surface they name, and light up when it is the one showing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Driven through the segments rather than through the commands directly, for the reason
|
||||||
|
/// <c>TerminalTabsTests.TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage</c> gave for its own
|
||||||
|
/// three fixed tabs: what is being checked is that three buttons in the markup are wired to three
|
||||||
|
/// different things, which three commands called by hand would not catch if two of the three were
|
||||||
|
/// bound to the same one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSwitcherSegmentsSelectTheirSurface_AndLightTheActiveOne()
|
||||||
|
{
|
||||||
|
await OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
shell.IsSshShowing.ShouldBeTrue("nothing has navigated to SFTP or S3 yet");
|
||||||
|
Segment(rail, "SSH").Classes.Contains("active").ShouldBeTrue();
|
||||||
|
|
||||||
|
Click(Segment(rail, "SFTP"), window);
|
||||||
|
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
Segment(rail, "SFTP").Classes.Contains("active").ShouldBeTrue();
|
||||||
|
Segment(rail, "SSH").Classes.Contains("active").ShouldBeFalse("exactly one segment lights at once");
|
||||||
|
|
||||||
|
Click(Segment(rail, "S3"), window);
|
||||||
|
|
||||||
|
shell.IsBucketsShowing.ShouldBeTrue();
|
||||||
|
shell.IsTransfersShowing.ShouldBeFalse();
|
||||||
|
Segment(rail, "S3").Classes.Contains("active").ShouldBeTrue();
|
||||||
|
|
||||||
|
Click(Segment(rail, "SSH"), window);
|
||||||
|
|
||||||
|
shell.IsSshShowing.ShouldBeTrue();
|
||||||
|
shell.IsBucketsShowing.ShouldBeFalse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mode-dependent first row follows the same three flags the switcher above lights.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The label is read straight off the row's own bound text rather than off <see cref="MainWindowViewModel"/>
|
||||||
|
/// state directly, because what a fidelity pass could break is the binding between the two, not the
|
||||||
|
/// property computing the right string on its own — <c>MainWindowViewModelTests</c> would already catch
|
||||||
|
/// that half.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheFirstRailItemsLabel_FollowsTheSwitchersMode()
|
||||||
|
{
|
||||||
|
await OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
FirstRow(rail).ShouldBe("Terminal");
|
||||||
|
|
||||||
|
Click(Segment(rail, "SFTP"), window);
|
||||||
|
FirstRow(rail).ShouldBe("Files");
|
||||||
|
|
||||||
|
Click(Segment(rail, "S3"), window);
|
||||||
|
FirstRow(rail).ShouldBe("Buckets");
|
||||||
|
|
||||||
|
Click(Segment(rail, "SSH"), window);
|
||||||
|
FirstRow(rail).ShouldBe("Terminal");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The user popover is a <c>Flyout</c>, and this is the same assertion
|
||||||
|
/// <c>TerminalTabsTests.TheCaretBesideVaults_OpensTheVaultMenu</c> made of the strip's own — the pointer
|
||||||
|
/// opens the popup, and nothing about its position or its content occlude anything, since it never
|
||||||
|
/// crosses into the terminal's own rectangle. See the remark in NavRail.axaml.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClickingTheUserChip_OpensThePopover()
|
||||||
|
{
|
||||||
|
await OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
var chip = UserChip(rail);
|
||||||
|
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
|
||||||
|
|
||||||
|
Click(chip, window);
|
||||||
|
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Each vault switch in the popover draws the vault's own display name and shown state, and is wired
|
||||||
|
/// to the command that toggles it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="MainWindowViewModel.VaultToggles"/> is fed directly rather than through a real sign-in
|
||||||
|
/// and a second vault created on a fake server. <c>VaultVisibilityTests</c>, in the Avalonia-free
|
||||||
|
/// <c>DodoSSH.Client.App.Tests</c> project, already proves <see cref="MainWindowViewModel.ToggleVaultCommand"/>
|
||||||
|
/// itself — that a hidden vault stays syncing, that the personal one refuses, and everything else the
|
||||||
|
/// command actually does once it runs. What is worth proving here, in the project that can lay markup
|
||||||
|
/// out at all, is only the wiring: that a row in this popover shows the right vault and calls that
|
||||||
|
/// command with that vault when pressed — the fact a fidelity pass to this file could actually break.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The command is read off the row rather than pressed, because <see cref="MainWindowViewModel.ToggleVaultCommand"/>
|
||||||
|
/// itself declines with no observable effect when <see cref="MainWindowViewModel.Vault"/> is null — which
|
||||||
|
/// it is here, for the reason above — so a press would prove nothing a reader could tell from a press
|
||||||
|
/// that reached the wrong command entirely.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task PopoverVaultRows_NameTheirVaultAndAreWiredToToggleIt()
|
||||||
|
{
|
||||||
|
var shown = new VaultToggleViewModel(Guid.CreateVersion7(), "Personal", IsPersonal: true, IsShown: true);
|
||||||
|
var hidden = new VaultToggleViewModel(Guid.CreateVersion7(), "Platform secrets", IsPersonal: false, IsShown: false);
|
||||||
|
|
||||||
|
shell.VaultToggles.Add(shown);
|
||||||
|
shell.VaultToggles.Add(hidden);
|
||||||
|
|
||||||
|
await OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
Click(UserChip(rail), window);
|
||||||
|
|
||||||
|
var shownRow = PopoverRow(window, shown);
|
||||||
|
var hiddenRow = PopoverRow(window, hidden);
|
||||||
|
|
||||||
|
shownRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
|
||||||
|
shownRow.CommandParameter.ShouldBeSameAs(shown);
|
||||||
|
HasVisibleCheck(shownRow).ShouldBeTrue("the personal vault is always shown");
|
||||||
|
|
||||||
|
hiddenRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
|
||||||
|
hiddenRow.CommandParameter.ShouldBeSameAs(hidden);
|
||||||
|
HasVisibleCheck(hiddenRow).ShouldBeFalse("this one was switched off");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
|
||||||
|
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||||
|
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
|
||||||
|
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||||
|
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
|
||||||
|
|
||||||
|
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
|
||||||
|
OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
var chip = UserChip(rail);
|
||||||
|
Click(chip, window);
|
||||||
|
|
||||||
|
Click(PopoverRow(window, label), window);
|
||||||
|
|
||||||
|
shell.Screen.ShouldBe(target);
|
||||||
|
shell.IsShowingPages.ShouldBeTrue();
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
|
||||||
|
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
|
||||||
|
/// room for at all is drawn inline on that one screen while the vault is unlocked, and arming it from
|
||||||
|
/// anywhere else would be a card raised nobody could see.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePopoversLogoutRow_GoesToPreferencesAndArmsTheSignOutConfirmation()
|
||||||
|
{
|
||||||
|
await OnTheRailAsync((rail, window) =>
|
||||||
|
{
|
||||||
|
var chip = UserChip(rail);
|
||||||
|
Click(chip, window);
|
||||||
|
|
||||||
|
Click(PopoverRow(window, "Logout"), window);
|
||||||
|
|
||||||
|
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
||||||
|
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var rail = new NavRail { DataContext = shell };
|
||||||
|
var window = new Window { Content = rail };
|
||||||
|
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body(rail, window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
private static Button Segment(Visual rail, string label) =>
|
||||||
|
rail.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => button.Classes.Contains("navseg")
|
||||||
|
&& button.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
||||||
|
|
||||||
|
private static Button UserChip(Visual rail) =>
|
||||||
|
rail.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("navuser"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A row inside the open popover, found by its own data context. Searched from the window rather than
|
||||||
|
/// from the rail: a <c>Flyout</c>'s content is a popup, hosted in the window's own overlay layer rather
|
||||||
|
/// than nested inside the control that owns it, so it is outside <c>rail.GetVisualDescendants()</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static Button PopoverRow(Visual window, VaultToggleViewModel toggle) =>
|
||||||
|
window.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => button.Classes.Contains("poprow") && ReferenceEquals(button.DataContext, toggle));
|
||||||
|
|
||||||
|
/// <summary>A navigation row inside the open popover, found by the word on it.</summary>
|
||||||
|
/// <inheritdoc cref="PopoverRow(Visual, VaultToggleViewModel)" path="/summary" />
|
||||||
|
private static Button PopoverRow(Visual window, string label) =>
|
||||||
|
window.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => button.Classes.Contains("poprow")
|
||||||
|
&& button.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
||||||
|
|
||||||
|
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
|
||||||
|
private static bool HasVisibleCheck(Button row) =>
|
||||||
|
row.GetVisualDescendants().OfType<Border>().Any(
|
||||||
|
border => border.Classes.Contains("vaultcheck") && border.IsVisible);
|
||||||
|
|
||||||
|
/// <summary>The mode-dependent first row's own label, read off its bound <c>TextBlock</c>.</summary>
|
||||||
|
private static string FirstRow(Visual rail) =>
|
||||||
|
rail.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => button.Classes.Contains("nav") && !button.Classes.Contains("navseg"))
|
||||||
|
.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(text => text.Classes.Contains("navlabel"))
|
||||||
|
.Text ?? string.Empty;
|
||||||
|
|
||||||
|
private static void Click(Visual control, Window window)
|
||||||
|
{
|
||||||
|
var at = control.TranslatePoint(
|
||||||
|
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||||
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||||
|
|
||||||
|
window.MouseDown(at, MouseButton.Left);
|
||||||
|
window.MouseUp(at, MouseButton.Left);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -825,7 +825,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
|
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
|
||||||
|
|
||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
|
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
|
||||||
|
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
|
||||||
|
// usage is "deliberately not given the session shell above."
|
||||||
|
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1144,18 +1147,30 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- The transfers screen ----
|
// ---- The transfers screen ----
|
||||||
|
//
|
||||||
|
// ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
|
||||||
|
// control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
|
||||||
|
// MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
|
||||||
|
// container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
|
||||||
|
// it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
|
||||||
|
// TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
|
||||||
|
// SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
|
||||||
|
// the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The widest thing in this window and the one with the least room to give: two file listings side by
|
/// The widest this screen gets and the one with the least room to give once connected: two file listings
|
||||||
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
|
/// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
|
||||||
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
|
/// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
|
||||||
/// a button and a chip on one line.
|
/// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
|
||||||
|
/// actually chosen against; see its own remark on the column widths.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
|
/// Measured disconnected here, though, which is the state the screen opens in and the one where the
|
||||||
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
|
/// local pane is at its fullest — it lists this machine's home directory, so the row template is
|
||||||
/// of real length rather than with fixtures chosen to fit.
|
/// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
|
||||||
|
/// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
|
||||||
|
/// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1253,14 +1268,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
|
/// The state the screen is in once something is open, and the tightest one wave C's restyle has to
|
||||||
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
|
/// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
|
||||||
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
|
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
|
||||||
/// that already carries UP, REFRESH and DELETE.
|
/// 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
|
||||||
|
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
|
||||||
|
/// 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.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
|
/// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
|
||||||
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
|
/// trimming where the header prints it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1303,6 +1322,214 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
|
||||||
|
/// the fixture's own home directory — whose contents this suite does not control — by adding one row of
|
||||||
|
/// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
|
||||||
|
/// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
|
||||||
|
/// not proves alongside the two shared columns.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheRowTemplateShowsSizeAndDateColumns()
|
||||||
|
{
|
||||||
|
var local = new LocalEntryRowViewModel(new LocalEntry(
|
||||||
|
"notes.txt",
|
||||||
|
Path.Combine(Path.GetTempPath(), "notes.txt"),
|
||||||
|
IsDirectory: false,
|
||||||
|
Length: 4_096,
|
||||||
|
new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
// Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
|
||||||
|
// than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
|
||||||
|
// row added after all of those would never be built at all, which is a false pass rather than a
|
||||||
|
// proof — the assertion below would find nothing to have missed.
|
||||||
|
transfers.LocalEntries.Clear();
|
||||||
|
transfers.LocalEntries.Add(local);
|
||||||
|
|
||||||
|
var remote = new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"deploy.log",
|
||||||
|
"/srv/releases/deploy.log",
|
||||||
|
SftpEntryKind.File,
|
||||||
|
49_152,
|
||||||
|
new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
|
||||||
|
"-rw-r--r--"));
|
||||||
|
|
||||||
|
transfers.RemoteEntries.Add(remote);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();
|
||||||
|
|
||||||
|
texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
|
||||||
|
texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
|
||||||
|
texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
|
||||||
|
texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
|
||||||
|
texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
|
||||||
|
/// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
|
||||||
|
/// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
|
||||||
|
/// rendered onto the strip itself.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
|
||||||
|
{
|
||||||
|
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
|
||||||
|
Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
|
||||||
|
Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
|
||||||
|
Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
|
||||||
|
Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");
|
||||||
|
|
||||||
|
var expected = new[] { "queued", "64%", "done", "stopped", "failed" };
|
||||||
|
|
||||||
|
transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var words = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Where(text => text.Classes.Contains("transferstatus"))
|
||||||
|
.Select(text => text.Text ?? string.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
words.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
|
||||||
|
/// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
|
||||||
|
/// absent with nothing queued, and a queued transfer brings the full strip straight back.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
|
||||||
|
{
|
||||||
|
transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var label = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
|
||||||
|
|
||||||
|
label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");
|
||||||
|
|
||||||
|
var collapsed = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Single(text => text.Text is { } spoken
|
||||||
|
&& spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
collapsed.IsEffectivelyVisible.ShouldBeTrue(
|
||||||
|
"the honest sentence stays reachable in the collapsed shape");
|
||||||
|
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var label = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
|
||||||
|
|
||||||
|
label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
|
||||||
|
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
|
||||||
|
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
|
||||||
|
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
|
||||||
|
/// pane.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
|
||||||
|
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
|
||||||
|
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
|
||||||
|
912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
|
||||||
|
0, TimeProvider.System.GetUtcNow(), string.Empty)));
|
||||||
|
|
||||||
|
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
|
||||||
|
TransferState.Running, bytesPerSecond: 6_500_000);
|
||||||
|
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
|
||||||
|
TransferState.Cancelled);
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(
|
||||||
|
faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The chrome ----
|
// ---- The chrome ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1355,27 +1582,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
|
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
|
||||||
/// the entries have to leave room for each other, which is the same failure the old four-button selector
|
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
|
||||||
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
|
/// rows below it and the user chip at the foot, both the rail's full content width. A single
|
||||||
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
|
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
|
||||||
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
|
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
|
||||||
/// pass every other assertion here.
|
/// own width now.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
|
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
|
||||||
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
|
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
|
||||||
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
|
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
|
||||||
/// in. See <c>NavRail.axaml</c>.
|
/// entry silently dropping off the bottom would still pass every other assertion here.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
|
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||||
{
|
{
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
var rail = new NavRail();
|
var rail = new NavRail { DataContext = shell };
|
||||||
var window = LayoutHarness.HostAtMinimumSize(
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
||||||
|
|
||||||
@@ -1383,18 +1610,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
|
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
|
||||||
|
|
||||||
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
|
var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
|
||||||
|
var rows = buttons.Where(button => button.Classes.Contains("nav")
|
||||||
|
&& !button.Classes.Contains("navseg")).ToList();
|
||||||
|
var chip = buttons.Single(button => button.Classes.Contains("navuser"));
|
||||||
|
|
||||||
foreach (var button in buttons)
|
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
|
||||||
|
rows.Count.ShouldBe(
|
||||||
|
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
|
||||||
|
|
||||||
|
foreach (var segment in segments)
|
||||||
{
|
{
|
||||||
button.Bounds.Height.ShouldBeGreaterThan(20);
|
segment.Bounds.Height.ShouldBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
|
||||||
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
|
foreach (var row in rows.Append(chip))
|
||||||
// each side that v2 gives the rows so a filled one reads as a rounded row rather
|
{
|
||||||
// than as a full-width band. Stated exactly rather than as a lower bound: a button
|
row.Bounds.Height.ShouldBeGreaterThan(20);
|
||||||
// that stopped filling the row would leave a dead strip beside a destination, which
|
|
||||||
// is precisely the kind of near-miss a bound hides.
|
// 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
|
||||||
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
|
// inset v5b's own padding gives each side. Stated exactly rather than as a lower
|
||||||
|
// bound: a row that stopped filling the width would leave a dead strip beside a
|
||||||
|
// destination, which is precisely the kind of near-miss a bound hides.
|
||||||
|
row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
@@ -1579,8 +1817,21 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
|
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The same width and height as a full screen: the card is a sibling of the page area rather than
|
/// <para>
|
||||||
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
|
/// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
|
||||||
|
/// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
|
||||||
|
/// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
|
||||||
|
/// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
|
||||||
|
/// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
|
||||||
|
/// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
|
||||||
|
/// not the plain screen budget.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
|
||||||
|
/// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
|
||||||
|
/// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
|
||||||
|
/// either — see the two callers below, both of which select one before calling this.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
|
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
@@ -1594,7 +1845,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
var card = new ConnectingCard { DataContext = shell };
|
var card = new ConnectingCard { DataContext = shell };
|
||||||
|
|
||||||
var window = LayoutHarness.HostAtMinimumSize(
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1609,12 +1860,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
|
/// <para>
|
||||||
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
|
/// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
|
||||||
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
|
/// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
|
||||||
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
|
/// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
|
||||||
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
|
/// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
|
||||||
/// stop fitting when the strip is what it is drawn over.
|
/// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
|
||||||
|
/// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
|
||||||
|
/// the intent the two states share, not this control's actual bounds.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
|
||||||
|
/// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
|
||||||
|
/// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
|
||||||
|
/// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
|
||||||
|
/// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
|
||||||
|
/// records is that no gap was left for a later wave to close.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
|
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
@@ -1852,7 +2114,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
|
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
|
||||||
|
|
||||||
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
|
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
|
||||||
|
/// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
|
||||||
|
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
||||||
|
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
||||||
|
/// </remarks>
|
||||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen,
|
||||||
|
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
|
||||||
|
LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
assert(LayoutHarness.Unreachable(window));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
|
||||||
|
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
|
||||||
|
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
|
||||||
|
/// the same budget every other full-bleed page gets.
|
||||||
|
/// </remarks>
|
||||||
|
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
@@ -1896,8 +2193,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
|
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
|
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
|
||||||
/// takes 190 and the vault list 268, leaving 558 for everything above.
|
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
|
||||||
|
/// widened the rail, because the minimum grew by exactly what the rail did.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
|
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
|
using Avalonia.Headless;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
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>
|
||||||
|
/// How the v5b in-screen tab row answers a pointer, on both of the two ways it is used.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The replacement for <c>TerminalTabsTests</c>, which measured the window-wide strip this control replaced
|
||||||
|
/// — see <c>SessionTabRow.axaml</c>'s own remark for why one control now serves both the terminal surface and
|
||||||
|
/// the SFTP surface. The gestures that do not vary by surface — middle-click closes, "+" opens the palette,
|
||||||
|
/// the tab pointer feedback — are carried over from that suite essentially unchanged; what is new here is
|
||||||
|
/// <see cref="TabRowTabClickRunsTheGivenCommand"/> and <see cref="SftpRowMarksTheColourItIsToldTo"/>, which
|
||||||
|
/// prove the one thing this control adds: the click and the accent colour are handed in rather than fixed.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SessionTabRowTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private ClientCacheFactory caches = null!;
|
||||||
|
private TerminalWorkspace workspace = null!;
|
||||||
|
private MainWindowViewModel shell = null!;
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
caches = ClientCacheFactory.ForMemory($"session-tabs-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
workspace = new TerminalWorkspace(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
shell = new MainWindowViewModel(
|
||||||
|
ClientPaths.Default,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AMiddleClickOnATabClosesThatTab()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
var doomed = shell.Tabs[0];
|
||||||
|
var survivor = shell.Tabs[1];
|
||||||
|
|
||||||
|
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
window.MouseDown(new Point(700, 17), MouseButton.Middle);
|
||||||
|
|
||||||
|
shell.Tabs.Count.ShouldBe(2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
|
||||||
|
|
||||||
|
shell.Tabs.Count.ShouldBe(2);
|
||||||
|
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
var survivor = shell.Tabs[1];
|
||||||
|
|
||||||
|
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
var doomed = shell.Tabs[0];
|
||||||
|
var survivor = shell.Tabs[1];
|
||||||
|
|
||||||
|
shell.SelectTabCommand.Execute(survivor);
|
||||||
|
|
||||||
|
var cross = CloseButton(strip, doomed);
|
||||||
|
window.MouseDown(Centre(cross, window), MouseButton.Left);
|
||||||
|
window.MouseUp(Centre(cross, window), MouseButton.Left);
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
||||||
|
shell.SelectedTab.ShouldBe(survivor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The terminal row's own click: <c>TabCommand</c> bound to <c>SelectTabCommand</c>, the same as the
|
||||||
|
/// window-wide strip's used to be.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ALeftClickOnATabRunsTheBoundTabCommand()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
var wanted = shell.Tabs[1];
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||||
|
shell.IsTerminalShowing.ShouldBeFalse();
|
||||||
|
|
||||||
|
var button = TabButton(strip, wanted);
|
||||||
|
window.MouseDown(Centre(button, window), MouseButton.Left);
|
||||||
|
window.MouseUp(Centre(button, window), MouseButton.Left);
|
||||||
|
|
||||||
|
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
|
||||||
|
shell.SelectedTab.ShouldBe(wanted);
|
||||||
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The one thing this control adds over the strip it replaces: which command a tab click runs is handed
|
||||||
|
/// in, not fixed. Proved with a command that is not <c>SelectTabCommand</c> at all, so a row that quietly
|
||||||
|
/// ignored <c>TabCommand</c> and fell back to selecting the tab itself would fail this rather than pass
|
||||||
|
/// it by coincidence.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TabRowTabClickRunsTheGivenCommand()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
|
||||||
|
|
||||||
|
var seen = new List<TerminalTabViewModel>();
|
||||||
|
var strip = new SessionTabRow
|
||||||
|
{
|
||||||
|
DataContext = shell,
|
||||||
|
TabCommand = new RelayCommand<TerminalTabViewModel>(tab => seen.Add(tab!)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var window = new Window { Content = strip };
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var button = TabButton(strip, shell.Tabs[0]);
|
||||||
|
window.MouseDown(Centre(button, window), MouseButton.Left);
|
||||||
|
window.MouseUp(Centre(button, window), MouseButton.Left);
|
||||||
|
|
||||||
|
seen.ShouldHaveSingleItem().ShouldBe(shell.Tabs[0]);
|
||||||
|
shell.SelectedTab.ShouldBeNull("the given command ran instead of SelectTabCommand");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheButtonThatOpensAConnectionOpensThePalette()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync((strip, window) =>
|
||||||
|
{
|
||||||
|
var plus = PlusButton(strip);
|
||||||
|
window.MouseDown(Centre(plus, window), MouseButton.Left);
|
||||||
|
window.MouseUp(Centre(plus, window), MouseButton.Left);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The strip's own height budget moved with it — see <see cref="LayoutHarness.SessionTabRowHeight"/> —
|
||||||
|
/// but the invariant this test protects is the same one <c>TerminalTabsTests</c> protected: a row of
|
||||||
|
/// tabs must not grow the chrome around it, however many are open.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheRowIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
{
|
||||||
|
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
strip.DesiredSize.Height.ShouldBe(LayoutHarness.SessionTabRowHeight);
|
||||||
|
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
|
||||||
|
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
|
||||||
|
/// identically to the plus at rest by design. This proves the same two facts a different way: an inactive
|
||||||
|
/// tab still changes under the pointer, and the plus never gets the treatment an <em>active</em> tab does
|
||||||
|
/// — the coloured top border and the DeepChrome fill — which is the one visual claim "not a thing being
|
||||||
|
/// chosen between" actually has to hold.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsAnActiveTab()
|
||||||
|
{
|
||||||
|
await OnTheStripAsync(
|
||||||
|
(strip, window) =>
|
||||||
|
{
|
||||||
|
var tab = TabButton(strip, shell.Tabs[0]);
|
||||||
|
var resting = Fill(tab);
|
||||||
|
|
||||||
|
window.MouseMove(Centre(tab, window));
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
||||||
|
|
||||||
|
Fill(tab).ShouldNotBe(
|
||||||
|
resting,
|
||||||
|
"a tab that does not change under the pointer is one nobody can tell is clickable");
|
||||||
|
|
||||||
|
window.MouseMove(new Point(0, 0));
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
var activeTab = TabButton(strip, shell.Tabs[0]);
|
||||||
|
var plus = PlusButton(strip);
|
||||||
|
|
||||||
|
Fill(plus).ShouldNotBe(
|
||||||
|
Fill(activeTab),
|
||||||
|
"the button that opens a connection never carries the active tab's DeepChrome fill");
|
||||||
|
|
||||||
|
Presenter(plus).BorderThickness.ShouldBe(
|
||||||
|
default(Thickness),
|
||||||
|
"it carries no outline, because it is not a thing being chosen between");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The SFTP row's own accent: <c>Classes="sftp"</c> on this control's own usage — see
|
||||||
|
/// <c>MainWindow.axaml</c> — is what selects <c>Magenta</c> over the terminal row's <c>TerminalTabAccent</c>
|
||||||
|
/// for an active tab's top border. Read as a colour rather than a class list, so this catches the App.axaml
|
||||||
|
/// selector actually resolving to a different brush rather than merely having the class applied.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SftpRowMarksTheColourItIsToldTo()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
|
||||||
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||||
|
|
||||||
|
var sshRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
|
||||||
|
var sftpRow = new SessionTabRow
|
||||||
|
{
|
||||||
|
DataContext = shell,
|
||||||
|
TabCommand = shell.SelectTabCommand,
|
||||||
|
Classes = { "sftp" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var sshWindow = new Window { Content = sshRow };
|
||||||
|
var sftpWindow = new Window { Content = sftpRow };
|
||||||
|
LayoutHarness.Settle(sshWindow, 900, 600);
|
||||||
|
LayoutHarness.Settle(sftpWindow, 900, 600);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sshBorder = Presenter(TabButton(sshRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
|
||||||
|
var sftpBorder = Presenter(TabButton(sftpRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
|
||||||
|
|
||||||
|
sshBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#7C5CFF"), "TerminalTabAccent");
|
||||||
|
sftpBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#DD1296"), "Magenta");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sshWindow.Close();
|
||||||
|
sftpWindow.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
private static ContentPresenter Presenter(Visual button) =>
|
||||||
|
button.GetVisualDescendants()
|
||||||
|
.OfType<ContentPresenter>()
|
||||||
|
.First(presenter => presenter.Name is "PART_ContentPresenter");
|
||||||
|
|
||||||
|
private static Color? Fill(Visual button) =>
|
||||||
|
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
|
||||||
|
|
||||||
|
private Task OnTheStripAsync(Action<SessionTabRow, Window> body) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
|
||||||
|
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
|
||||||
|
|
||||||
|
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
|
||||||
|
var window = new Window { Content = strip };
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body(strip, window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
|
||||||
|
strip.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("sesstab"));
|
||||||
|
|
||||||
|
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
|
||||||
|
strip.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
|
||||||
|
|
||||||
|
private static Button PlusButton(Visual strip) =>
|
||||||
|
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
|
||||||
|
|
||||||
|
private static Point Centre(Visual control, Visual window) =>
|
||||||
|
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||||
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||||
|
}
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.Presenters;
|
|
||||||
using Avalonia.Controls.Primitives;
|
|
||||||
using Avalonia.Headless;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.VisualTree;
|
|
||||||
using DodoSSH.Client.App.Views;
|
|
||||||
using DodoSSH.Client.Session;
|
|
||||||
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>
|
|
||||||
/// How the tab strip answers a pointer.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
|
|
||||||
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
|
|
||||||
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
|
|
||||||
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
|
|
||||||
/// binding.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
|
|
||||||
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
|
|
||||||
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
|
|
||||||
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
|
|
||||||
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class TerminalTabsTests : IAsyncLifetime
|
|
||||||
{
|
|
||||||
private ClientCacheFactory caches = null!;
|
|
||||||
private TerminalWorkspace workspace = null!;
|
|
||||||
private MainWindowViewModel shell = null!;
|
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public ValueTask InitializeAsync()
|
|
||||||
{
|
|
||||||
caches = ClientCacheFactory.ForMemory($"tabs-{Guid.CreateVersion7():N}");
|
|
||||||
|
|
||||||
workspace = new TerminalWorkspace(
|
|
||||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
||||||
Substitute.For<ISshConnectionFactory>(),
|
|
||||||
TimeProvider.System);
|
|
||||||
|
|
||||||
shell = new MainWindowViewModel(
|
|
||||||
ClientPaths.Default,
|
|
||||||
caches,
|
|
||||||
workspace,
|
|
||||||
new VaultKnownHostStore(),
|
|
||||||
Substitute.For<IDeviceKeyStore>(),
|
|
||||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
||||||
TimeProvider.System,
|
|
||||||
Substitute.For<ISftpSessionFactory>())
|
|
||||||
{
|
|
||||||
// The only state the strip is ever interactive in. Assigned rather than reached through an
|
|
||||||
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
|
|
||||||
State = ShellState.Unlocked,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await shell.DisposeAsync();
|
|
||||||
await workspace.DisposeAsync();
|
|
||||||
caches.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
|
|
||||||
/// one, and the strip answered nothing but a left click before.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AMiddleClickOnATabClosesThatTab()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var doomed = shell.Tabs[0];
|
|
||||||
var survivor = shell.Tabs[1];
|
|
||||||
|
|
||||||
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
|
|
||||||
|
|
||||||
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
|
|
||||||
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
|
|
||||||
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
|
|
||||||
/// the same feature with a way to get it wrong.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
// Well right of two short tabs and the button after them, and inside the strip's own height.
|
|
||||||
window.MouseDown(new Point(700, 17), MouseButton.Middle);
|
|
||||||
|
|
||||||
shell.Tabs.Count.ShouldBe(2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
|
|
||||||
|
|
||||||
shell.Tabs.Count.ShouldBe(2);
|
|
||||||
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
|
|
||||||
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var survivor = shell.Tabs[1];
|
|
||||||
|
|
||||||
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
|
|
||||||
|
|
||||||
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
|
|
||||||
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
|
|
||||||
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
|
|
||||||
/// terminal switching to something that is about to disappear.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var doomed = shell.Tabs[0];
|
|
||||||
var survivor = shell.Tabs[1];
|
|
||||||
|
|
||||||
shell.SelectTabCommand.Execute(survivor);
|
|
||||||
|
|
||||||
var cross = CloseButton(strip, doomed);
|
|
||||||
window.MouseDown(Centre(cross, window), MouseButton.Left);
|
|
||||||
window.MouseUp(Centre(cross, window), MouseButton.Left);
|
|
||||||
|
|
||||||
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
||||||
shell.SelectedTab.ShouldBe(survivor);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var wanted = shell.Tabs[1];
|
|
||||||
|
|
||||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
||||||
shell.IsTerminalShowing.ShouldBeFalse();
|
|
||||||
|
|
||||||
var button = TabButton(strip, wanted);
|
|
||||||
window.MouseDown(Centre(button, window), MouseButton.Left);
|
|
||||||
window.MouseUp(Centre(button, window), MouseButton.Left);
|
|
||||||
|
|
||||||
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
|
|
||||||
shell.SelectedTab.ShouldBe(wanted);
|
|
||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
|
|
||||||
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
|
|
||||||
/// claim this project is willing to make without a screenshot.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheButtonThatOpensAConnectionOpensThePalette()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var plus = PlusButton(strip);
|
|
||||||
window.MouseDown(Centre(plus, window), MouseButton.Left);
|
|
||||||
window.MouseUp(Centre(plus, window), MouseButton.Left);
|
|
||||||
|
|
||||||
shell.IsSearching.ShouldBeTrue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
|
|
||||||
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
|
|
||||||
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
|
|
||||||
/// Hosts, which is most of the time.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Driven through the strip rather than through the commands, because what is being checked is that
|
|
||||||
/// three buttons in the markup are wired to three different things. Three commands called directly
|
|
||||||
/// would pass on a strip whose SFTP tab was bound to the S3 one.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
|
|
||||||
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
|
|
||||||
|
|
||||||
Click(FixedTab(strip, "SFTP"), window);
|
|
||||||
|
|
||||||
shell.IsTransfersShowing.ShouldBeTrue();
|
|
||||||
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
|
|
||||||
|
|
||||||
Click(FixedTab(strip, "S3"), window);
|
|
||||||
|
|
||||||
shell.IsBucketsShowing.ShouldBeTrue();
|
|
||||||
shell.IsTransfersShowing.ShouldBeFalse();
|
|
||||||
|
|
||||||
Click(FixedTab(strip, "Vaults"), window);
|
|
||||||
|
|
||||||
shell.IsVaultsTab.ShouldBeTrue();
|
|
||||||
shell.Screen.ShouldBe(
|
|
||||||
ShellScreen.Snippets,
|
|
||||||
"the Vaults tab comes back to the page it was left on, not to Hosts");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The caret is the second half of the Vaults pill and the only control in this strip that opens a
|
|
||||||
/// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in
|
|
||||||
/// the handler rather than the flyout being attached — see the test below.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheCaretBesideVaults_OpensTheVaultMenu()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
var caret = CaretButton(strip);
|
|
||||||
|
|
||||||
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
|
|
||||||
|
|
||||||
Click(caret, window);
|
|
||||||
|
|
||||||
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The occlusion guard, and the reason this strip may have a flyout at all.</b> The comment on the
|
|
||||||
/// <c>+</c> button refuses one because a popup dropping into the terminal's rectangle would have to
|
|
||||||
/// composite above a native child window, which this project does not claim without a screenshot. The
|
|
||||||
/// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens,
|
|
||||||
/// and a page surface is one where the renderer is not drawn.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// So the assertion is on <see cref="MainWindowViewModel.IsTerminalShowing"/> rather than on anything
|
|
||||||
/// about the popup. A change that opened the flyout without moving the surface first would still show a
|
|
||||||
/// menu in every screenshot anybody took on a machine where it happened to work.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, window) =>
|
|
||||||
{
|
|
||||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
||||||
|
|
||||||
shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way");
|
|
||||||
|
|
||||||
Click(CaretButton(strip), window);
|
|
||||||
|
|
||||||
shell.IsVaultsTab.ShouldBeTrue();
|
|
||||||
shell.IsTerminalShowing.ShouldBeFalse(
|
|
||||||
"the flyout must never have to composite over the renderer's native child window");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
|
|
||||||
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
|
|
||||||
/// SFTP.
|
|
||||||
/// <para>
|
|
||||||
/// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion
|
|
||||||
/// meaning what it says: a button inside a fixed tab would still be a close box.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheFixedTabsCarryNoCloseBox()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync((strip, _) =>
|
|
||||||
{
|
|
||||||
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
|
|
||||||
{
|
|
||||||
FixedTab(strip, label)
|
|
||||||
.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.ShouldBeEmpty($"{label} is a destination, not a session");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
|
|
||||||
/// and this is what stops the budget drifting from the markup. See
|
|
||||||
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
|
|
||||||
{
|
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
for (var i = 0; i < 12; i++)
|
|
||||||
{
|
|
||||||
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var strip = new TerminalTabs { DataContext = shell };
|
|
||||||
var window = LayoutHarness.HostAtMinimumSize(
|
|
||||||
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// What it asks for, not what this host window gave it. Hosting it at 34 and then
|
|
||||||
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
|
|
||||||
// exactly the regression the budget needs catching.
|
|
||||||
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
|
|
||||||
|
|
||||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A tab lights under the pointer, and the button that opens one is not drawn as a tab.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The only test in this suite that reads a brush rather than a rectangle, and it is here because that
|
|
||||||
/// was the gap a real regression went through. Everything else measures heights and reachability, so a
|
|
||||||
/// strip whose tabs had silently stopped answering the pointer passed all of it.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity —
|
|
||||||
/// the later declaration wins — and when the tab became a pill that paints its own background, that
|
|
||||||
/// background was declared *after* the hover rule it relied on and after the exceptions the <c>+</c>
|
|
||||||
/// is made of. So every tab lost its pointer feedback and the <c>+</c> gained a fill and an outline it
|
|
||||||
/// is specifically not supposed to have. Both are one assertion each below.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab()
|
|
||||||
{
|
|
||||||
await OnTheStripAsync(
|
|
||||||
(strip, window) =>
|
|
||||||
{
|
|
||||||
var tab = TabButton(strip, shell.Tabs[0]);
|
|
||||||
var resting = Fill(tab);
|
|
||||||
|
|
||||||
window.MouseMove(Centre(tab, window));
|
|
||||||
LayoutHarness.Settle(window, 900, 600);
|
|
||||||
|
|
||||||
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
|
||||||
|
|
||||||
Fill(tab).ShouldNotBe(
|
|
||||||
resting,
|
|
||||||
"a tab that does not change under the pointer is one nobody can tell is clickable");
|
|
||||||
|
|
||||||
// Off the strip again, so the plus is measured at rest rather than under the pointer.
|
|
||||||
window.MouseMove(new Point(0, 0));
|
|
||||||
LayoutHarness.Settle(window, 900, 600);
|
|
||||||
|
|
||||||
var plus = PlusButton(strip);
|
|
||||||
|
|
||||||
Fill(plus).ShouldNotBe(
|
|
||||||
Fill(TabButton(strip, shell.Tabs[0])),
|
|
||||||
"the button that opens a connection is not one of the connections");
|
|
||||||
|
|
||||||
Presenter(plus).BorderThickness.ShouldBe(
|
|
||||||
default(Thickness),
|
|
||||||
"it carries no outline, because it is not a thing being chosen between");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
|
|
||||||
/// <summary>The presenter the Fluent theme actually paints, which is where every button style lands.</summary>
|
|
||||||
private static ContentPresenter Presenter(Visual button) =>
|
|
||||||
button.GetVisualDescendants()
|
|
||||||
.OfType<ContentPresenter>()
|
|
||||||
.First(presenter => presenter.Name is "PART_ContentPresenter");
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The colour rather than the brush. Two <see cref="ISolidColorBrush"/> instances holding the same
|
|
||||||
/// colour are not equal, and it is the colour a user sees.
|
|
||||||
/// </remarks>
|
|
||||||
private static Color? Fill(Visual button) =>
|
|
||||||
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
|
|
||||||
|
|
||||||
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
|
|
||||||
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
|
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
|
|
||||||
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
|
|
||||||
|
|
||||||
var strip = new TerminalTabs { DataContext = shell };
|
|
||||||
var window = new Window { Content = strip };
|
|
||||||
LayoutHarness.Settle(window, 900, 600);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
body(strip, window);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Token);
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
|
|
||||||
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
|
|
||||||
/// classes tell them apart.
|
|
||||||
/// </remarks>
|
|
||||||
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
|
|
||||||
strip.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
|
|
||||||
|
|
||||||
/// <inheritdoc cref="TabButton" />
|
|
||||||
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
|
|
||||||
strip.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
|
|
||||||
|
|
||||||
/// <summary>The half of the Vaults pill that opens the vault menu.</summary>
|
|
||||||
/// <inheritdoc cref="TabButton" path="/remarks" />
|
|
||||||
private static Button CaretButton(Visual strip) =>
|
|
||||||
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("caret"));
|
|
||||||
|
|
||||||
/// <inheritdoc cref="TabButton" />
|
|
||||||
private static Button PlusButton(Visual strip) =>
|
|
||||||
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
|
|
||||||
|
|
||||||
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
|
|
||||||
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
|
|
||||||
/// first, because a terminal tab could be opened on a host called SFTP.
|
|
||||||
/// </remarks>
|
|
||||||
private static Button FixedTab(Visual strip, string label) =>
|
|
||||||
strip.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.First(button => button.Classes.Contains("fixed")
|
|
||||||
&& button.GetVisualDescendants()
|
|
||||||
.OfType<TextBlock>()
|
|
||||||
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
|
||||||
|
|
||||||
private static void Click(Visual control, Window window)
|
|
||||||
{
|
|
||||||
var at = Centre(control, window);
|
|
||||||
|
|
||||||
window.MouseDown(at, MouseButton.Left);
|
|
||||||
window.MouseUp(at, MouseButton.Left);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Point Centre(Visual control, Visual window) =>
|
|
||||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
|
||||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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 titlebar's own search pill still opens the quick-connect palette after v5b's redraw.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A narrow suite on purpose: <see cref="LayoutHarnessTests"/> already holds the bar to its declared
|
||||||
|
/// height and proves nothing inside it is unreachable, and the search pill's own accent-on-hover border is
|
||||||
|
/// a style rule with nothing to assert headlessly. What is worth a gesture is the one thing a fidelity pass
|
||||||
|
/// could quietly break without any layout test noticing — the pill no longer opening what Ctrl+K opens.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TitleBarTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private ClientCacheFactory caches = null!;
|
||||||
|
private TerminalWorkspace workspace = null!;
|
||||||
|
private MainWindowViewModel shell = null!;
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
caches = ClientCacheFactory.ForMemory($"titlebar-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
workspace = new TerminalWorkspace(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
shell = new MainWindowViewModel(
|
||||||
|
ClientPaths.Default,
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
new VaultKnownHostStore(),
|
||||||
|
Substitute.For<IDeviceKeyStore>(),
|
||||||
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||||
|
TimeProvider.System,
|
||||||
|
Substitute.For<ISftpSessionFactory>())
|
||||||
|
{
|
||||||
|
// The search pill is disabled while locked — see TitleBar.axaml's IsEnabled binding — and this
|
||||||
|
// is the only state that gesture is reachable in.
|
||||||
|
State = ShellState.Unlocked,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await shell.DisposeAsync();
|
||||||
|
await workspace.DisposeAsync();
|
||||||
|
caches.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var bar = new TitleBar { DataContext = shell };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
shell.IsSearching.ShouldBeFalse("nothing has been pressed yet");
|
||||||
|
|
||||||
|
var pill = bar.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.First(button => button.Classes.Contains("search"));
|
||||||
|
|
||||||
|
var centre = pill.TranslatePoint(
|
||||||
|
new Point(pill.Bounds.Width / 2, pill.Bounds.Height / 2), window)
|
||||||
|
?? throw new InvalidOperationException("the pill is not in this window's tree");
|
||||||
|
|
||||||
|
window.MouseDown(centre, MouseButton.Left);
|
||||||
|
window.MouseUp(centre, MouseButton.Left);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeTrue(
|
||||||
|
"the pill is the click-through path Ctrl+K also opens — see ToggleSearchCommand");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2789,6 +2789,125 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Status.ShouldContain("host");
|
vault.Status.ShouldContain("host");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
|
||||||
|
/// count beside every category row, and this pins that each one is the same number the category's own
|
||||||
|
/// list already carries rather than a second, hand-kept tally that could drift from it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await AddKeyAsync(vault, "backup");
|
||||||
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
||||||
|
await AddTagAsync(vault, "production");
|
||||||
|
|
||||||
|
vault.Keys.Count.ShouldBe(2);
|
||||||
|
vault.Credentials.Count.ShouldBe(1);
|
||||||
|
vault.Tags.Count.ShouldBe(1);
|
||||||
|
vault.ObjectStores.Count.ShouldBe(0);
|
||||||
|
vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
|
||||||
|
/// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
|
||||||
|
/// which is the whole reason the column exists.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EachVaultItemKindCarriesItsOwnGlyph()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
||||||
|
await AddTagAsync(vault, "production");
|
||||||
|
|
||||||
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||||
|
|
||||||
|
var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);
|
||||||
|
|
||||||
|
byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
|
||||||
|
byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
|
||||||
|
byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();
|
||||||
|
|
||||||
|
new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
|
||||||
|
.Distinct(StringComparer.Ordinal).Count()
|
||||||
|
.ShouldBe(3, "three kinds on the same table read as three different glyphs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
|
||||||
|
/// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
|
||||||
|
/// clearing it brings every row straight back.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy-key");
|
||||||
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
||||||
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||||
|
|
||||||
|
vault.ItemFilter = "deploy";
|
||||||
|
|
||||||
|
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);
|
||||||
|
|
||||||
|
vault.ItemFilter = "PASSWORD";
|
||||||
|
|
||||||
|
vault.VaultItems.Select(row => row.Name).ShouldBe(
|
||||||
|
["pg-primary"], "the type word matches too, case-insensitively");
|
||||||
|
|
||||||
|
vault.ItemFilter = string.Empty;
|
||||||
|
|
||||||
|
vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
|
||||||
|
/// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
|
||||||
|
/// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
|
||||||
|
/// them going stale relative to the others.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-api-01");
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
|
||||||
|
var key = vault.Keys[0];
|
||||||
|
await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);
|
||||||
|
|
||||||
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
||||||
|
var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);
|
||||||
|
|
||||||
|
row.UsedBySummary.ShouldBe("prod-api-01");
|
||||||
|
row.HasUsedBySummary.ShouldBeTrue();
|
||||||
|
|
||||||
|
vault.SelectedVaultItem = row;
|
||||||
|
|
||||||
|
vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
|
||||||
|
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
|
||||||
|
vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");
|
||||||
|
|
||||||
|
// And a key nothing authenticates with says none of this — never a zero-count claim.
|
||||||
|
await AddKeyAsync(vault, "unused");
|
||||||
|
vault.SelectedVaultItem = vault.VaultItems.Single(
|
||||||
|
item => string.Equals(item.Name, "unused", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
|
||||||
|
vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Keeping an open editor's pickers in step with the vault ----
|
// ---- Keeping an open editor's pickers in step with the vault ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -5639,7 +5758,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.Transfers.Status.ShouldContain("password");
|
shell.Transfers.Status.ShouldContain("password");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- The terminal pin strip ----
|
// ---- The v5b session sidebar's QUICK ACCESS ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
||||||
@@ -5666,16 +5785,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
|
public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
|
||||||
{
|
{
|
||||||
await ConnectedHostWithAPinAsync();
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
shell.ShowsPinStrip.ShouldBeTrue();
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
|
||||||
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
|
public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
@@ -5684,14 +5803,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.AddEditorPinCommand.Execute(null);
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
|
// Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
|
||||||
// that happens to be selected on the hosts screen.
|
// to a selected tab, not to the host that happens to be selected on the hosts screen.
|
||||||
shell.ShowsPinStrip.ShouldBeFalse();
|
shell.ShowsQuickAccessSidebar.ShouldBeFalse();
|
||||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
|
||||||
|
/// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
|
||||||
|
/// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
|
||||||
|
/// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
|
public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
@@ -5703,28 +5828,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
|
||||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
|
public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
|
||||||
{
|
{
|
||||||
await ConnectedHostWithAPinAsync();
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
shell.ShowsPinStrip.ShouldBeTrue();
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
|
||||||
|
|
||||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||||
|
|
||||||
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
|
shell.ShowsQuickAccessSidebar.ShouldBeFalse(
|
||||||
|
"a page is showing, not the terminal or SFTP the sidebar sits beside");
|
||||||
|
|
||||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||||
|
|
||||||
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
|
/// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
|
||||||
|
/// than on a selected tab, since a session on that screen is its own connection — see
|
||||||
|
/// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||||
|
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
|
||||||
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
||||||
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
||||||
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
||||||
@@ -5746,6 +5888,129 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
|
||||||
|
/// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
var tab = shell.Tabs[0];
|
||||||
|
shell.SelectedTab = null;
|
||||||
|
|
||||||
|
await shell.SelectFilesHostCommand.ExecuteAsync(tab);
|
||||||
|
|
||||||
|
shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
|
||||||
|
/// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
|
||||||
|
/// tab that does not exist.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||||
|
var tabsBefore = shell.Tabs.Count;
|
||||||
|
|
||||||
|
await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
|
||||||
|
shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||||
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// that same mechanism rather than reimplementing it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
||||||
|
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
||||||
|
|
||||||
|
var row = snippets.Visible.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
||||||
|
|
||||||
|
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
shell.AddSnippetFromSidebarCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsSnippetsShowing.ShouldBeTrue();
|
||||||
|
shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
|
||||||
|
/// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
|
||||||
|
/// the hosts screen's own EDIT does.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
shell.PinFolderFromSidebarCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsHostsShowing.ShouldBeTrue();
|
||||||
|
vault.IsEditing.ShouldBeTrue();
|
||||||
|
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
||||||
|
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
||||||
|
/// opens, and this reads it back through the same clock.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
||||||
|
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
|
||||||
|
/// a blank or a placeholder standing in for them.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_AreAbsentWithNoSessionOpen()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
shell.IsSessionConnected.ShouldBeFalse();
|
||||||
|
shell.SessionAddress.ShouldBeNull();
|
||||||
|
shell.SessionElapsedText.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
||||||
{
|
{
|
||||||
@@ -6593,6 +6858,40 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
|
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
|
||||||
|
/// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
|
||||||
|
/// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
|
||||||
|
/// confirming actually removes it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);
|
||||||
|
|
||||||
|
snippets.Selected = snippets.Visible.Single();
|
||||||
|
snippets.RequestDeleteCommand.Execute(null);
|
||||||
|
|
||||||
|
snippets.IsConfirmingDelete.ShouldBeTrue();
|
||||||
|
snippets.DeleteQuestion.ShouldContain("restart the api");
|
||||||
|
snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
|
||||||
|
snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");
|
||||||
|
|
||||||
|
snippets.CancelDeleteCommand.Execute(null);
|
||||||
|
|
||||||
|
snippets.IsConfirmingDelete.ShouldBeFalse();
|
||||||
|
snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");
|
||||||
|
|
||||||
|
snippets.RequestDeleteCommand.Execute(null);
|
||||||
|
await snippets.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
snippets.IsConfirmingDelete.ShouldBeFalse();
|
||||||
|
snippets.Visible.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private static SnippetsViewModel SnippetsOver(
|
private static SnippetsViewModel SnippetsOver(
|
||||||
VaultViewModel vault,
|
VaultViewModel vault,
|
||||||
InsertTarget target,
|
InsertTarget target,
|
||||||
@@ -6646,6 +6945,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Logs ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
|
||||||
|
/// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
|
||||||
|
/// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
|
||||||
|
/// segment's own active state reads are mutually exclusive.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var logs = shell.LogsScreen.ShouldNotBeNull();
|
||||||
|
|
||||||
|
logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
|
||||||
|
logs.ShowsConnections.ShouldBeTrue();
|
||||||
|
logs.ShowsActivity.ShouldBeFalse();
|
||||||
|
|
||||||
|
logs.ShowSectionCommand.Execute(LogSection.Activity);
|
||||||
|
|
||||||
|
logs.ShowsActivity.ShouldBeTrue();
|
||||||
|
logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");
|
||||||
|
|
||||||
|
logs.ShowSectionCommand.Execute(LogSection.Connections);
|
||||||
|
logs.ShowsConnections.ShouldBeTrue();
|
||||||
|
|
||||||
|
Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
|
||||||
|
/// section fact this type's header comment already states truthfully, and steps aside for a refresh
|
||||||
|
/// error when refreshing just produced one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var logs = shell.LogsScreen.ShouldNotBeNull();
|
||||||
|
|
||||||
|
logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");
|
||||||
|
|
||||||
|
logs.ShowSectionCommand.Execute(LogSection.Activity);
|
||||||
|
logs.HeaderStatusLine.ShouldBe("one row per write, per device");
|
||||||
|
|
||||||
|
logs.Status = "network unreachable";
|
||||||
|
logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|||||||
@@ -639,7 +639,13 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
|||||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
|
/// <summary>
|
||||||
|
/// Switches a vault off through <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
|
||||||
|
/// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
|
||||||
|
/// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
|
||||||
|
/// is the command's own effect; that the popover's row is wired to call it is
|
||||||
|
/// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
|
||||||
|
/// </summary>
|
||||||
private async Task HideAsync(Guid vaultId)
|
private async Task HideAsync(Guid vaultId)
|
||||||
{
|
{
|
||||||
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);
|
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);
|
||||||
|
|||||||
Reference in New Issue
Block a user