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}" />
|
||||
</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
|
||||
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}" />
|
||||
</Style>
|
||||
<!--
|
||||
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
|
||||
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
|
||||
row carries, so the window has one vocabulary for selection rather than one per control.
|
||||
-->
|
||||
<!--
|
||||
A destination in the sidebar. v2 turns these from 54-pixel stacked labels into 32-pixel rows with a
|
||||
glyph, a word and a count, and the active one becomes a filled rounded row rather than a label with an
|
||||
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.
|
||||
The nav rail. v5b redraws it against TitleBar.dc.html's sibling NavRail.dc.html rather than against
|
||||
the v2 mock this replaces — see the comment block at the top of NavRail.axaml for what moved and why.
|
||||
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.
|
||||
|
||||
TextGhost rather than TextFaint for the resting label: the mock computes rgb(124,127,152) for an
|
||||
inactive row, which is TextGhost's own #7C7F98 exactly, not TextFaint's paler #9C9EB4.
|
||||
-->
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="10,0" />
|
||||
<Setter Property="Height" Value="35" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<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="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<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="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<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 Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The three parts of a sidebar row. Separate classes rather than inline setters because nine rows draw
|
||||
them and the day one of the three moves is the day eight of them would not have.
|
||||
|
||||
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.
|
||||
The two parts of a sidebar row: a 19-pixel Material glyph and its word, ten pixels apart. v2's third
|
||||
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.
|
||||
-->
|
||||
<Style Selector="TextBlock.navicon">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.navlabel">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</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
|
||||
own rounded outline, and the active one filled instead of marked along an edge. The edge mark is gone
|
||||
because a pill has no edge to share with its neighbour — the gap between two of them is the divider
|
||||
the old top-and-right border was standing in for.
|
||||
The segmented SSH / SFTP / S3 switcher at the rail's head. A track the width of the rail's own content
|
||||
column, 2 pixels of padding holding three equal segments — so the three are Buttons with
|
||||
HorizontalAlignment="Stretch" inside a Grid of three equal columns, the same "buttons carry no state"
|
||||
reasoning <c>NavRail.axaml</c>'s own remarks give for every row beneath them.
|
||||
-->
|
||||
<Style Selector="Button.tab">
|
||||
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
|
||||
<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" />
|
||||
<Style Selector="Border.navtrack">
|
||||
<Setter Property="Height" Value="31" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
<Setter Property="Padding" Value="2" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ ORDER IS THE BEHAVIOUR HERE. The base pill comes first, then its hover, then the exceptions — and
|
||||
that ordering is the fix for a bug this arrangement had when v2 first drew it.
|
||||
|
||||
A pill paints its own Background, which the flat tab it replaced did not. That one detail moves where
|
||||
the hover has to live: `Button.flat:pointerover` is declared far above and used to supply it, and the
|
||||
moment `.tab`'s template rule set a Background of its own, the later declaration won and every tab in
|
||||
the strip lost its pointer feedback silently. The `+` lost more than that — it sits below as an
|
||||
exception, so a `.tab` rule declared after it was overriding the very thing that made it an exception,
|
||||
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 Selector="Button.navseg">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Height" Value="27" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="0.1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
<Style Selector="Button.navseg /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<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="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
|
||||
close box, so it takes its padding back on the right: the base rule is short there to leave room for
|
||||
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
|
||||
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.
|
||||
The rail's foot: the user chip that opens the popover. Flat until the pointer arrives, the same idiom
|
||||
every other row in this rail follows, at its own height and radius because it is a chip rather than a
|
||||
destination — see <c>NavRail.axaml</c> for what it opens and why a <c>Flyout</c> is safe here.
|
||||
-->
|
||||
<Style Selector="Button.tab.fixed">
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<Style Selector="Button.navuser">
|
||||
<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>
|
||||
|
||||
<!--
|
||||
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
|
||||
being chosen between. Both of those are cleared rather than merely omitted — the base rule above sets
|
||||
a fill and a border, so an exception has to say so.
|
||||
being chosen between.
|
||||
-->
|
||||
<Style Selector="Button.tab.plus">
|
||||
<Style Selector="Button.sesstab.plus">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Width" Value="28" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</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="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</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="Background" Value="{StaticResource Hover}" />
|
||||
</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}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
|
||||
because they do two things, drawn as one pill because they are one destination — so the pair meets in
|
||||
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
|
||||
are.
|
||||
|
||||
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.
|
||||
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
|
||||
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.tab.split">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
<Style Selector="Button.headerghost">
|
||||
<Setter Property="Height" Value="32" />
|
||||
<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 Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
<Setter Property="BorderThickness" Value="1,1,0,1" />
|
||||
<Style Selector="Button.headerghost /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.caret">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
||||
<Style Selector="Button.headerghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</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>
|
||||
|
||||
<!--
|
||||
@@ -661,6 +800,34 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</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
|
||||
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}" />
|
||||
</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.
|
||||
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
Icon="/Assets/dodossh.ico"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="1016"
|
||||
MinHeight="574"
|
||||
MinWidth="1081"
|
||||
MinHeight="583"
|
||||
Background="{StaticResource Canvas}"
|
||||
SystemDecorations="BorderOnly"
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
|
||||
|
||||
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 —
|
||||
@@ -26,6 +25,17 @@
|
||||
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
||||
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
|
||||
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.
|
||||
@@ -37,12 +47,19 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
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
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. 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.
|
||||
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
|
||||
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
|
||||
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
|
||||
<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.
|
||||
|
||||
@@ -67,7 +84,9 @@
|
||||
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
|
||||
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">
|
||||
@@ -77,209 +96,227 @@
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- 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.
|
||||
|
||||
It was the other way round for a reason that stopped being true: while every tab was a terminal,
|
||||
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.
|
||||
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
|
||||
to give up its own first row to. See the remark below on where a session's tabs live now.
|
||||
-->
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<!--
|
||||
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
|
||||
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 PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
|
||||
|
||||
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
|
||||
connecting card are all layered on top of one another there, which is right for three things
|
||||
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
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 IsVisible="{Binding IsHostsScreen}">
|
||||
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SFTP ============ -->
|
||||
<!--
|
||||
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}" />
|
||||
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
|
||||
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
|
||||
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
|
||||
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
|
||||
a host header and a status bar around the screen's own content, and a 300px sidebar.
|
||||
|
||||
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
|
||||
not the WebView", which is the occlusion question, and which mode the switcher is on is this
|
||||
wrapper's own separate question.
|
||||
|
||||
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
|
||||
this application, and building one is out of this wave's scope; see the notes' own open
|
||||
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
|
||||
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
|
||||
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
|
||||
exists that does not.
|
||||
-->
|
||||
<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
|
||||
process tree, so twenty tabs would cost twenty of them.
|
||||
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 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
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
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.
|
||||
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
|
||||
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
|
||||
already was, and this is the recorded reason rather than an oversight.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
<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>
|
||||
|
||||
<!-- ============ 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>
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -7,159 +7,295 @@
|
||||
<!--
|
||||
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
|
||||
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
|
||||
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
|
||||
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
|
||||
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 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
|
||||
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
|
||||
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
|
||||
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
|
||||
is a question you would otherwise have to open the screen to answer.
|
||||
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
|
||||
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
|
||||
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
|
||||
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
|
||||
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
|
||||
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
|
||||
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
|
||||
number on every row; a zero beside Logs would be a fact this application never computed.
|
||||
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
|
||||
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
|
||||
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. ──────────────────────────────────────
|
||||
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
|
||||
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
|
||||
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
|
||||
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
|
||||
in. See MainWindowViewModel.IsVaultsTab.
|
||||
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
|
||||
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
|
||||
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
|
||||
which read the same three flags the switcher above lights — so the row and the segment can never name
|
||||
two different modes between them.
|
||||
|
||||
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
|
||||
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
|
||||
counting buckets is going anyway.
|
||||
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
|
||||
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||
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
|
||||
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
|
||||
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
|
||||
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.
|
||||
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||
Button carries no state to disagree with the screen that is actually showing.
|
||||
-->
|
||||
|
||||
<Border Width="190" Background="{StaticResource Sidebar}"
|
||||
<Border Width="255" Background="{StaticResource DeepChrome}"
|
||||
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">
|
||||
|
||||
<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>
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="18">
|
||||
|
||||
<!--
|
||||
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
|
||||
with the list under it. It no longer does: buckets have their own entry above, so this number and
|
||||
this screen now count the same things.
|
||||
The segmented switcher. Three equal columns in a Grid rather than a StackPanel with Width="*" on
|
||||
each child — Avalonia gives a StackPanel's children their desired size, not an even split, and the
|
||||
mock's three segments are exactly a third each.
|
||||
-->
|
||||
<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">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚿" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
|
||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
|
||||
<Border Classes="navtrack">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding IsSshShowing}"
|
||||
Command="{Binding ShowTerminalCommand}"
|
||||
ToolTip.Tip="The terminal, and every shell you have open">
|
||||
<TextBlock Text="SSH" />
|
||||
</Button>
|
||||
<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>
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
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>
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="❯" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
|
||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<!--
|
||||
The mode-dependent first row: Terminal, Files or Buckets, matching whichever segment above is
|
||||
lit. Active follows !IsVaultsTab rather than a property of its own — that is already exactly
|
||||
"the terminal surface, or the files screen, or the buckets screen", which is what this row is.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding !IsVaultsTab}"
|
||||
Command="{Binding ShowFirstRailItemCommand}"
|
||||
ToolTip.Tip="The terminal while SSH is selected, or whichever file picker SFTP or S3 chose">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="{Binding FirstRailItemIcon}" />
|
||||
<TextBlock Classes="navlabel" Text="{Binding FirstRailItemLabel}" />
|
||||
</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">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="≡" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<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">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Hosts" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
|
||||
the server authorises against; it is no longer something anybody has to make, name or think about,
|
||||
so the rail names the thing people came for. See VaultsViewModel.
|
||||
<!--
|
||||
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
|
||||
passwords; see ShellScreen.Keychain for the name that did not move with the label.
|
||||
-->
|
||||
<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.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}"
|
||||
ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
|
||||
<!--
|
||||
No count. The vault list is the session's and could be counted here — but who is in each one
|
||||
is read from the server when the screen is opened, not on unlock, and a number naming only
|
||||
half of what the screen is about would be the one figure on this list that has to be
|
||||
explained.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<!--
|
||||
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
|
||||
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Pins" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
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>
|
||||
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
|
||||
Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
|
||||
<!--
|
||||
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
|
||||
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
|
||||
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
|
||||
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
|
||||
switcher replaced, which sat directly above that rectangle and had to select a page before opening
|
||||
one for exactly that reason.
|
||||
-->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,9 +1,85 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
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
|
||||
{
|
||||
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 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.
|
||||
|
||||
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
|
||||
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}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
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
|
||||
same one the launcher icon carries, so the three cannot drift.
|
||||
24 pixels and radius 7, the design's own tile — up from v3's 20/6, which was this bar's own
|
||||
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"
|
||||
Foreground="{StaticResource AccentInk}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<!--
|
||||
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
||||
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
||||
19 bold at -0.2 tracking, the design's own numbers. Named, because a nightly says so here — see
|
||||
the code-behind, where the release build is exactly what this markup says and nothing changes
|
||||
for it.
|
||||
-->
|
||||
<!--
|
||||
Named, because a nightly says so here. See the code-behind: the release build is what this
|
||||
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" />
|
||||
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="19" FontWeight="Bold"
|
||||
LetterSpacing="-0.2" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
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
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
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"
|
||||
<Button Grid.Column="1" Classes="flat search" MaxWidth="514" Height="35"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10,0">
|
||||
<Border Classes="searchpill" CornerRadius="10" Padding="16,0,8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" Text=""
|
||||
FontSize="15" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
"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
|
||||
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"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
|
||||
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
|
||||
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
|
||||
Background="{StaticResource KbdChip}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -112,20 +109,28 @@
|
||||
<Border Width="1" Height="16" Background="{StaticResource Border}"
|
||||
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">
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||
ToolTip.Tip="Minimise">
|
||||
<TextBlock Text="–" FontSize="13" Foreground="{StaticResource TextDim}"
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
||||
ToolTip.Tip="Maximise">
|
||||
<TextBlock Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
||||
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>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -35,40 +35,71 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
A directory used to be marked by colour alone — this application shipped no icon set when that rule
|
||||
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
|
||||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
||||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
||||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
||||
world-writable executable show both facts instead of one winning an argument.
|
||||
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
|
||||
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
|
||||
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
|
||||
screen no longer borrows it.
|
||||
|
||||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
||||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
||||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
||||
A second colour still comes from the mode, and it still overrides the first rather than competing with
|
||||
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
|
||||
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">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<!--
|
||||
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
|
||||
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
|
||||
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
|
||||
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.
|
||||
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
|
||||
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
|
||||
execute bit off a file's own mode and nothing else's.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.exec">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</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
|
||||
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">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</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>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
@@ -99,14 +164,34 @@
|
||||
-->
|
||||
<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">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<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
|
||||
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.ItemsPanel>
|
||||
@@ -116,14 +201,20 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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}"
|
||||
CommandParameter="{Binding Path}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||||
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
|
||||
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>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -150,25 +241,37 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</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" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||||
<!--
|
||||
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
|
||||
glyph centred with no reserved width drifts as the font's own advance width varies between folder
|
||||
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>
|
||||
|
||||
<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}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="12"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -219,26 +322,41 @@
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||
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">
|
||||
<!--
|
||||
Which kind of remote this pane is for, in the place the local pane names itself. It is the
|
||||
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
|
||||
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
|
||||
difference between a directory tree and a flat namespace with inferred folders in it.
|
||||
-->
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding RemotePath}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
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>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -246,27 +364,22 @@
|
||||
<!--
|
||||
◆ 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
|
||||
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
|
||||
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
|
||||
off the edge. The layout suite would have caught it — which is the point of stating the number
|
||||
here, so the next thing added to either row is measured against it rather than tried.
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
|
||||
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
||||
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||
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.
|
||||
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
|
||||
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
|
||||
queue's own strip, which has the width for one.
|
||||
The status line still is not here, for the reason the comment this replaced gave: there is no room
|
||||
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
|
||||
the queue's own strip.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
|
||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Grid>
|
||||
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
@@ -277,6 +390,11 @@
|
||||
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
|
||||
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}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
@@ -289,11 +407,12 @@
|
||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||||
<WrapPanel>
|
||||
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</StackPanel>
|
||||
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -324,37 +443,48 @@
|
||||
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,
|
||||
"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"
|
||||
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}" />
|
||||
</StackPanel>
|
||||
</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" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
|
||||
<!--
|
||||
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
|
||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||
-->
|
||||
<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>
|
||||
|
||||
<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}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="12"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<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>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -392,10 +522,11 @@
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
<!--
|
||||
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
|
||||
application ships no icon set — see the note on colour at the top of this file — so a glyph in
|
||||
a rounded square is what an icon is here, and ▤ is already the one that means "a place things
|
||||
are kept".
|
||||
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
|
||||
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
|
||||
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
|
||||
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"
|
||||
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
@@ -545,94 +676,126 @@
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The queue ============ -->
|
||||
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!--
|
||||
============ The TRANSFERS strip ============
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
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">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
|
||||
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
|
||||
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
|
||||
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
|
||||
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"
|
||||
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
|
||||
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
|
||||
width. Only while something is open — the other half of the time it is inside the invitation
|
||||
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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
|
||||
clause of the sentence to its left.
|
||||
-->
|
||||
<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>
|
||||
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding Progress}" />
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
|
||||
IsVisible="{Binding !HasTransfers}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
<TextBlock Grid.Column="2" Classes="mono transferstatus"
|
||||
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
|
||||
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
|
||||
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
|
||||
Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<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>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
|
||||
ToolTip.Tip="Stop this transfer"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
|
||||
ToolTip.Tip="{Binding RetryLabel}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
|
||||
ToolTip.Tip="Discard this row"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
||||
|
||||
</Grid>
|
||||
</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
|
||||
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",
|
||||
};
|
||||
|
||||
/// <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" />
|
||||
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 "
|
||||
+ "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>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Authentication;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
|
||||
|
||||
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>
|
||||
/// A record rebuilt per change rather 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.
|
||||
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
|
||||
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
|
||||
/// 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>
|
||||
/// <param name="VaultId">The vault.</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>
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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;
|
||||
|
||||
/// <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);
|
||||
|
||||
_ = 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>
|
||||
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
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]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
vault.ManualStatus = string.Empty;
|
||||
}
|
||||
|
||||
// ---- The desktop's fixed tabs ----
|
||||
// ---- The rail's own page grouping ----
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
|
||||
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
|
||||
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
|
||||
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
|
||||
/// which is the screen you are looking at.
|
||||
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
|
||||
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
|
||||
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
|
||||
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
|
||||
/// 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>
|
||||
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
|
||||
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
|
||||
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
|
||||
/// 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".
|
||||
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
|
||||
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
|
||||
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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) =>
|
||||
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
||||
|
||||
/// <summary>
|
||||
/// Which page the Vaults tab returns to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
|
||||
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
|
||||
/// 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;
|
||||
// ---- The rail's segmented switcher and its mode-dependent first entry ----
|
||||
//
|
||||
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
|
||||
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
|
||||
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
|
||||
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
|
||||
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
|
||||
|
||||
/// <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]
|
||||
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 ----
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
|
||||
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
|
||||
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
|
||||
/// than the keyring can.
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it — the
|
||||
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
|
||||
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
|
||||
/// opened, so it cannot outlive the session any more than the keyring can.
|
||||
/// </remarks>
|
||||
private VaultVisibility? visibility;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
|
||||
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
|
||||
ServerUrl = profile.ServerUrl;
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
||||
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
.RefreshAsync(ServerUrl, cancellationToken)
|
||||
.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;
|
||||
|
||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
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>
|
||||
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
||||
/// </summary>
|
||||
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
AccountName = null;
|
||||
Email = null;
|
||||
Passphrase = string.Empty;
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = null;
|
||||
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
// 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
|
||||
// object would otherwise leak.
|
||||
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
|
||||
// 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.
|
||||
@@ -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
|
||||
// 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.
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
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 WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||
RaiseTerminalState();
|
||||
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
});
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <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
|
||||
/// sites for the list of moments that counts as.
|
||||
/// </para>
|
||||
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
RaiseSessionState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
host.IsConnected = Tabs.Any(
|
||||
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
|
||||
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pin strip draws at all.
|
||||
/// Whether the v5b session sidebar draws at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
|
||||
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
|
||||
/// the rare frame between selecting a terminal tab and navigating away from it.
|
||||
/// <para>
|
||||
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface && HasActiveTabPinnedPaths</c>
|
||||
/// — 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>
|
||||
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
|
||||
internal bool ShowsQuickAccessSidebar =>
|
||||
(IsTerminalSurface && SelectedTab is not null)
|
||||
|| (IsTransfersShowing && Transfers.IsConnected);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||
/// </summary>
|
||||
/// <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
|
||||
/// 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>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasLiveSessions));
|
||||
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
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
|
||||
// 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.
|
||||
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
|
||||
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
|
||||
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
|
||||
// would answer correctly the next time anything else asked it.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
|
||||
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
|
||||
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
|
||||
// off a terminal tab to a page screen, even though the property itself would answer correctly the
|
||||
// next time anything else asked it.
|
||||
RaiseSessionState();
|
||||
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsBucketsShowing));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
|
||||
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
|
||||
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||
// 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
|
||||
// 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>
|
||||
/// <remarks>
|
||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
||||
/// otherwise still be there offering to open it again.
|
||||
/// Off while the move panel or the delete confirmation is up, either of which takes their place — the
|
||||
/// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
|
||||
/// that opened one would otherwise still be there offering to open it again.
|
||||
/// </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>
|
||||
/// <remarks>
|
||||
@@ -334,6 +357,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <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]
|
||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -347,6 +378,55 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
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>
|
||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||
/// </summary>
|
||||
@@ -515,6 +595,15 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
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(CanInsert));
|
||||
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>
|
||||
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" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
|
||||
@@ -40,6 +40,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
@@ -84,6 +93,9 @@ internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
||||
|
||||
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 Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
@@ -132,8 +144,64 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
? Transfer.RemotePath
|
||||
: 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;
|
||||
|
||||
/// <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>
|
||||
/// What the row says about where it has got to.
|
||||
/// </summary>
|
||||
@@ -192,6 +260,9 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
OnPropertyChanged(nameof(Percent));
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(StatusWord));
|
||||
OnPropertyChanged(nameof(IsInProgress));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsFinished));
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
@@ -297,6 +368,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||
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
|
||||
// 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>
|
||||
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;
|
||||
|
||||
/// <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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
|
||||
|
||||
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>
|
||||
internal string Description => key.Secret switch
|
||||
{
|
||||
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
|
||||
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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>
|
||||
internal enum DeletionTarget
|
||||
{
|
||||
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
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>
|
||||
/// The selected row of the vault table.
|
||||
/// </summary>
|
||||
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
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>
|
||||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
||||
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
|
||||
var unreadable = 0;
|
||||
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
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
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();
|
||||
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
|
||||
var unreadable = 0;
|
||||
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
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
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();
|
||||
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
|
||||
"TAG",
|
||||
tag.Description,
|
||||
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>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
|
||||
"SSH KEY",
|
||||
key.Description,
|
||||
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",
|
||||
credential.Description,
|
||||
credential.Badge,
|
||||
credential.HasUnsyncedChanges));
|
||||
credential.HasUnsyncedChanges)
|
||||
{
|
||||
VaultBadge = credential.VaultBadge,
|
||||
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AddTagRows();
|
||||
AddBucketRows();
|
||||
ApplyItemFilter();
|
||||
|
||||
// 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.
|
||||
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
|
||||
RebuildSelectedItemUsage(value);
|
||||
|
||||
// Every kind this table can delete, because one selection covers all of their lists.
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Key, 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>
|
||||
/// 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
|
||||
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasUnreadableItems));
|
||||
|
||||
Reference in New Issue
Block a user