Public Access
Compare commits
9
Commits
82966af37b
...
7d64027972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d64027972 | ||
|
|
281e828e25 | ||
|
|
dca2e888d6 | ||
|
|
2ba7c14e35 | ||
|
|
c3ef4bd8b4 | ||
|
|
369109dd4e | ||
|
|
06f9dcfc27 | ||
|
|
bea0279937 | ||
|
|
49645db680 |
@@ -165,6 +165,84 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
||||
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
||||
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
||||
>
|
||||
> ## The desktop's v5 — hosts, restyled, and two reversals
|
||||
>
|
||||
> A fifth pass, and the first that undoes as much of its own predecessors as it adds: a re-theme both heads
|
||||
> share, pinned folders as a full feature, a last-connected time read off the connection log, and two pieces
|
||||
> of earlier passes put back on the user's own instruction rather than kept.
|
||||
>
|
||||
> **The palette went purple, and three fonts are embedded that were only ever named before.** The shared
|
||||
> scheme moves from v3's near-black-and-blue to a purple accent (`#5D42DE`, with a gradient and a glow of its
|
||||
> own) over the same near-black canvas, and both heads recolour together — `Palette.axaml` is shared, and a
|
||||
> colour is not allowed to mean something different on the two of them. What changed on the desktop alone is
|
||||
> the type: Montserrat, JetBrains Mono and Material Icons are embedded as `AvaloniaResource` in the Shell
|
||||
> project rather than requested by name and left to whatever the machine happens to have, the way `WithInterFont`
|
||||
> alone used to leave Inter registered but unused. Montserrat is the default sans now, with Inter kept as its
|
||||
> fallback rather than the whole answer; `MonoFont` gains JetBrains Mono at the front of its own list, ahead of
|
||||
> the system-mono stack that is still behind it for a glyph JetBrains Mono does not cover. Android recolours
|
||||
> with the shared palette and keeps its own default sans — fonts are per-head by decision, only colour is
|
||||
> shared.
|
||||
>
|
||||
> **Flat sections replace the grid of group cards and the breadcrumb trail — reversing v3, on the user's own
|
||||
> approval rather than a defect found in it.** v3's grid held one level of the group tree at a time, opened by
|
||||
> a double-click, with a trail above it saying where you were; that model is retired outright rather than
|
||||
> folded into anything; every group is now a heading on one flat board, in label order, exactly the flattening
|
||||
> the phone's `SidebarRows` has always drawn — a headerless board for a groupless keychain, "No group" first
|
||||
> where there is one, a chevron per heading and one Collapse All. Nothing here was found broken; the grid did
|
||||
> what v3 asked for. The user asked for the phone's shape on the desktop too, and decision 2 in
|
||||
> `hosts-v5-design-spec.md` is that request recorded.
|
||||
>
|
||||
> **Tag chips and a mono address line are back on the card — reversing v4, also by approval and not by
|
||||
> defect.** v4 had replaced both with a two-line `ssh, root, pci, eu-west-1` subtitle and moved the address to
|
||||
> the tooltip alone, on the reasoning that a card read while scanning forty machines wants the kind of machine
|
||||
> more than its address. v5's own mock draws chips and an address on every card, and the user chose the mock's
|
||||
> arrangement over v4's when the two were put side by side — so the card now carries both: the full address is
|
||||
> still on the tooltip, as v4 left it, and a truncated mono copy sits under the name as well.
|
||||
>
|
||||
> **Pinned folders are a full feature, not a field with nowhere to be edited.** `HostSecret.PinnedPaths` is an
|
||||
> ordered, deduplicated list merged per path the way `TagIds` is merged per tag, so two machines pinning
|
||||
> different folders on one host both keep theirs. The drawer's QUICK ACCESS section edits it — a row per pin
|
||||
> with a close box, an add field and button, staged on `VaultViewModel.EditorPinnedPaths` the way the tag
|
||||
> picker stages `editorTagIds` — the card shows a pin-count badge once there is at least one, and the detail
|
||||
> pane repeats the list read-only in its own fieldrow idiom. Clicking a pin above a connected terminal opens
|
||||
> the SFTP tab with the remote pane navigated straight to that path, through the same connect path "Browse
|
||||
> files" already used — a second authenticated connection, exactly as every SFTP session here is.
|
||||
>
|
||||
> **Last connected is read off the synced connection log, and it is deliberately narrow about when.**
|
||||
> `max(StartedAt)` per `HostId` is computed on the hosts screen's own activation and again when a session ends,
|
||||
> then restrung into words on a one-minute tick for as long as the screen stays visible — never on the sync
|
||||
> loop, which is the same rule `LogsViewModel` states for the log itself: nobody is watching their own
|
||||
> connection from an hour ago update on a screen they are not looking at. A connected host prints nothing here;
|
||||
> the green dot already answers "right now", and printing an age beside it would be answering a question about
|
||||
> a session that is not the one still running. **It is scoped to the active vault alone**, the same limitation
|
||||
> `LogsViewModel` and `MainWindowViewModel.RecentConnections` already carry — a host filed in a second vault
|
||||
> this session can read gets no ago-text until something on this screen reads more than one vault's connection
|
||||
> history, which nothing does yet. And a session ending can, briefly, still show the previous entry: the
|
||||
> recorder's own write is queued onto a background task rather than made inline, so a re-read landing before
|
||||
> that write drains reads the log as it stood before the session it is reporting on closed. It self-heals on
|
||||
> the next activation or the next session end, and closing the gap outright was judged not worth blocking a
|
||||
> screen transition on the recorder's queue.
|
||||
>
|
||||
> **Quick connect grew to match, and quietly answers a different question than the mock's own row does.** The
|
||||
> card widened from 520 to 640, gained the mock's `>_` prompt styling and a live dot per row the way the hosts
|
||||
> board's own two-state dot works. What it does not draw is the mock's SSH/SFTP kind column — every palette
|
||||
> connection here is SSH, so a constant printed as though it varied would be exactly the kind of decoration
|
||||
> this document has refused before (the terminal's session footer, the snippet's kind badge); the auth word
|
||||
> (credential/key/password) answers a question that is actually different per row instead.
|
||||
>
|
||||
> | v5 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
|
||||
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. |
|
||||
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
||||
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
||||
> | QUICK ACCESS's editor, on the phone | **Deferred; the data is not.** `HostSecret.PinnedPaths` is shared, synced and merged on both heads, so a pin made on the desktop reaches the phone and back — Android just has nowhere yet to add or remove one itself. |
|
||||
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
||||
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
||||
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
||||
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
|
||||
|
||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||
@@ -301,7 +379,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
|
||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||
|
||||
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
||||
|
||||
@@ -141,8 +141,8 @@ Nothing calls it with a proxy yet. That is deliberate and it is one step wide: s
|
||||
- The hop's own connection, and a `ForwardedPortDynamic("127.0.0.1", 0)` started on it, whose `BoundPort`
|
||||
becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice.
|
||||
- `VaultViewModel` resolves `JumpHostIds` to hosts in the same vault, applying group inheritance per hop the
|
||||
way the target already gets it, and refuses a chain that crosses a vault — the same refusal
|
||||
`RefusesTheDrop` and the group picker already make, for the same reason.
|
||||
way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group
|
||||
picker already makes, for the same reason.
|
||||
- ◆ **The target's address is resolved at the last hop, not here.** A SOCKS CONNECT names the target and the
|
||||
bastion resolves it, so what has to be stored on the host is the address *the bastion* can reach — which
|
||||
is what an `ssh_config` means by `HostName` beside a `ProxyJump`, and what the importer is already
|
||||
|
||||
@@ -110,11 +110,14 @@
|
||||
<!--
|
||||
A chip: a bordered scrap of text stating one fact. The design uses them for tags, roles, scopes and
|
||||
shortcut hints, which have nothing in common except that each is a noun the eye should be able to skip.
|
||||
|
||||
CornerRadius 6 rather than v2's 4: v3 draws every small chip — a tag, a pin badge — at 5-6, distinct
|
||||
from the 10-12 the cards and fields moved to, so a chip keeps reading as a chip beside either.
|
||||
-->
|
||||
<Style Selector="Border.chip">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
@@ -147,7 +150,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
@@ -185,7 +188,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
@@ -199,25 +202,51 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The primary action, and since v3 filled with a gradient rather than a flat colour: the design draws
|
||||
every accent button top-to-bottom from a lighter violet into the accent proper, and lifts it off the
|
||||
surface with a soft violet glow instead of a border. Both live in Palette.axaml as AccentGradient and
|
||||
AccentGlow, named there rather than here for the reason every other accent value is — see the remark
|
||||
on the pair in that file.
|
||||
|
||||
BoxShadow is set here rather than on the Button itself: this rule already targets the template's
|
||||
ContentPresenter for Background for the Fluent-override reason explained above, and ContentPresenter
|
||||
carries its own BoxShadow property in Avalonia 11, so the glow and the fill live on the same element
|
||||
and move together.
|
||||
-->
|
||||
<Style Selector="Button.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="Opacity" Value="0.85" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
</Style>
|
||||
<!--
|
||||
A disabled accent button keeps its shape and loses its fill. Fluent's disabled state greys the
|
||||
foreground and leaves the background, which on a green slab is unreadable rather than merely dim.
|
||||
foreground and leaves the background, which on a purple slab is unreadable rather than merely dim.
|
||||
|
||||
BoxShadow is cleared here too, and that is new alongside the gradient: the base rule now sets a glow
|
||||
that reads as "this is lit and wants pressing", which is the opposite of what a disabled control
|
||||
should say. Left unset it would still apply — the base rule's Setter is still in effect wherever a
|
||||
more specific one does not override it — so the glow has to be named here, as "none", to go away.
|
||||
|
||||
◆ "none" RATHER THAN THE EMPTY STRING. The remark this replaced claimed an empty string parses to "no
|
||||
shadows", and it does not on the Avalonia this project builds against — BoxShadowsTypeConverter throws
|
||||
an InvalidCastException on it instead, which failed silently at style-sheet load time in nothing that
|
||||
exercises this rule's own screen and loudly in every headless layout test that lays out any window at
|
||||
all, because Application.Styles is global. "none" is the literal this converter does accept for an
|
||||
empty BoxShadows.
|
||||
-->
|
||||
<Style Selector="Button.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderFaint}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BoxShadow" Value="none" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
@@ -225,7 +254,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
@@ -530,7 +559,7 @@
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource Accent}" />
|
||||
@@ -556,6 +585,30 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE ADDRESS FIELD, the one input the host drawer draws with an accent border rather than the
|
||||
ordinary grey hairline — the mock's own choice, kept, because it is the one field a session cannot be
|
||||
opened without. Taller than the drawer's other fields too, at 42 rather than the TextBox default.
|
||||
|
||||
No drawn caret: the mock blinks one inside the field's own text, but this is a real TextBox with a real
|
||||
one already, so painting a second would either fight the true caret for the same pixel or lie about
|
||||
where typing lands. See HostDrawer.axaml's ADDRESS section, on both the pane and the editor.
|
||||
|
||||
Declared after the base TextBox rules — including :pointerover and :focus — for the reason this file
|
||||
states three times already for Button.tab, Border.rowmark and the tiles list: Avalonia has no
|
||||
specificity, and a rule declared before an exception it overrides does nothing at all. Placed here
|
||||
rather than beside them so both still read as one un-interrupted block of "what every TextBox looks
|
||||
like" with the one exception after it, not threaded through the middle.
|
||||
-->
|
||||
<Style Selector="TextBox.address">
|
||||
<Setter Property="Height" Value="42" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.address /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -613,24 +666,26 @@
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 232 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 304, which is 522. The
|
||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 490. A WrapPanel fits floor(490 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 235.
|
||||
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 227.
|
||||
|
||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
||||
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
|
||||
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
|
||||
the size this application guarantees, which is the shape the cards exist to avoid.
|
||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="232" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -658,7 +713,7 @@
|
||||
chosen card that also drew an accent outline would be indistinguishable from the selected one, and the
|
||||
question "which of these is the drawer showing" would have no answer. Declared after that rule anyway,
|
||||
because it sets the same Background and Avalonia settles two matching rules by declaration order —
|
||||
the trap this file records for Button.tab, Border.rowmark and the drop target below.
|
||||
the trap this file records for Button.tab and Border.rowmark.
|
||||
-->
|
||||
<Style Selector="Border.tile.chosen">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
@@ -720,26 +775,33 @@
|
||||
|
||||
The hosts drawer is a column of grouped fields rather than a run of labelled controls — address here,
|
||||
the general facts here, the connection and its credentials here — which is what the design draws and
|
||||
what a 304-pixel column needs to stay readable: three cards is three things to find, where fourteen
|
||||
stacked controls is fourteen.
|
||||
what a 320-pixel column needs to stay readable: three cards is three things to find, where fourteen
|
||||
stacked controls is fourteen. v5 widened the column from 304 to 320 for the ADDRESS field's own
|
||||
breathing room, and moved every affected number in this file and its neighbours with it.
|
||||
|
||||
Raised on Sidebar, which is the one pairing that works in this column. Panel and Chrome are the same
|
||||
value as each other and near enough Sidebar to vanish against it, and Field is the well a control sits
|
||||
in — a card drawn in it would read as one enormous input.
|
||||
value as each other and near enough Sidebar to vanish against it. Field no longer reads as a well
|
||||
beneath either: v3 gives a field and a card the same fill, where a field used to sit visibly below
|
||||
one — see the remark on Field in Palette.axaml. What still marks a field as a field here is its
|
||||
border and its focus state, not a darker surface underneath it.
|
||||
-->
|
||||
<Style Selector="Border.section">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A card's heading, and deliberately not TextBlock.label. That style is the small tracked-out capitals
|
||||
this design uses for a *section of a screen* — GROUPS, HOSTS, the vault's categories — and three of
|
||||
them inside a 304-pixel column would claim the same weight as the screen headings beside it. These are
|
||||
them inside a 320-pixel column would claim the same weight as the screen headings beside it. These are
|
||||
captions on a card, so they are sentence case at the body size.
|
||||
|
||||
Still what the group editor's two cards use — GROUP, and what its hosts inherit — because that editor
|
||||
is not the mock this file's ADDRESS/GENERAL/CONNECTION/QUICK ACCESS labels were drawn from and nothing
|
||||
asked it to match a screen it is not on.
|
||||
-->
|
||||
<Style Selector="TextBlock.sectionhead">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
@@ -747,6 +809,24 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE OTHER CARD HEADING, v5's own, and the two are not interchangeable.
|
||||
|
||||
TextBlock.sectionhead above is sentence case at the body size — a caption on a card, the same weight
|
||||
the design's own prose is drawn at. This is the v5 mock's own idiom for the same job, on the one screen
|
||||
it was drawn for: ADDRESS, GENERAL, CONNECTION and QUICK ACCESS in the host drawer, tracked-out capitals
|
||||
at a third the size. Both exist because the group editor's cards were not restyled to the v5 mock — see
|
||||
the remark above — so the drawer now carries a heading in each of the two idioms depending on which
|
||||
panel is showing, and a single class could not have been both.
|
||||
-->
|
||||
<Style Selector="TextBlock.sectionlabel">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A ROW IN THE DETAIL PANE, AND IT IS A BUTTON THAT LOOKS LIKE A FIELD.
|
||||
|
||||
@@ -786,6 +866,19 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The detail pane's own ADDRESS row, matched to TextBox.address above: taller, and bordered in accent
|
||||
rather than the ordinary hairline even under the pointer — declared after the :pointerover rule for the
|
||||
reason that class's own remark gives, so it wins in both states rather than only the resting one.
|
||||
-->
|
||||
<Style Selector="Button.fieldrow.address">
|
||||
<Setter Property="MinHeight" Value="42" />
|
||||
</Style>
|
||||
<Style Selector="Button.fieldrow.address /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The glyph at the head of such a row. A fixed width so that four rows in a card line their text up, and
|
||||
the faintest step in the ramp because it is a mark to skip rather than a fact to read: what the row
|
||||
@@ -831,12 +924,14 @@
|
||||
decoration that reads as information.
|
||||
|
||||
A style rather than a value converter so that the two colours are the palette's, once. A converter
|
||||
would have had to name #3DDC97 in C#, which is how a green ends up one shade off in the fourth place
|
||||
it appears.
|
||||
would have had to name the hex value in C#, which is how a green ends up one shade off in the fourth
|
||||
place it appears — and it has already moved once, from v2's #3DDC97 to this design's #34D399, without
|
||||
touching this file.
|
||||
|
||||
Live rather than Accent, and that is a v2 change with meaning rather than a recolour. The accent is
|
||||
blue now, and the green survives as the colour of a fact — see the remark on Live in Palette.axaml.
|
||||
Filling this with Accent would say a connected host is a thing to press.
|
||||
Live rather than Accent, and that is a v2 change with meaning rather than a recolour, one that survives
|
||||
the accent moving again from blue to purple in v3: the green is the colour of a fact regardless of what
|
||||
hue the accent currently is — see the remark on Live in Palette.axaml. Filling this with Accent would
|
||||
say a connected host is a thing to press.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot">
|
||||
<Setter Property="Width" Value="6" />
|
||||
@@ -860,31 +955,39 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The group card a host is about to be dropped on. A wash rather than the accent strip a selection
|
||||
carries, because it is not a selection: it says "let go here", it lasts as long as the pointer is over
|
||||
the card, and it has to be legible on top of whatever that card already looks like — including the
|
||||
selected one, which is the group the grid is currently narrowed to and so often the one being dragged
|
||||
onto.
|
||||
Quick connect's own rows. In this file rather than QuickConnect.axaml, the same reason every other
|
||||
list style is here: that view is the one caller, but what a selected row looks like is a palette
|
||||
answer and belongs beside every other one.
|
||||
|
||||
Painted on the tile rather than on the item's own presenter, which is where this used to go when the
|
||||
target was a full-width heading. A wash behind a 10-pixel-rounded tile is a square of colour with the
|
||||
card floating on it — the same four grey corners the tiles list clears the theme's own hover and
|
||||
selection brushes to avoid. Declared after that rule and after the selected-tile rule, because
|
||||
Avalonia has no specificity and settles two matching rules by declaration order.
|
||||
|
||||
◆ THE DOUBLED BORDER IS THE MARK, not the fill, and that is what the move to the tile forced. The fill
|
||||
a selected tile already carries is the strongest one this palette has, so a target that only filled
|
||||
would be invisible on the card the grid is narrowed to — which is the card a host most often has to be
|
||||
dragged off, into another group. Two pixels of accent is legible on both.
|
||||
|
||||
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
|
||||
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
|
||||
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
|
||||
Classes="tiles" on that ListBox clears the theme's own full-bleed selection wash first — see the
|
||||
remark above "A LIST OF TILES" — for the same reason: a wash behind a 9-pixel-rounded row shows as
|
||||
square corners peeking out from behind it. This is the one list in the application whose selected row
|
||||
fills solid rather than tinting or carrying a strip, which is the mock's own choice and reads as
|
||||
right here specifically: this palette is choosing a machine to connect to *now*, not one it is idly
|
||||
browsing, and nothing else in the window asks to look this insistent.
|
||||
-->
|
||||
<Style Selector="ListBoxItem.droptarget Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
<Style Selector="Border.qcrow">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.qcrow">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected Border.qcrow, ListBoxItem:selected:pointerover Border.qcrow">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.qcrow-text">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.qcrow-subtext">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
|
||||
<!--
|
||||
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
|
||||
accent", which is what a filled row is. Naming it that way rather than #FFFFFF is what keeps this
|
||||
rule true if the accent itself ever moves to a hue where white stops being the readable choice.
|
||||
-->
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -925,7 +1028,7 @@
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
@@ -933,7 +1036,7 @@
|
||||
<Style Selector="NumericUpDown">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
|
||||
@@ -93,17 +93,25 @@ internal static class Program
|
||||
|
||||
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <c>WithInterFont</c> registers Inter; it does not make it the default, and until this line the
|
||||
/// application shipped a font it then declined to use — falling back to Segoe UI on Windows and to
|
||||
/// whatever fontconfig offered on Linux. Almost nothing visible moves, because App.axaml sets
|
||||
/// <c>MonoFont</c> on essentially every control that draws text, but the fallback behind those is now
|
||||
/// a font that travels with the build rather than one the machine is assumed to have. The layout
|
||||
/// suite pins the same family, and has to: see HeadlessApp.
|
||||
/// <c>WithInterFont</c> still registers Inter, and it is still what the layout suite pins: see
|
||||
/// HeadlessApp. What changed is which face answers when nothing more specific is asked for. The v5
|
||||
/// design specifies Montserrat, and unlike the substitution this comment used to explain, Montserrat
|
||||
/// now ships embedded in the shell project rather than being requested by a name the machine may not
|
||||
/// have — see DodoSSH.Client.Shell/Assets/Fonts and its csproj entry. Inter stays registered and is
|
||||
/// named as a fallback, so a glyph Montserrat does not cover still resolves to a font that travels
|
||||
/// with the build rather than to whatever the platform's default sans happens to be.
|
||||
/// </remarks>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<DodoSshApp>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.With(new FontManagerOptions { DefaultFamilyName = "avares://Avalonia.Fonts.Inter/Assets#Inter" })
|
||||
.With(new FontManagerOptions
|
||||
{
|
||||
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||
FontFallbacks =
|
||||
[
|
||||
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||
],
|
||||
})
|
||||
.LogToTrace();
|
||||
}
|
||||
|
||||
@@ -31,16 +31,23 @@
|
||||
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
|
||||
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
||||
300-pixel column beside a grid is 300 pixels the grid could have had.
|
||||
320-pixel column beside a grid is 320 pixels the grid could have had.
|
||||
|
||||
◆ AND IT IS ASKED FOR NOW, rather than arriving with a selection. Touching a card used to open this,
|
||||
which charged the width of the pane for the act of choosing; the pencil on a card is the asking. See
|
||||
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
|
||||
|
||||
── THE THREE PANELS ARE THE SAME THREE CARDS ────────────────────────────────────────────────────────
|
||||
Address, General, Connection — first as rows stating what the host is, then as boxes for changing it.
|
||||
The detail pane's rows are buttons that open the editor, so a box that looks editable turns out to be,
|
||||
one step along; see Button.fieldrow in App.axaml for why they are not inputs that save as you type.
|
||||
── THE TWO PANELS ARE THE SAME CARDS ────────────────────────────────────────────────────────────────
|
||||
Address, General, Connection, and — since v5 — Quick access: first as rows stating what the host is,
|
||||
then as boxes for changing it. QUICK ACCESS is the one card the detail pane draws conditionally rather
|
||||
than always: a host with no pinned paths has nothing to state, where an empty Address or General would
|
||||
be a fact about the host rather than an absent one. The detail pane's rows are buttons that open the
|
||||
editor, so a box that looks editable turns out to be, one step along; see Button.fieldrow in App.axaml
|
||||
for why they are not inputs that save as you type.
|
||||
|
||||
The editor's four cards carry v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — the
|
||||
mock's tracked-out capitals rather than TextBlock.sectionhead's sentence case; see TextBlock.sectionlabel
|
||||
in App.axaml and the remark on the editor's StackPanel below for why the detail pane did not move too.
|
||||
|
||||
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
|
||||
screen hands it over.
|
||||
@@ -62,7 +69,7 @@
|
||||
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
|
||||
-->
|
||||
|
||||
<Border Width="304" Background="{StaticResource Sidebar}"
|
||||
<Border Width="320" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
@@ -79,14 +86,19 @@
|
||||
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
|
||||
moment a host is created is a different question, and it is in the editor beside the name.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
|
||||
<Border Grid.Row="0" Padding="22,16" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
|
||||
<!--
|
||||
Title 20 bold and subtitle 12.5, the mock's own header sizes. What the subtitle says is
|
||||
unchanged — see DrawerSubtitle's own remarks below the header for why it is the vault's name
|
||||
rather than the mock's "Saving to X vault" sentence with a picker's chevron inside it.
|
||||
-->
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="20" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
|
||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding DrawerSubtitle,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
@@ -138,7 +150,7 @@
|
||||
<Panel>
|
||||
|
||||
<!-- ============ WHAT THIS HOST IS ============ -->
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||
|
||||
<!--
|
||||
============ ADDRESS ============
|
||||
@@ -155,7 +167,12 @@
|
||||
Foreground="{StaticResource AccentText}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Button Grid.Column="1" Classes="fieldrow" Margin="10,0,0,0"
|
||||
<!--
|
||||
Classes="address" is the v5 restyle: 42 tall and bordered in accent rather than the
|
||||
ordinary hairline, matched by TextBox.address in App.axaml, which the editor's own
|
||||
hostname box below wears too — the two are the same field, one step apart.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="fieldrow address" Margin="10,0,0,0"
|
||||
Command="{Binding EditSelectedHostCommand}"
|
||||
ToolTip.Tip="Opens this host's editor.">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
||||
@@ -338,6 +355,36 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ QUICK ACCESS (read-only) ============
|
||||
The folders pinned on this host, in the pane's own fieldrow idiom rather than the editor's rows
|
||||
with a close box on each one: one Button opens the editor on a press, same as every other row
|
||||
here, and what is inside it is a list rather than a single fact — the same shape the tags row
|
||||
above takes for the same reason. Hidden entirely rather than shown empty, for the reason Notes
|
||||
is: a card titled QUICK ACCESS with nothing under it would answer a question nobody asked here.
|
||||
-->
|
||||
<Border Classes="section" IsVisible="{Binding SelectedHost.HasPins}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="Quick access" Margin="0,0,0,2" />
|
||||
|
||||
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
|
||||
Margin="0,3,0,0" Text="" />
|
||||
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="mono" Text="{Binding}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
|
||||
|
||||
@@ -345,22 +392,35 @@
|
||||
|
||||
<!-- ============ THE HOST EDITOR ============ -->
|
||||
<!--
|
||||
The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
|
||||
The editor doubles as the "add" form; there is no separate dialog. Same four cards as the pane
|
||||
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
|
||||
looks like where it was pressed.
|
||||
looks like where it was pressed. QUICK ACCESS is the one card that always draws here even when
|
||||
the pane above hid it for having nothing to show: an empty editor section is where a first pin
|
||||
gets added, so it cannot be conditional on already having one.
|
||||
-->
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||
|
||||
<!--
|
||||
v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — replace sentence-case
|
||||
headings on this one panel, the editor, because it is the panel the mock actually draws; the
|
||||
detail pane above keeps TextBlock.sectionhead, its own established idiom, since the mock has no
|
||||
read-only view to restyle it against. See TextBlock.sectionlabel in App.axaml.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="sectionhead" Text="Address" />
|
||||
<TextBlock Classes="sectionlabel" Text="ADDRESS" />
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
||||
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="1" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
||||
<!--
|
||||
No drawn caret: this TextBox has a real one, and the mock's blinking bar is what a real
|
||||
caret looks like in a screenshot that cannot show motion. Classes="address" is the 42-tall,
|
||||
accent-bordered restyle shared with the detail pane's own Hostname row above.
|
||||
-->
|
||||
<TextBox Grid.Column="1" Classes="address" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
||||
PlaceholderText="hostname or address" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -368,9 +428,9 @@
|
||||
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
|
||||
<TextBlock Classes="sectionlabel" Text="GENERAL" Margin="0,0,0,2" />
|
||||
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" Height="40" />
|
||||
|
||||
<!--
|
||||
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
|
||||
@@ -413,7 +473,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch">
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
@@ -453,37 +513,48 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Add button is 40x40 and a glyph rather than the word ADD, matched to the field beside it —
|
||||
the v5 mock draws every add affordance in this drawer as a square icon button; QUICK ACCESS's
|
||||
own add row below is the same idiom at 36x36, one size down for its shorter field.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
|
||||
Height="40">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="ADD"
|
||||
Command="{Binding AddEditorTagCommand}" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="+" Width="40" Height="40"
|
||||
FontSize="16" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorTagCommand}" ToolTip.Tip="Adds this tag" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="56" TextWrapping="Wrap" />
|
||||
Height="58" TextWrapping="Wrap" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="Connection" Margin="0,0,0,2" />
|
||||
<TextBlock Classes="sectionlabel" Text="CONNECTION" Margin="0,0,0,2" />
|
||||
|
||||
<!--
|
||||
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset".
|
||||
The watermark is what the host will actually use if it is left that way, which is why it is
|
||||
bound rather than literal: it changes when the group picker above moves.
|
||||
Username first and port 78 wide second, the mock's own order — reversed from the field's
|
||||
earlier "port, username" layout, which put the number that is usually left blank ahead of
|
||||
the one that usually is not. Both boxes are still allowed to be empty, and empty still means
|
||||
"take the group's" rather than "unset"; the watermark is what the host will actually use if
|
||||
it is left that way, which is why it is bound rather than literal — it changes when the
|
||||
group picker above moves.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" ShowButtonSpinner="False"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
<Grid ColumnDefinitions="*,8,78">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||
Height="40" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -500,7 +571,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch">
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -513,26 +584,42 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay (not wired up yet)" />
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
||||
relay off the server stores no address at all; with it on the server must be able to
|
||||
resolve the target, or it becomes an authenticated open proxy into the operator's network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay is
|
||||
built and this client has no path to it: VaultViewModel dials the address directly whether
|
||||
this is ticked or not. So the box as it stood spent the one deliberate privacy concession
|
||||
in the design — the address in plaintext columns — and delivered a connection that failed
|
||||
exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to be
|
||||
able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
|
||||
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
|
||||
and still the reason this box says what it says.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything.
|
||||
With relay off the server stores no address at all; with it on the server must be able
|
||||
to resolve the target, or it becomes an authenticated open proxy into the operator's
|
||||
network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||
concession in the design — the address in plaintext columns — and delivered a
|
||||
connection that failed exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||
be able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||
@@ -550,6 +637,66 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ QUICK ACCESS ============
|
||||
The folders pinned on this host. Staged on VaultViewModel.EditorPinnedPaths the way the tag
|
||||
picker stages editorTagIds — see that field's own remarks — populated when the editor opens,
|
||||
read back by BuildHost on Save, and left alone by CANCEL for the same reason every other field
|
||||
here is: the whole editor is abandoned together.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="sectionlabel" Text="QUICK ACCESS" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding}" FontSize="12.5"
|
||||
Margin="8,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
$parent[ItemsControl] rather than #Board: this ItemsControl's own DataContext is
|
||||
already the VaultViewModel — HostDrawer's root x:DataType is that type directly —
|
||||
so one level up is enough, the same single-hop case ToggleEditorTagCommand above
|
||||
already uses.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
|
||||
FontSize="11"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
|
||||
Text="{Binding EditorNewPin}"
|
||||
PlaceholderText="/path/to/folder" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorPinCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorPinCommand}" ToolTip.Tip="Pins this path" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Pinned folders appear above the terminal for this host." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ THE GROUP EDITOR ============ -->
|
||||
@@ -727,10 +874,17 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Save host flex-grows and Cancel is a fixed 92, the mock's own footer proportions — a Grid rather
|
||||
than the StackPanel every other footer row uses, because a StackPanel measures each child at its
|
||||
own size and has nothing that means "the rest of the row".
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,92" IsVisible="{Binding IsEditing}">
|
||||
<Button Grid.Column="0" Classes="accent" Content="Save host" Height="44"
|
||||
HorizontalContentAlignment="Center" Command="{Binding SaveHostCommand}" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="Cancel" Height="44"
|
||||
HorizontalContentAlignment="Center" Command="{Binding CancelEditCommand}" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,113 +9,75 @@ using DodoSSH.Client.Shell.ViewModels;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
|
||||
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
|
||||
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
|
||||
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
||||
/// ◆ <b>v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG.</b> The desktop
|
||||
/// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a
|
||||
/// double-click, with a trail above it saying where you were — the way a directory pane holds one directory.
|
||||
/// That model is retired outright rather than folded into the new board: every group is now a heading, in
|
||||
/// label order, all of them at once, exactly the flattening the phone's <c>SidebarRows</c> has always drawn.
|
||||
/// A host is filed under a group through the group's own picker in its editor, or through the "Change
|
||||
/// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one
|
||||
/// destination, a group card, and that card is what left. See <c>VaultViewModel.HostSections</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
|
||||
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
|
||||
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
|
||||
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
|
||||
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
|
||||
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
|
||||
/// different.
|
||||
/// ◆ <b>ONE SECTION, ONE <c>ListBox</c>.</b> The two shapes worth weighing were a single mixed list of
|
||||
/// header rows and host rows in one <c>WrapPanel</c>, or an <c>ItemsControl</c> of sections each holding its
|
||||
/// own card list. The first is what the phone's single flat list already is, and reusing it here would have
|
||||
/// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before
|
||||
/// and after it — a real technique, and a fragile one to get right at every width this window can be resized
|
||||
/// to. The second costs a real thing: there is no longer one <c>ListBox</c> owning one <c>SelectedItem</c>,
|
||||
/// so multi-select and the marquee have to be taught to reach across however many section lists are on
|
||||
/// screen. That is what most of this file now does. It was chosen anyway, because a <c>WrapPanel</c> that
|
||||
/// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead
|
||||
/// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup —
|
||||
/// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a
|
||||
/// custom panel this application does not otherwise need.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
|
||||
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
|
||||
/// beside the grid inherits the same one.
|
||||
/// ◆ <b>SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT.</b> Each section's
|
||||
/// <c>ListBox</c> binds <c>SelectedItem</c> from <c>VaultViewModel.SelectedSidebarRow</c> so the right card
|
||||
/// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt
|
||||
/// lists would have each list's own <c>Reset</c> (one per filter keystroke, one per background sync) racing
|
||||
/// to null the one shared property, which is the same hazard <c>VaultViewModel.RebuildVisibleHosts</c>
|
||||
/// already documents for a single list and multiplies it by the section count. So a card being pressed is
|
||||
/// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be —
|
||||
/// see <see cref="OnPointerPressed"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.</b>
|
||||
/// <c>SelectedHost</c> is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is
|
||||
/// what Ctrl, Shift and the band build on top of it, as <c>IsChosen</c> on the row. Every handler below that
|
||||
/// reads a modifier is about keeping the two from being confused for one another, on the same terms this
|
||||
/// screen has always used — see <c>VaultViewModel.ChooseHosts</c> and Android's <c>HostsScreen</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is. The window hands it
|
||||
/// over; see <see cref="MainWindow"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// How hosts travel from the cards they were picked up on to the group card they are dropped on.
|
||||
/// </summary>
|
||||
/// <summary>How far the pointer has to travel before a press on the empty space becomes a band.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
|
||||
/// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
|
||||
/// drop needs: they know which vault each edit has to return to, which an id on its own does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
|
||||
/// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
|
||||
/// a gesture that quietly does a fraction of what it looks like it does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
|
||||
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
|
||||
/// those into a drag gesture the user never asked for. The band on the empty space between the cards
|
||||
/// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
|
||||
/// flashed up on every one of those would be a gesture reported where none happened.
|
||||
/// A threshold, because a press on the space around the cards is nearly always a click that drops the
|
||||
/// chosen set — see <see cref="OnBandPressed"/>. Starting a band on the press itself would turn every one
|
||||
/// of those into a rectangle nobody asked for.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 5;
|
||||
|
||||
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
|
||||
/// <remarks>
|
||||
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
|
||||
/// carrying something the user is looking at — a band the width of a hairline would only be found by
|
||||
/// accident, and only by somebody who did not need it.
|
||||
/// </remarks>
|
||||
private const double EdgeBand = 48;
|
||||
|
||||
/// <summary>How far one drag event inside that band moves the grid.</summary>
|
||||
/// <remarks>
|
||||
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
|
||||
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
|
||||
/// </remarks>
|
||||
private const double EdgeStep = 24;
|
||||
|
||||
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
|
||||
/// <remarks>
|
||||
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
|
||||
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
|
||||
/// over to the operating system.
|
||||
/// </remarks>
|
||||
private PointerPressedEventArgs? press;
|
||||
|
||||
/// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
|
||||
private IReadOnlyList<HostRowViewModel> pickedUp = [];
|
||||
|
||||
private Point origin;
|
||||
|
||||
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
||||
private ListBoxItem? marked;
|
||||
|
||||
/// <summary>
|
||||
/// Where a Shift-click measures its run from: the last card pressed without one.
|
||||
/// </summary>
|
||||
/// <summary>Where a Shift-click measures its run from: the last card pressed without one.</summary>
|
||||
/// <remarks>
|
||||
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
|
||||
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
|
||||
/// Null until something has been pressed, which is what a Shift-click into an untouched board falls back
|
||||
/// to the selected card for.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? anchor;
|
||||
|
||||
/// <summary>
|
||||
/// The ticked card a plain press landed on, which the release collapses the set onto.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
|
||||
/// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
|
||||
/// was an ordinary click and means what a click always means here — this one card, and nothing else. If
|
||||
/// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
|
||||
/// </remarks>
|
||||
private HostRowViewModel? collapse;
|
||||
|
||||
/// <summary>Whether a band is being dragged out over the grid right now.</summary>
|
||||
/// <summary>Whether a band is being dragged out over the board right now.</summary>
|
||||
private bool banding;
|
||||
|
||||
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
|
||||
@@ -133,53 +95,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
||||
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
|
||||
// password box above it, and a host that asks for a password still needs it typed first.
|
||||
HostGrid.DoubleTapped += OnHostActivated;
|
||||
// the transfers screen opens a directory too. Attached to the board rather than to any one section's
|
||||
// list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles.
|
||||
Board.DoubleTapped += OnHostActivated;
|
||||
|
||||
// And a group card opens the group, on the same gesture, for the same reason: going inside
|
||||
// something by double-clicking it is what the transfers screen's directories do and what every file
|
||||
// manager does. One click used to open a group, which made the card that names a group and the
|
||||
// control that narrows the grid to it the same press — so there was no way to select a group in
|
||||
// order to rename it without also losing sight of every host outside it.
|
||||
GroupGrid.DoubleTapped += OnGroupActivated;
|
||||
|
||||
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
||||
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
||||
// what is selected, and the menu is about to open against it. It is also what lets a modifier click
|
||||
// stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
|
||||
// moving its own selection onto it.
|
||||
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
// And the group cards answer a right click the same way, for the same reason: their menu's commands
|
||||
// read the vault's group selection, and without this they would act on whichever card was selected
|
||||
// before — or, with none, on the group the trail ends with, which is not on screen at all.
|
||||
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
HostGrid.PointerMoved += OnPointerMoved;
|
||||
HostGrid.PointerReleased += OnPointerReleased;
|
||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
||||
// Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered
|
||||
// the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by
|
||||
// the time either reaches this control the list has already decided what it thinks is selected.
|
||||
Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||
|
||||
WireTheBand();
|
||||
|
||||
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
|
||||
// same control: the target was a heading among the cards, and with the headings gone the group cards
|
||||
// are the only thing on this screen that names a group. A card dropped onto another card is refused
|
||||
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
|
||||
// would be, and a gesture whose result you cannot see before you let go is one that files machines
|
||||
// somewhere the user did not intend.
|
||||
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
|
||||
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
|
||||
DragDrop.AddDropHandler(GroupGrid, OnDrop);
|
||||
|
||||
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
|
||||
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
|
||||
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
|
||||
// platform is concerned. This is where all of them but a group card are turned down — and where a
|
||||
// drag held at the top or bottom edge pulls the grid towards the target.
|
||||
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
||||
}
|
||||
|
||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||
@@ -190,17 +116,17 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void WireTheBand()
|
||||
{
|
||||
// Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
|
||||
// Esc and Ctrl+A, on the board rather than on the window: both are ordinary editing keys that mean
|
||||
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
|
||||
// in it. The grid takes focus on a press, so the keys work from the moment anything has been
|
||||
// in it. The board takes focus on a press, so the keys work from the moment anything has been
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
HostGrid.KeyDown += OnGridKey;
|
||||
Board.KeyDown += OnGridKey;
|
||||
|
||||
// ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
|
||||
// mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
|
||||
// the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
|
||||
// reason the list's own press is, and it runs first, so it has to recognise a press on a card and
|
||||
// leave it alone.
|
||||
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||
// the board's own press is, and it runs first, so it has to recognise a press on a card and leave it
|
||||
// alone.
|
||||
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
|
||||
Scroll.PointerMoved += OnBandMoved;
|
||||
Scroll.PointerReleased += OnBandReleased;
|
||||
@@ -224,15 +150,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// <para>
|
||||
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
||||
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
||||
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
|
||||
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
|
||||
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
|
||||
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
|
||||
/// answer then, and it is a good one: it is where they were typing.
|
||||
/// something that is not there would swallow it. <c>Board</c> is focusable and holds every section, so it
|
||||
/// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and
|
||||
/// the find box is the answer then — it is where they were typing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget =>
|
||||
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
|
||||
Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||
@@ -241,31 +165,15 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
// Only over a card. A double-tap on the space around them must not connect to whichever host was
|
||||
// selected before — which is what an unguarded handler would do, on a machine the user is not even
|
||||
// pointing at.
|
||||
// Only over a card. A double-tap on the space around them, or on a section heading, must not connect
|
||||
// to whichever host was selected before — which is what an unguarded handler would do, on a machine
|
||||
// the user is not even pointing at.
|
||||
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
||||
{
|
||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the group card that was double-clicked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
|
||||
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
|
||||
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
|
||||
/// </remarks>
|
||||
private void OnGroupActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
|
||||
{
|
||||
vault.OpenGroupCommand.Execute(group);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the menu at whatever was right-clicked.
|
||||
/// </summary>
|
||||
@@ -283,8 +191,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// behind the menu.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
|
||||
/// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
|
||||
/// Cancelled outright over the space around the cards, or on a section heading. Neither is a host, and a
|
||||
/// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on
|
||||
/// something else entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
@@ -302,68 +211,39 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
if (!vault.IsChoosingHosts)
|
||||
{
|
||||
vault.SelectedSidebarRow = row;
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the group menu at whatever was right-clicked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
|
||||
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
|
||||
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
|
||||
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
|
||||
/// them now, so aiming it is the whole of aiming them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
|
||||
/// the fallback is exactly what would make the menu look like it worked there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedGroup = row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks cards, or remembers a press that may become a drag.
|
||||
/// Ticks cards, or moves the selection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
||||
/// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
|
||||
/// that also selected would light the card it had just unticked, and the drawer would open on a machine
|
||||
/// the user was removing from a set. Handled on the way down is the only place that can be said —
|
||||
/// by the time the press bubbles the control has already answered it.
|
||||
/// what stops whichever section's <c>ListBox</c> the press landed in from moving its own selection onto
|
||||
/// the card: a Ctrl-click that also selected would light the card it had just unticked, and the drawer
|
||||
/// would open on a machine the user was removing from a set. Handled on the way down is the only place
|
||||
/// that can be said — by the time the press bubbles the control has already answered it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
|
||||
/// file manager has and the reason the two selections on this screen can never disagree: after an
|
||||
/// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
|
||||
/// to the release, because the press may be the start of a drag of the whole set; see
|
||||
/// <see cref="collapse"/>.
|
||||
/// <b>A plain press moves the selection and drops the chosen set</b>, which is every file manager's rule
|
||||
/// and the reason the two selections on this screen can never disagree: after an ordinary click there is
|
||||
/// exactly one card in play. Not marked handled — the section list's own native selection is left to
|
||||
/// follow along, which is harmless now that it is only <c>OneWay</c> bound: whatever it shows locally is
|
||||
/// overwritten the moment <see cref="VaultViewModel.SelectedHost"/>'s change reaches every section again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
|
||||
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
|
||||
/// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// aimed at, where the board sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||
/// things that can then be done to a set each refuse for themselves, and say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
Forget();
|
||||
|
||||
if (Vault is not { } vault
|
||||
|| !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
||||
|| !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
@@ -375,7 +255,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
|
||||
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
@@ -386,7 +266,7 @@ internal sealed partial class HostsScreen : UserControl
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
@@ -394,82 +274,23 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
anchor = row;
|
||||
|
||||
// Read before anything is cleared, since clearing is one of the two things it decides.
|
||||
if (row.IsChosen)
|
||||
{
|
||||
collapse = row;
|
||||
pickedUp = vault.ChosenHosts;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
pickedUp = [row];
|
||||
}
|
||||
|
||||
press = e;
|
||||
origin = e.GetPosition(HostGrid);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The drag is started from the remembered press once the pointer has travelled far enough — see
|
||||
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
|
||||
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (press is not { } pressed || pickedUp.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
Forget();
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = e.GetPosition(HostGrid) - origin;
|
||||
|
||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var carried = pickedUp;
|
||||
|
||||
Forget();
|
||||
|
||||
_ = DragAsync(pressed, carried);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
|
||||
/// what every other click on a card means. See <see cref="collapse"/>.
|
||||
/// </remarks>
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (collapse is not null && Vault is { } vault)
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
Forget();
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
|
||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
|
||||
|
||||
/// <summary>
|
||||
/// Esc drops the set, and Ctrl+A is every card on the screen.
|
||||
/// Esc drops the set, and Ctrl+A is every card on the board.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
|
||||
/// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
|
||||
/// including the thirty-two not being shown would be the worst possible input to Delete.
|
||||
/// Every card <em>on the board</em>: <see cref="VaultViewModel.HostBoardOrder"/>, which is every section
|
||||
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a
|
||||
/// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing would
|
||||
/// be the worst possible input to Delete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
|
||||
@@ -492,10 +313,12 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
|
||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||
{
|
||||
vault.ChooseHosts(vault.VisibleHosts, replacing: true);
|
||||
anchor = vault.VisibleHosts[0];
|
||||
var order = vault.HostBoardOrder.ToList();
|
||||
|
||||
vault.ChooseHosts(order, replacing: true);
|
||||
anchor = order[0];
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -505,16 +328,16 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
|
||||
/// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
|
||||
/// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
|
||||
/// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
|
||||
/// above it moves the ceiling with no code here changing.
|
||||
/// ◆ <b>Only where there is nothing to press.</b> The scroller holds every section heading, every card
|
||||
/// list and whichever panel the set's menu raised, and a rectangle dragged out from inside any of those
|
||||
/// would be a gesture aimed at hosts started on something that is not one. <see cref="TakesThePress"/> is
|
||||
/// the guard, and it is ancestor-based rather than naming one control, which is what lets it work
|
||||
/// regardless of how many section lists happen to be realized.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
|
||||
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
|
||||
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the board with
|
||||
/// nothing left to take it down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -533,19 +356,12 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var at = e.GetPosition(Scroll);
|
||||
|
||||
if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
banding = true;
|
||||
bandFrom = at;
|
||||
bandFrom = e.GetPosition(Scroll);
|
||||
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||
|
||||
e.Pointer.Capture(Scroll);
|
||||
HostGrid.Focus();
|
||||
Board.Focus();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -628,15 +444,16 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
|
||||
/// <remarks>
|
||||
/// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
|
||||
/// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
|
||||
/// the run, and would tick nothing at all for a band drawn down the middle of a column.
|
||||
/// Touches rather than contains, which is what makes a band usable at all: a rectangle that had to
|
||||
/// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from
|
||||
/// every section's own list — see <see cref="AllCardContainers"/> — rather than one named control, since
|
||||
/// a band drawn across two sections has to touch cards in both.
|
||||
/// </remarks>
|
||||
private List<HostRowViewModel> CardsIn(Rect box)
|
||||
{
|
||||
var hit = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var container in HostGrid.GetRealizedContainers())
|
||||
foreach (var container in AllCardContainers())
|
||||
{
|
||||
if (container.DataContext is HostRowViewModel row
|
||||
&& container.TranslatePoint(default, Scroll) is { } corner
|
||||
@@ -649,227 +466,41 @@ internal sealed partial class HostsScreen : UserControl
|
||||
return hit;
|
||||
}
|
||||
|
||||
/// <summary>Every realized card, across every section's own list.</summary>
|
||||
/// <remarks>
|
||||
/// A section is a <c>ListBox</c> of its own — see the type's remarks — so there is no single control
|
||||
/// whose <c>GetRealizedContainers()</c> would answer for the whole board. Found by class rather than by
|
||||
/// name, because how many sections exist is a fact about the keychain rather than the markup: a flat
|
||||
/// keychain draws one, a filed one draws several, and neither shape is known until the vault says so.
|
||||
/// </remarks>
|
||||
private List<ListBoxItem> AllCardContainers() =>
|
||||
[.. this.GetVisualDescendants()
|
||||
.OfType<ListBox>()
|
||||
.Where(list => list.Classes.Contains("sectioncards"))
|
||||
.SelectMany(list => list.GetRealizedContainers())
|
||||
.OfType<ListBoxItem>()];
|
||||
|
||||
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
|
||||
/// <remarks>
|
||||
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
|
||||
/// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
|
||||
/// tick everything it passed.
|
||||
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up or in a section's own
|
||||
/// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would
|
||||
/// paint a band down the board and tick everything it passed.
|
||||
/// </remarks>
|
||||
private static bool TakesThePress(object? source) => source is Visual visual
|
||||
&& visual.GetSelfAndVisualAncestors().Any(element =>
|
||||
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
|
||||
|
||||
/// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
|
||||
private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, rows));
|
||||
|
||||
try
|
||||
{
|
||||
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
|
||||
// thread's.
|
||||
await DragDrop
|
||||
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
|
||||
// target for a drag that ended somewhere else entirely.
|
||||
Unmark();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says whether what is under the pointer would take these hosts, and marks it if it would.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A drag whose hosts are all already in the group under the pointer is refused, which is not pedantry:
|
||||
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
|
||||
/// would do something and then does nothing is worse than one that says so while it is still in the air.
|
||||
/// A set with even one host from somewhere else is accepted, because filing that one is a real move.
|
||||
/// </remarks>
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
if (Target(e) is not { } target)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
Unmark();
|
||||
return;
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
Mark(target.Container);
|
||||
}
|
||||
|
||||
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
|
||||
|
||||
/// <summary>
|
||||
/// Carries the grid under a drag that is over the cards rather than over a group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
|
||||
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
|
||||
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
|
||||
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
|
||||
/// list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
|
||||
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
|
||||
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
|
||||
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
|
||||
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
|
||||
/// everything that is not a group.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDragOverScroll(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
Unmark();
|
||||
|
||||
var at = e.GetPosition(Scroll).Y;
|
||||
var height = Scroll.Bounds.Height;
|
||||
|
||||
var step = at switch
|
||||
{
|
||||
_ when at < EdgeBand => -EdgeStep,
|
||||
_ when at > height - EdgeBand => EdgeStep,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (step == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
|
||||
|
||||
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
|
||||
/// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
|
||||
/// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
|
||||
/// a group belongs to one vault — and reports a count rather than forty status lines in a row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
|
||||
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
|
||||
/// task nothing observes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
Unmark();
|
||||
|
||||
if (Vault is not { } vault || Target(e) is not { } target)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
return;
|
||||
}
|
||||
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
|
||||
if (target.Hosts.Count > 1)
|
||||
{
|
||||
vault.FileChosenHostsUnderCommand.Execute(target.Group);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a drag currently is, or null if it is over nothing that would take it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
|
||||
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
|
||||
/// dropped on is what it says on it. The space around the cards takes nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A group the vault no longer has is read as no group at all, which is what the list already does with
|
||||
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
|
||||
/// rather than here, so the rule has one home.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static DropTarget? Target(DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
|
||||
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
||||
|| dragged.All(row => row.Host.GroupId == group.EntityId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropTarget(dragged, group, container);
|
||||
}
|
||||
|
||||
private void Mark(ListBoxItem container)
|
||||
{
|
||||
if (ReferenceEquals(marked, container))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Unmark();
|
||||
|
||||
marked = container;
|
||||
marked.Classes.Add("droptarget");
|
||||
}
|
||||
|
||||
private void Unmark()
|
||||
{
|
||||
marked?.Classes.Remove("droptarget");
|
||||
marked = null;
|
||||
}
|
||||
|
||||
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
|
||||
private void Forget()
|
||||
{
|
||||
press = null;
|
||||
pickedUp = [];
|
||||
collapse = null;
|
||||
}
|
||||
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
||||
private static object? RowUnder(object? source) => Container(source)?.DataContext;
|
||||
/// <summary>The view model of the grid item an event happened on, if it happened on a host card.</summary>
|
||||
private static HostRowViewModel? RowUnder(object? source) =>
|
||||
Container(source)?.DataContext as HostRowViewModel;
|
||||
|
||||
/// <remarks>
|
||||
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
||||
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
|
||||
/// yields null.
|
||||
/// is what an event's source is. Anything not inside a card, which includes a section's own heading and
|
||||
/// the space around them, yields null.
|
||||
/// </remarks>
|
||||
private static ListBoxItem? Container(object? source) => source is Visual visual
|
||||
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
||||
: null;
|
||||
|
||||
/// <summary>A drag in flight, and where it would land.</summary>
|
||||
/// <remarks>
|
||||
/// The group is the card rather than its id, which it was while only one host could be dragged: the
|
||||
/// command that files a set takes the card, because the refusal it makes is about which vault the group
|
||||
/// is in. The way out of a group is still the host's own editor, which is the one place "no group" can be
|
||||
/// said in words.
|
||||
/// </remarks>
|
||||
private sealed record DropTarget(
|
||||
IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,53 @@
|
||||
-->
|
||||
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!--
|
||||
◆ 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.
|
||||
-->
|
||||
<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}">
|
||||
@@ -232,6 +278,7 @@
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a host card's <c>MonogramHue</c> string into the background or foreground brush the mock pairs it
|
||||
/// with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The four pairs are the design's own and are not in <c>Palette.axaml</c>: that file is another wave's, and
|
||||
/// none of the shared palette's existing keys is this hue ramp — a monogram's colour is a property of the
|
||||
/// four-way hash in <c>HostRowViewModel.MonogramHue</c>, not a fact the rest of the theme has any use for.
|
||||
/// Held here, beside the one screen that reads it, rather than invented as four new shared resources for a
|
||||
/// single card.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two instances rather than one converter taking a "which half" parameter, so the markup names each with a
|
||||
/// <c>StaticResource</c>-shaped reference instead of a converter parameter that means nothing without
|
||||
/// reading this file.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MonogramBrushConverter(bool background) : IValueConverter
|
||||
{
|
||||
/// <summary>The fill behind the two letters.</summary>
|
||||
internal static MonogramBrushConverter Background { get; } = new(background: true);
|
||||
|
||||
/// <summary>The colour of the two letters themselves.</summary>
|
||||
internal static MonogramBrushConverter Foreground { get; } = new(background: false);
|
||||
|
||||
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram.</summary>
|
||||
private static readonly Dictionary<string, (string Background, string Foreground)> Pairs = new(
|
||||
StringComparer.Ordinal)
|
||||
{
|
||||
["violet"] = ("#241C4F", "#A78BFA"),
|
||||
["green"] = ("#103A2F", "#34D399"),
|
||||
["amber"] = ("#423211", "#F5B942"),
|
||||
["gray"] = ("#1E1E2C", "#9C9EB4"),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var pair = value is string hue && Pairs.TryGetValue(hue, out var found) ? found : Pairs["gray"];
|
||||
|
||||
return new SolidColorBrush(Color.Parse(background ? pair.Background : pair.Foreground));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException("The monogram hue is read-only.");
|
||||
}
|
||||
@@ -30,17 +30,23 @@
|
||||
<!-- 80% of Canvas. Written out because a scrim is a brush with an alpha, and the palette holds no alpha
|
||||
variant of a surface — see Palette.axaml, where the only pre-multiplied brushes are the accent washes. -->
|
||||
<Border x:Name="Backdrop" Background="#CC0E1220" PointerPressed="OnBackdropPressed">
|
||||
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
|
||||
<Border Width="640" VerticalAlignment="Top" Margin="0,160,0,0"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel>
|
||||
|
||||
<Border Padding="12,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<!--
|
||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||
it already has a real one.
|
||||
-->
|
||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">" FontSize="13"
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts" Margin="8,0,0,0"
|
||||
PlaceholderText="search hosts" Margin="10,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
@@ -54,32 +60,50 @@
|
||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||
|
||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||
shows its corners.
|
||||
-->
|
||||
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
|
||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,8,12,8">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="12,0,0,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Authentication}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||
-->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="8" Height="8" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="14,16"
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||
Text="No host matches that."
|
||||
IsVisible="{Binding !HasSearchResults}" />
|
||||
|
||||
<Border Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||
</Border>
|
||||
|
||||
@@ -29,6 +29,22 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
|
||||
public const int DefaultPort = 22;
|
||||
|
||||
/// <summary>The longest a single pinned path may be.</summary>
|
||||
/// <remarks>
|
||||
/// A remote path is opaque to this client, so nothing about its shape is checked beyond this — but
|
||||
/// opaque is not unbounded. This is inside an encrypted payload every device decrypts and renders, and
|
||||
/// a length nobody would type by hand is the shape a corrupted or hostile payload takes.
|
||||
/// </remarks>
|
||||
public const int MaxPinnedPathLength = 1024;
|
||||
|
||||
/// <summary>The most paths a host may pin.</summary>
|
||||
/// <remarks>
|
||||
/// A quick-access list a person actually uses tops out well short of this; the limit exists to bound
|
||||
/// the payload rather than to constrain anyone's workflow. See <see cref="MaxPinnedPathLength"/> for
|
||||
/// the parallel reasoning about a single entry.
|
||||
/// </remarks>
|
||||
public const int MaxPinnedPaths = 32;
|
||||
|
||||
/// <summary>Display name. The only name this host has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
@@ -188,6 +204,25 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// </remarks>
|
||||
public TagSet TagIds { get; init; } = TagSet.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The remote directories pinned on this host, in the order the user arranged them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Plain strings rather than any parsed path type, because the path means something only to the
|
||||
/// remote shell — this client never resolves it, never lists it, never checks that it exists. What
|
||||
/// "pinned" buys is a shortcut into wherever the user already knows to go: a project checkout, a log
|
||||
/// directory, the place a deploy lands.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Order is kept rather than sorted, unlike <see cref="TagIds"/>, because order is what a pinned-path
|
||||
/// list is for — it is what a quick-access menu draws top to bottom, and a user who moves their
|
||||
/// most-used path to the top has expressed something a sorted set would erase. See
|
||||
/// <see cref="PinnedPathList"/> for how that survives the merge.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The group this host is filed under, or null for none.
|
||||
/// </summary>
|
||||
@@ -279,7 +314,62 @@ public sealed record HostSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
return ReferencesAreStorable(out reason);
|
||||
return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the paths pinned on this host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A path here is opaque to this client: it is resolved by the remote shell, not by this one, so
|
||||
/// nothing about leading slashes or <c>.</c> components is checked, and a relative path — one meant to
|
||||
/// resolve against the account's home directory — is exactly as valid as an absolute one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is refused is narrower. A path that is blank once trimmed pins nothing to jump to. A control
|
||||
/// character, NUL included, is never part of a real path and is the shape a corrupted or hand-crafted
|
||||
/// payload takes — this client renders these directly in a shortcut list, and a stray escape sequence
|
||||
/// or embedded NUL there is a bug at best. And <see cref="MaxPinnedPathLength"/> and
|
||||
/// <see cref="MaxPinnedPaths"/> bound the payload the same way <see cref="HostOptions"/> bounds
|
||||
/// directives: not because a real workflow approaches either limit, but because an encrypted payload
|
||||
/// every device must decrypt and render should not be allowed to demand an unbounded amount of either.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool PinnedPathsAreStorable([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (PinnedPaths.Count > MaxPinnedPaths)
|
||||
{
|
||||
reason = $"A host cannot pin more than {MaxPinnedPaths} paths.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var path in PinnedPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
reason = "A pinned path cannot be blank.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (path.Length > MaxPinnedPathLength)
|
||||
{
|
||||
reason = $"A pinned path cannot be longer than {MaxPinnedPathLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in path)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
{
|
||||
reason = "A pinned path cannot contain a control character.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -102,12 +102,22 @@ public static class HostSecretCodec
|
||||
/// </remarks>
|
||||
public const int TagIdsSchemaVersion = 6;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.PinnedPaths"/>.</summary>
|
||||
/// <remarks>
|
||||
/// One past tags rather than sharing with them, for the reason <see cref="TagIdsSchemaVersion"/> gives
|
||||
/// for sharing with inheritance instead of standing alone: the two fields are independent, a host can
|
||||
/// pin paths without wearing a single tag, and stating the version such a host would actually need
|
||||
/// keeps the rule in <see cref="SchemaVersionFor"/> a maximum over what is genuinely present rather
|
||||
/// than a coincidence of what shipped together.
|
||||
/// </remarks>
|
||||
public const int PinnedPathsSchemaVersion = 7;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
/// <remarks>
|
||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||
/// maximum. A new field added below this line has to be named here too.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = TagIdsSchemaVersion;
|
||||
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -149,6 +159,10 @@ public static class HostSecretCodec
|
||||
// [] here would land in every host in every vault and make the first sync after the upgrade
|
||||
// read as though every one of them had changed.
|
||||
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
|
||||
|
||||
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
|
||||
// as it did before this field existed.
|
||||
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -219,6 +233,11 @@ public static class HostSecretCodec
|
||||
version = Math.Max(version, TagIdsSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.PinnedPaths.Count > 0)
|
||||
{
|
||||
version = Math.Max(version, PinnedPathsSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -295,6 +314,8 @@ public static class HostSecretCodec
|
||||
AsksForPassword = parsed.AsksForPassword is true ? true : null,
|
||||
|
||||
TagIds = TagSet.Create(parsed.TagIds ?? []),
|
||||
|
||||
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -369,6 +390,12 @@ internal sealed class HostPayloadDocument
|
||||
/// which the sync engine would read as every host having changed.
|
||||
/// </remarks>
|
||||
public Guid[]? TagIds { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and null rather than <c>[]</c>, for exactly the reasons <see cref="TagIds"/> gives — it is the
|
||||
/// newer of the two and follows the same rule.
|
||||
/// </remarks>
|
||||
public string[]? PinnedPaths { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
@@ -97,6 +97,8 @@ public static class HostSecretMerge
|
||||
FormatChain),
|
||||
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
|
||||
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
|
||||
PinnedPaths = MergePinnedPaths(
|
||||
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
|
||||
RelayEnabled = Field(
|
||||
nameof(HostSecret.RelayEnabled),
|
||||
ancestor.RelayEnabled,
|
||||
@@ -290,6 +292,56 @@ public static class HostSecretMerge
|
||||
return TagSet.Create(merge.Merged.Keys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the pinned paths per path, so two people each pinning a different one both keep theirs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same reasoning as <see cref="MergeTags"/>, and the same shape: a whole-value merge would take
|
||||
/// one side's list entire and drop the other's, so a colleague pinning <c>/var/log</c> while you pinned
|
||||
/// <c>/var/www/app</c> would silently lose one of the two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Unlike a tag set, order survives.</b> <see cref="PinnedPathList.ToPathMap"/> is built in list
|
||||
/// order rather than a canonical one, and <see cref="ThreeWayMerge.Map"/> unions keys ancestor first,
|
||||
/// then local, then remote — so the merged keys come out base order, then this machine's additions,
|
||||
/// then the server's, which is what <see cref="PinnedPathList.Create(IEnumerable{string})"/> then
|
||||
/// preserves rather than re-sorting.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No conflict is reachable here either, for the reason <see cref="MergeTags"/> gives.</b> The value
|
||||
/// in the map is the key, so a path can only be present or absent, and the "both sides moved
|
||||
/// differently" branch of the keyed merge needs one key to hold two values — which this map cannot
|
||||
/// express. The loop stays for the same reason it stays there: the proof depends on
|
||||
/// <see cref="PinnedPathList.ToPathMap"/> keying by value, and that is one edit away from no longer
|
||||
/// being true.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static PinnedPathList MergePinnedPaths(
|
||||
PinnedPathList ancestor,
|
||||
PinnedPathList local,
|
||||
PinnedPathList remote,
|
||||
List<HostFieldConflict> conflicts)
|
||||
{
|
||||
var merge = ThreeWayMerge.Map(
|
||||
ancestor.ToPathMap(),
|
||||
local.ToPathMap(),
|
||||
remote.ToPathMap(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var conflict in merge.Conflicts)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
$"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]",
|
||||
conflict.DiscardedSide,
|
||||
conflict.Kept is null ? "not pinned" : "pinned",
|
||||
conflict.DiscardedWasRemoval ? "not pinned" : "pinned",
|
||||
conflict.DiscardedWasRemoval));
|
||||
}
|
||||
|
||||
return PinnedPathList.Create(merge.Merged.Keys);
|
||||
}
|
||||
|
||||
private static string FormatChain(JumpChain chain) =>
|
||||
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// The remote directory paths pinned on a host, in the order the user arranged them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A dedicated type rather than a list of strings, for the reason <see cref="JumpChain"/> and
|
||||
/// <see cref="TagSet"/> both give: a plain <see cref="IReadOnlyList{T}"/> on a record gets reference
|
||||
/// equality from the compiler-generated <c>Equals</c>, so every host would read as changed on every sync
|
||||
/// pass and two identical edits would register as a conflict.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ordered, unlike <see cref="TagSet"/>; deduplicated, unlike <see cref="JumpChain"/>.</b> Order is
|
||||
/// exactly what a pinned-path list is for: it is what a quick-access menu draws top to bottom, and a user
|
||||
/// who drags their most-used path to the top has expressed something a sorted set would erase. So this
|
||||
/// preserves insertion order rather than sorting at construction. But two pins of the same path are not
|
||||
/// two shortcuts, only one typed twice, so this still dedupes — keeping the first occurrence — by ordinal
|
||||
/// comparison, because the path is meaningful only to the remote shell and that shell may well be running
|
||||
/// on a case-sensitive filesystem; folding case here would silently collapse two different directories
|
||||
/// into one entry.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty and over-length paths are not rejected at construction, for the reason <see cref="TagSet"/>
|
||||
/// gives for its empty ids: they arrive from a decrypted payload written by another client, and a
|
||||
/// constructor that threw would turn one bad item into a failed sync pass for every other item behind it.
|
||||
/// <see cref="HostSecret.TryValidate"/> is where that is caught.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PinnedPathList : IReadOnlyList<string>, IEquatable<PinnedPathList>
|
||||
{
|
||||
private readonly string[] paths;
|
||||
private readonly int hash;
|
||||
|
||||
private PinnedPathList(string[] paths)
|
||||
{
|
||||
this.paths = paths;
|
||||
hash = ComputeHash(paths);
|
||||
}
|
||||
|
||||
/// <summary>No pinned paths.</summary>
|
||||
public static PinnedPathList Empty { get; } = new([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => paths.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string this[int index] => paths[index];
|
||||
|
||||
/// <summary>Copies a sequence of paths, keeping order and the first occurrence of any repeat.</summary>
|
||||
public static PinnedPathList Create(IEnumerable<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return Canonicalise([.. paths]);
|
||||
}
|
||||
|
||||
/// <summary>Copies a span of paths, keeping order and the first occurrence of any repeat.</summary>
|
||||
public static PinnedPathList Create(ReadOnlySpan<string> paths) => Canonicalise(paths.ToArray());
|
||||
|
||||
/// <summary>Whether this host pins the given path, compared ordinally.</summary>
|
||||
public bool Contains(string path) => Array.IndexOf(paths, path) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// The list as a map from path to path, in list order, which is the shape
|
||||
/// <see cref="ThreeWayMerge.Map"/> takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The value repeats the key for the reason <see cref="TagSet.ToIdMap"/>'s does: a per-key merge
|
||||
/// resolves presence and absence independently, which is set semantics with removals, so two machines
|
||||
/// pinning different paths on the same host both keep theirs, and a removal on one side is reported
|
||||
/// rather than silently undone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Built in list order rather than any canonical one, and that is load-bearing rather than incidental.
|
||||
/// <see cref="ThreeWayMerge.Map"/> unions keys by walking the ancestor's map, then the local side's,
|
||||
/// then the remote's, each in the order its dictionary enumerates — so an ordered map here is what lets
|
||||
/// <see cref="HostSecretMerge"/> reproduce "base order, then additions" after the merge without doing
|
||||
/// any ordering of its own.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, string> ToPathMap()
|
||||
{
|
||||
var map = new Dictionary<string, string>(paths.Length, StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
map[path] = path;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(PinnedPathList? other)
|
||||
{
|
||||
if (ReferenceEquals(this, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is not null
|
||||
&& other.hash == hash
|
||||
&& paths.AsSpan().SequenceEqual(other.paths);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => Equals(obj as PinnedPathList);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => hash;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)paths).GetEnumerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => paths.GetEnumerator();
|
||||
|
||||
/// <summary>The paths, without copying, in list order.</summary>
|
||||
public ReadOnlySpan<string> AsSpan() => paths;
|
||||
|
||||
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||
[SuppressMessage(
|
||||
"Usage",
|
||||
"CA2225:Operator overloads have named alternates",
|
||||
Justification = "Equals(PinnedPathList) is the named alternate.")]
|
||||
public static bool operator ==(PinnedPathList? left, PinnedPathList? right) =>
|
||||
left is null ? right is null : left.Equals(right);
|
||||
|
||||
/// <summary>Contents inequality.</summary>
|
||||
public static bool operator !=(PinnedPathList? left, PinnedPathList? right) => !(left == right);
|
||||
|
||||
/// <remarks>
|
||||
/// Order-preserving, unlike <see cref="TagSet"/>'s canonicalisation: the first occurrence of a path is
|
||||
/// kept in place and later repeats are dropped, rather than the whole array being sorted.
|
||||
/// </remarks>
|
||||
private static PinnedPathList Canonicalise(string[] paths)
|
||||
{
|
||||
if (paths.Length == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(paths.Length, StringComparer.Ordinal);
|
||||
var written = 0;
|
||||
|
||||
for (var read = 0; read < paths.Length; read++)
|
||||
{
|
||||
if (seen.Add(paths[read]))
|
||||
{
|
||||
paths[written++] = paths[read];
|
||||
}
|
||||
}
|
||||
|
||||
return new PinnedPathList(written == paths.Length ? paths : paths[..written]);
|
||||
}
|
||||
|
||||
private static int ComputeHash(string[] paths)
|
||||
{
|
||||
// Order-sensitive, because order is the thing this type exists to preserve.
|
||||
var accumulator = new HashCode();
|
||||
accumulator.Add(paths.Length);
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
accumulator.Add(path, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return accumulator.ToHashCode();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -68,6 +68,15 @@
|
||||
-->
|
||||
<AvaloniaResource Include="WebAssets/**" />
|
||||
<AvaloniaResource Include="Theme/**" />
|
||||
|
||||
<!--
|
||||
Montserrat, JetBrains Mono and Material Icons, embedded rather than requested by name for the same
|
||||
reason WebAssets is embedded: a font resolved from the machine is whatever that machine happens to
|
||||
have, and a phone and a desktop asked the same question would not get the same answer. Shared here
|
||||
so the desktop head's avares://DodoSSH.Client.Shell/Assets/Fonts URIs are the same ones the palette
|
||||
and Program.cs reference; see Theme/Palette.axaml's MonoFont and IconFont keys.
|
||||
-->
|
||||
<AvaloniaResource Include="Assets/Fonts/**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -25,127 +25,186 @@
|
||||
marked. That is deliberate rather than lazy: the finer distinctions here — three border weights, four
|
||||
text steps — are ones the design draws without naming, and collapsing them to match the token count
|
||||
would lose the rule each one encodes.
|
||||
|
||||
── v3 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The third design moves again, and further in the same direction v2 moved in: v1 was green-black, v2
|
||||
blue-black, and this one near-black — the surfaces are lower-contrast against true black than either
|
||||
predecessor, and the accent turns purple, sitting where the blue used to. Both heads still move
|
||||
together for the reason given above. Where a name below still carries a v2-era comment explaining a v2
|
||||
fact in v2's numbers, that comment is a record of what v2 changed and why, not a claim about the
|
||||
current values — it is left as history except where it stated something the current palette makes
|
||||
false outright, which is called out where it happens.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The surfaces, darkest first. Two things changed shape rather than merely colour.
|
||||
The surfaces, darkest first. Two things changed shape rather than merely colour, in v2, and the second
|
||||
of the two has since changed back.
|
||||
|
||||
A field is now *inset* — darker than the canvas it sits on — where it used to be raised. That follows
|
||||
the design, which draws the search box as a well rather than as a tile, and it is the one place where
|
||||
reading the old palette's ordering across to the new one gives the wrong answer.
|
||||
A field became *inset* in v2 — darker than the canvas it sat on — where it had been raised before that.
|
||||
That followed the v2 design, which drew the search box as a well rather than as a tile. It does not
|
||||
follow this one: the v3 design gives a card and a field the same fill, rgb(16,16,25), and marks a field
|
||||
as editable with a 1.5px accent-coloured inset border instead of a darker well. Raised and Field are
|
||||
kept as separate keys anyway, for the same reason Chrome and Panel are — the day one of the two needs
|
||||
to move again, having merged them would have to be undone everywhere that used the wrong one.
|
||||
|
||||
And Chrome and Panel are now the same value, because the design uses one surface for both the header
|
||||
Chrome and Panel remain the same value for that reason: the design uses one surface for both the header
|
||||
and a card. They are kept as two names anyway: they answer different questions, and the day one of the
|
||||
two moves is the day having merged them would have to be undone across every file that used the wrong
|
||||
one.
|
||||
-->
|
||||
<Color x:Key="CanvasColor">#0E1220</Color>
|
||||
<Color x:Key="CanvasColor">#05050A</Color>
|
||||
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
|
||||
<SolidColorBrush x:Key="Chrome" Color="#111629" />
|
||||
<SolidColorBrush x:Key="Sidebar" Color="#0F1426" />
|
||||
<SolidColorBrush x:Key="Panel" Color="#111629" />
|
||||
<SolidColorBrush x:Key="Raised" Color="#151B31" />
|
||||
<SolidColorBrush x:Key="Field" Color="#0B0F1D" />
|
||||
<SolidColorBrush x:Key="Chrome" Color="#10111E" />
|
||||
<SolidColorBrush x:Key="Sidebar" Color="#0D0D16" />
|
||||
<SolidColorBrush x:Key="Panel" Color="#10111E" />
|
||||
<SolidColorBrush x:Key="Raised" Color="#101019" />
|
||||
<SolidColorBrush x:Key="Field" Color="#101019" />
|
||||
|
||||
<!--
|
||||
The surface a terminal, a log and a snippet's command are drawn on, and it is not one of the five
|
||||
above. The design gives every block of monospaced output this one value, warmer than the chrome around
|
||||
it, and that is what makes the lit thing read as lit. Named here rather than repeated in the renderer's
|
||||
stylesheet and in two screens, which is how the three of them would drift.
|
||||
|
||||
Not one of the design's named tokens in v3 either — the mock this palette was drawn from is a Hosts
|
||||
screen with no terminal on it — so the value is interpolated the same way it was for v2: warmer and a
|
||||
touch lighter than the new Chrome, on the same ramp as Raised and Field rather than the old blue-black
|
||||
one.
|
||||
-->
|
||||
<SolidColorBrush x:Key="TerminalSurface" Color="#171A26" />
|
||||
<SolidColorBrush x:Key="TerminalSurface" Color="#18161F" />
|
||||
|
||||
<!-- Row hover, and the heavier one the chrome's own buttons use. On the phone these are press states. -->
|
||||
<SolidColorBrush x:Key="Hover" Color="#151B31" />
|
||||
<SolidColorBrush x:Key="ChromeHover" Color="#1D2749" />
|
||||
<SolidColorBrush x:Key="Hover" Color="#14151E" />
|
||||
<SolidColorBrush x:Key="ChromeHover" Color="#1E1E2C" />
|
||||
|
||||
<!--
|
||||
Selection, and it is a surface rather than a highlight: the design fills a chosen host row, the current
|
||||
segment of a toggle and the current chip with this and adds no border. Distinct from Hover, which is
|
||||
what the finger is on rather than what is chosen.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Active" Color="#1D2749" />
|
||||
<SolidColorBrush x:Key="Active" Color="#1E1E2C" />
|
||||
|
||||
<!-- The fill behind a small monospaced tag — a group name, a storage class, a bucket. -->
|
||||
<SolidColorBrush x:Key="Chip" Color="#16204A" />
|
||||
<SolidColorBrush x:Key="Chip" Color="#1A1A28" />
|
||||
|
||||
<!--
|
||||
Three border weights, and they are not interchangeable. Strong separates one region from another,
|
||||
subtle separates rows inside one region, and mid is what a control draws around itself.
|
||||
|
||||
BorderFaint changes relationship in v3: in v2 it sat above Border, lighter than it — a fact this
|
||||
comment did not previously state because nothing here described it. In this design BorderSubtle and
|
||||
BorderFaint are both a step darker than Border rather than one of them being a step lighter, so the
|
||||
weight now runs, faintest to strongest: BorderFaint, BorderSubtle, Border, BorderMid, BorderHover — a
|
||||
single ramp rather than Border sitting in the middle of two that disagree about which side it is on.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Border" Color="#1A2138" />
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#151B31" />
|
||||
<SolidColorBrush x:Key="BorderMid" Color="#232B45" />
|
||||
<SolidColorBrush x:Key="BorderHover" Color="#2E3859" />
|
||||
<SolidColorBrush x:Key="BorderFaint" Color="#1F2740" />
|
||||
<SolidColorBrush x:Key="Border" Color="#1E1E2C" />
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#17172A" />
|
||||
<SolidColorBrush x:Key="BorderMid" Color="#24243A" />
|
||||
<SolidColorBrush x:Key="BorderHover" Color="#2C2C3E" />
|
||||
<SolidColorBrush x:Key="BorderFaint" Color="#141420" />
|
||||
|
||||
<!--
|
||||
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
|
||||
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
|
||||
|
||||
── The ramp is white-topped, and every step below it moved with the top. ─────────────────────────────
|
||||
The design's #E3E7F4 is a blue-tinted white, and against these blue-black surfaces the tint costs
|
||||
contrast twice: once because it is darker than white, and once because it shares a hue with what it is
|
||||
drawn on. Pure white against #0E1220 is 18.3:1, where #E3E7F4 was 15.5:1.
|
||||
This was v2's reasoning, told in v2's numbers, and is left as a record of that change rather than
|
||||
updated in place: the design's #E3E7F4 was a blue-tinted white, and against v2's blue-black surfaces
|
||||
the tint cost contrast twice — once because it was darker than white, and once because it shared a hue
|
||||
with what it was drawn on. Pure white against v2's canvas, #0E1220, was 18.6:1, where #E3E7F4 had been
|
||||
15.5:1.
|
||||
|
||||
Raising only the top would have been the wrong half of the change. The three steps are a ramp rather
|
||||
Raising only the top would have been the wrong half of that change. The three steps are a ramp rather
|
||||
than three colours, and their job is that a glance can tell them apart — so lifting Text and leaving
|
||||
TextDim where it was would have widened the first gap and left the second two crowded at the bottom.
|
||||
Every step is raised by roughly what the top gained, which keeps the intervals the design chose and
|
||||
brings the lower steps up to where a secondary label is comfortably readable rather than merely
|
||||
present. TextDim clears 9:1 now, against 6.4:1 before.
|
||||
Every step was raised by roughly what the top gained, which kept the intervals the design chose and
|
||||
brought the lower steps up to where a secondary label was comfortably readable rather than merely
|
||||
present. TextDim cleared 9.8:1 at that point, against 6.4:1 before it.
|
||||
|
||||
── v3 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The canvas darkens again, past v2's blue-black to near-black, which raises every one of these ratios
|
||||
again without any of them moving: pure white against the new #05050A canvas is 20.3:1. TextDim,
|
||||
TextFaint and TextGhost are repicked to the design's own neutral-grey values rather than re-derived
|
||||
from v2's blue-tinted ones, because the design supplies its own steps this time — #EDEDF5, #9C9EB4 and
|
||||
#7C7F98 read against the new canvas at roughly 17.5:1, 7.7:1 and 5.2:1, in the same descending order the
|
||||
three names promise. The design also uses two deeper steps, #6D6F84 and #5D5F74, for distinctions this
|
||||
ramp does not yet draw; they are not added as keys until a screen needs them, on the same reasoning as
|
||||
the paragraph above the border ramp — a resource nothing reads is not free, because the next person to
|
||||
touch this file has to work out whether it is dead or merely not yet wired up.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Text" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#B4BCD4" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#7B849F" />
|
||||
<SolidColorBrush x:Key="TextGhost" Color="#5A6280" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#EDEDF5" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#9C9EB4" />
|
||||
<SolidColorBrush x:Key="TextGhost" Color="#7C7F98" />
|
||||
|
||||
<!--
|
||||
The accent, and it is two colours rather than one. The design fills with #5B8CFF — the button, the
|
||||
floating action button, a progress bar, a toggle that is on — and writes with #8FA7FF: the current
|
||||
The accent, and it is two colours rather than one. The design fills with #5D42DE — the button, the
|
||||
floating action button, a progress bar, a toggle that is on — and writes with #A78BFA: the current
|
||||
destination in the bottom bar, a link, the text inside a chip. They are not interchangeable in either
|
||||
direction. The fill is too saturated to read as small text on a dark surface, and the text colour is
|
||||
too pale to carry a button.
|
||||
|
||||
AccentInk is what goes *on* the fill. It equals Canvas today and is named separately anyway, because
|
||||
"the darkest surface" and "the colour of text on a blue button" are two facts that happen to coincide,
|
||||
and a light theme would separate them immediately.
|
||||
AccentInk is what goes *on* the fill, and it no longer equals Canvas the way it did through v1 and v2:
|
||||
the new accent is dark enough on its own that white carries better on it than the near-black the app
|
||||
otherwise writes on. "The darkest surface" and "the colour of text on the accent" were two facts that
|
||||
happened to coincide before; this design is the light theme the old comment predicted would separate
|
||||
them, arriving by a different route.
|
||||
|
||||
AccentGradient and the glow beneath it are new in v3 — the design draws its primary buttons with a
|
||||
top-to-bottom gradient rather than a flat fill, lighter violet into the accent proper, and lifts them
|
||||
off the surface with a soft violet-tinted shadow rather than a border. Both are named here rather than
|
||||
inlined in App.axaml for the same reason every other accent value is: one button styled from the wrong
|
||||
purple is how a screenshot next to another screenshot finds the seam.
|
||||
-->
|
||||
<Color x:Key="AccentColor">#5B8CFF</Color>
|
||||
<Color x:Key="AccentColor">#5D42DE</Color>
|
||||
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="AccentText" Color="#8FA7FF" />
|
||||
<SolidColorBrush x:Key="AccentInk" Color="#0E1220" />
|
||||
<SolidColorBrush x:Key="AccentSoft" Color="#5B8CFF" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="AccentWash" Color="#5B8CFF" Opacity="0.06" />
|
||||
<SolidColorBrush x:Key="AccentText" Color="#A78BFA" />
|
||||
<SolidColorBrush x:Key="AccentInk" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="AccentSoft" Color="#5D42DE" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="AccentWash" Color="#5D42DE" Opacity="0.06" />
|
||||
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Color="#8D51E7" Offset="0" />
|
||||
<GradientStop Color="#5D42DE" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
<BoxShadows x:Key="AccentGlow">0 0 22 0 #598D51E7</BoxShadows>
|
||||
|
||||
<!--
|
||||
◆ Green is no longer the accent, and that is the one semantic change in this file rather than a
|
||||
recolour.
|
||||
◆ Green is still not the accent, and purple replacing blue in v3 does not reopen that question.
|
||||
|
||||
It used to be both: Ellipse.dot.live filled with Accent, so "a shell is open on this host" and "this is
|
||||
the thing the screen wants you to press" were the same colour by construction. The v2 design keeps a
|
||||
green — #3DDC97, for the live dot, the sync light and the follow indicator — and makes the accent blue,
|
||||
which finally separates the two. Live is that green, and nothing that is merely interactive may use it.
|
||||
Blue was the accent through v1 and v2; purple is the accent now. Either way, Ellipse.dot.live keeps its
|
||||
own colour rather than borrowing the accent's, which is the separation this comment originally recorded
|
||||
when the accent first stopped being green: "a shell is open on this host" and "this is the thing the
|
||||
screen wants you to press" read as two different colours by construction, not by coincidence of which
|
||||
two hues the current design happens to pick.
|
||||
|
||||
Read the pair as: blue is what you can do, green is what is true.
|
||||
Live is #34D399 in this design — the design's own green, a shade cooler than v2's #3DDC97 — for the
|
||||
live dot, the sync light and the follow indicator. Nothing that is merely interactive may use it.
|
||||
|
||||
Read the pair as: purple is what you can do, green is what is true.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Live" Color="#3DDC97" />
|
||||
<SolidColorBrush x:Key="LiveWash" Color="#3DDC97" Opacity="0.12" />
|
||||
<SolidColorBrush x:Key="Live" Color="#34D399" />
|
||||
<SolidColorBrush x:Key="LiveWash" Color="#34D399" Opacity="0.12" />
|
||||
|
||||
<!--
|
||||
The three colours that are allowed to disagree with the accent. Amber means a caveat worth reading, red
|
||||
means refused or destructive, and blue-light is for the one thing that is neither — an informational log
|
||||
line, a directory, a distinct scope.
|
||||
|
||||
Info is left at its v2 value, #7FB0FF, rather than moved: it was already meant to read as distinct from
|
||||
the accent, and now that the accent has moved to purple a blue that used to sit close to it on the wheel
|
||||
sits further from it instead — the separation this colour exists for gets stronger for free.
|
||||
|
||||
WarnText and DangerText are the prose weights: a wash is a very dark tint, and the signal colour that
|
||||
works as a 6-pixel dot or a one-word label is not the one that reads as a sentence on top of it. The
|
||||
design supplies both reds — #E86A6A labels the refusal and #FF8F8F writes it out — and one amber, so
|
||||
the amber prose weight is interpolated.
|
||||
works as a 6-pixel dot or a one-word label is not the one that reads as a sentence on top of it. Danger
|
||||
keeps its v2 values outright — the design does not revisit red — so DangerSoft, DangerWash and
|
||||
DangerText stay derived from the same #E86A6A they always were. Warn's fill and wash move to the
|
||||
design's own #F59E0B, and WarnText to the design's own #F5B942 rather than the interpolated value v2
|
||||
carried, because this design supplies a prose weight where v2's did not name one.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Warn" Color="#FFB454" />
|
||||
<SolidColorBrush x:Key="WarnSoft" Color="#FFB454" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="WarnWash" Color="#FFB454" Opacity="0.12" />
|
||||
<SolidColorBrush x:Key="WarnText" Color="#E4A868" />
|
||||
<SolidColorBrush x:Key="Warn" Color="#F59E0B" />
|
||||
<SolidColorBrush x:Key="WarnSoft" Color="#F59E0B" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="WarnWash" Color="#F59E0B" Opacity="0.12" />
|
||||
<SolidColorBrush x:Key="WarnText" Color="#F5B942" />
|
||||
<SolidColorBrush x:Key="Danger" Color="#E86A6A" />
|
||||
<SolidColorBrush x:Key="DangerSoft" Color="#E86A6A" Opacity="0.3" />
|
||||
<SolidColorBrush x:Key="DangerWash" Color="#E86A6A" Opacity="0.12" />
|
||||
@@ -153,17 +212,24 @@
|
||||
<SolidColorBrush x:Key="Info" Color="#7FB0FF" />
|
||||
|
||||
<!--
|
||||
The design asks for Instrument Sans and Fira Code, as the one before it asked for IBM Plex. Neither
|
||||
ships with this application, and neither is on a stock Windows install or a stock Android one, so
|
||||
requesting them by name would render as whatever the font fallback chose that day — which is worse than
|
||||
choosing deliberately. Inter is embedded by both heads and is what they already draw with.
|
||||
Montserrat, JetBrains Mono and Material Icons are embedded font files rather than names handed to the
|
||||
font fallback system, and that is a change from every design before this one. v1 asked for IBM Plex,
|
||||
v2 for Instrument Sans and Fira Code, and neither shipped with the application nor came stock on a
|
||||
Windows or an Android install, so requesting either by name would have rendered as whatever fallback
|
||||
the platform chose that day — worse than substituting deliberately. Inter stood in for both heads' sans
|
||||
and the generic `monospace` stood in for the mono stack, and that substitution was the reversible
|
||||
stand-in this comment used to describe.
|
||||
|
||||
The stack ends in the generic `monospace` rather than a Windows face, which is what makes it work on
|
||||
both: Android has no Cascadia Mono or Consolas and resolves the generic name to its own mono face.
|
||||
|
||||
Named as a resource rather than repeated, because the substitution is the sort of thing that gets
|
||||
reversed later and should be reversible in one place. See docs/design-import-gaps.md.
|
||||
It no longer needs to be: the three faces this design names ship as TTFs under Assets/Fonts in this
|
||||
project, embedded the same way WebAssets is, and resolved by the same avares:// URI scheme Inter
|
||||
already used. MonoFont below is JetBrains Mono first, with the old system-mono stack kept after it as a
|
||||
fallback for whatever glyph JetBrains Mono does not cover. IconFont is new: the design's icons are drawn
|
||||
from Material Icons by codepoint rather than by name, and screens adopt that per glyph as they are
|
||||
touched rather than all at once — this resource only makes the face available. Program.cs makes the
|
||||
equivalent change for the sans stack, with Montserrat first and Inter kept as its fallback; see the
|
||||
remarks on BuildAvaloniaApp.
|
||||
-->
|
||||
<FontFamily x:Key="MonoFont">ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
|
||||
<FontFamily x:Key="MonoFont">avares://DodoSSH.Client.Shell/Assets/Fonts#JetBrains Mono,ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
|
||||
<FontFamily x:Key="IconFont">avares://DodoSSH.Client.Shell/Assets/Fonts#Material Icons</FontFamily>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -308,6 +308,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
||||
|
||||
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
|
||||
/// <remarks>
|
||||
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
|
||||
/// ever names, so a tick any faster would restring a fact that has not changed in any word it prints.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hosts screen was showing the last time <see cref="UpdateLastConnectedVisibility"/> ran.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The transition is what matters, not the level — see that method. Without this, every unrelated
|
||||
/// screen or surface change would re-read the connection log for no reason, which is exactly the
|
||||
/// background re-read <c>LogsViewModel.cs</c> argues a connection log must never be put on.
|
||||
/// </remarks>
|
||||
private bool wasHostsScreenShowing;
|
||||
|
||||
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
|
||||
private CancellationTokenSource? lastConnectedTick;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -2633,6 +2653,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
@@ -2892,8 +2917,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
/// <remarks>
|
||||
/// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts
|
||||
/// with neither, and this is the one place both know a rebuild just happened. No log read here — see
|
||||
/// <see cref="VaultViewModel.RestringLastConnected"/> — only the timestamps already on hand, written
|
||||
/// onto whichever row objects exist now.
|
||||
/// </remarks>
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
RefreshConnectedHosts();
|
||||
Vault?.RestringLastConnected();
|
||||
}
|
||||
|
||||
private void RaiseSyncState()
|
||||
{
|
||||
@@ -3152,9 +3186,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
|
||||
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
|
||||
/// the scrollback, and the renderer has already written the reason into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Also where a session ending re-reads the connection log</b>, per decision 4: the host just closed
|
||||
/// is the one whose ago-text is about to stop being suppressed by <see cref="HostRowViewModel.IsConnected"/>,
|
||||
/// and it deserves "just now" rather than whatever it last said. <see cref="ConnectionRecorder.Closed"/>
|
||||
/// runs before this event does, but its own write is queued onto a background task rather than made
|
||||
/// inline — see its remarks — so a read landing before that write drains shows the previous entry
|
||||
/// instead. It self-heals on the next activation or the next session end, which is judged an acceptable
|
||||
/// gap rather than worth blocking this handler on the recorder's queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
@@ -3165,29 +3210,110 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list.
|
||||
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
||||
/// wrong than a dot that goes dark when the vault is reopened.
|
||||
/// </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
|
||||
/// 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>
|
||||
/// </remarks>
|
||||
private void RefreshConnectedHosts()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
return;
|
||||
}
|
||||
|
||||
HostRowViewModel? connectedActiveHost = null;
|
||||
|
||||
foreach (var host in vault.Hosts)
|
||||
{
|
||||
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.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
{
|
||||
connectedActiveHost = host;
|
||||
}
|
||||
}
|
||||
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
|
||||
if (connectedActiveHost is not null)
|
||||
{
|
||||
foreach (var path in connectedActiveHost.Host.PinnedPaths)
|
||||
{
|
||||
ActiveTabPinnedPaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
|
||||
/// </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.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
|
||||
|
||||
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
|
||||
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pin strip 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.
|
||||
/// </remarks>
|
||||
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
|
||||
|
||||
/// <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
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnLiveSessionCountChanged(int value)
|
||||
@@ -3253,6 +3379,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ = vaults.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
UpdateLastConnectedVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3324,7 +3451,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// so handing it the vault's object would select nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
|
||||
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
|
||||
_ = GoToHostFilesAsync(e.Host, null);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The shared body behind <see cref="OnVaultFilesRequested"/> and <see cref="OpenPinnedPathAsync"/>: which
|
||||
/// machine and where to land once it is open are the only two things that differ between "Browse files"
|
||||
/// and a click on the pin strip, and both are parameters here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host asking for a typed password still only opens the picker.</b> <paramref name="path"/> is not
|
||||
/// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files
|
||||
/// already had before there was a path to carry, and closing it would mean holding a pending navigation
|
||||
/// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin
|
||||
/// that has to be clicked again once the session is open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It reconnects rather than checking whether this host is already open.</b> The existing "Browse
|
||||
/// files" flow — <see cref="TransfersViewModel.ConnectCommand"/> — never asked, and reusing it here
|
||||
/// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files
|
||||
/// does not have. See <c>hosts-v5-design-spec.md</c>: SFTP is a second authenticated connection, every
|
||||
/// time it is opened, by design.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task GoToHostFilesAsync(HostRowViewModel host, string? path)
|
||||
{
|
||||
ShowFiles(RemoteKind.Host);
|
||||
|
||||
@@ -3336,7 +3490,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
Transfers.SelectedHost =
|
||||
Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
|
||||
Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);
|
||||
|
||||
if (Transfers.SelectedHost is null)
|
||||
{
|
||||
@@ -3346,11 +3500,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (Transfers.SelectedHostAsksForAPassword)
|
||||
{
|
||||
Transfers.BeginChoosingRemoteCommand.Execute(null);
|
||||
Transfers.Status = $"{e.Host.Label} asks for a password. Type it here, then CONNECT.";
|
||||
Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT.";
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
|
||||
if (path is not null && Transfers.IsConnected)
|
||||
{
|
||||
await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
@@ -3369,6 +3528,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
RaiseSurfaceState();
|
||||
UpdateLastConnectedVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called from both <see cref="OnScreenChanged"/> and <see cref="OnSurfaceChanged"/>, because either one
|
||||
/// alone can be what makes <see cref="IsHostsShowing"/> flip: <see cref="ShowScreenCommand"/> moves both
|
||||
/// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface,
|
||||
/// and switching groups on the hosts screen itself moves neither. <see cref="wasHostsScreenShowing"/> is
|
||||
/// what turns two call sites into one decision rather than two chances to double the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The read this starts is the one <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already argues
|
||||
/// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the
|
||||
/// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait
|
||||
/// on a decrypt pass over its log.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void UpdateLastConnectedVisibility()
|
||||
{
|
||||
var showing = IsHostsShowing;
|
||||
|
||||
if (showing == wasHostsScreenShowing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wasHostsScreenShowing = showing;
|
||||
|
||||
if (showing)
|
||||
{
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
StartLastConnectedTick();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopLastConnectedTick();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No log read here or in <see cref="RunLastConnectedTickAsync"/>.</b> The tick only turns the
|
||||
/// timestamps <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already read into new words, through
|
||||
/// <see cref="VaultViewModel.RestringLastConnected"/> — reading the connection log itself on a timer is
|
||||
/// exactly what <c>LogsViewModel.cs</c> argues that log must never be put on.
|
||||
/// </remarks>
|
||||
private void StartLastConnectedTick()
|
||||
{
|
||||
if (lastConnectedTick is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
lastConnectedTick = cts;
|
||||
_ = RunLastConnectedTickAsync(cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>Stops the loop <see cref="StartLastConnectedTick"/> began, if one is running.</summary>
|
||||
/// <remarks>
|
||||
/// Cancelled rather than merely forgotten, so the loop's own <c>PeriodicTimer</c> wait unblocks and the
|
||||
/// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
|
||||
/// </remarks>
|
||||
private void StopLastConnectedTick()
|
||||
{
|
||||
if (lastConnectedTick is not { } cts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastConnectedTick = null;
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Vault?.RestringLastConnected();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The hosts screen navigated away, or the shell is closing.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -3378,6 +3633,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));
|
||||
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsKeychainScreen));
|
||||
@@ -3423,6 +3684,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));
|
||||
|
||||
// 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
|
||||
// on screen; see TerminalTabViewModel.IsShowing.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,8 @@ internal static class HeadlessApp
|
||||
/// On a machine with no fonts at all it does not merely differ, it stops: <c>FontManager</c> throws
|
||||
/// "Default font family name can't be null or empty" during <c>AppBuilder.SetupUnsafe</c>, before any
|
||||
/// test body runs. That is how a container CI runner reports all sixty-eight of these as failures with
|
||||
/// not one assertion among them. Naming Inter costs the runner nothing, because the font travels
|
||||
/// inside the package rather than on the host.
|
||||
/// not one assertion among them. Naming a font costs the runner nothing, because it travels inside the
|
||||
/// package rather than on the host — true of Inter before this and of Montserrat now.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
@@ -50,13 +50,27 @@ internal static class HeadlessApp
|
||||
.UseSkia()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
|
||||
.WithInterFont()
|
||||
.With(new FontManagerOptions { DefaultFamilyName = InterFamily });
|
||||
.With(new FontManagerOptions
|
||||
{
|
||||
DefaultFamilyName = DefaultFamily,
|
||||
FontFallbacks = [new FontFallback { FontFamily = new FontFamily(InterFamily) }],
|
||||
});
|
||||
|
||||
/// <summary>The family <c>WithInterFont</c> registers.</summary>
|
||||
/// <summary>The family <c>Program.BuildAvaloniaApp</c> asks the font manager to default to.</summary>
|
||||
/// <remarks>
|
||||
/// Kept in step with <c>Program.BuildAvaloniaApp</c> by hand, and it matters that they agree: a
|
||||
/// harness pinning a different default from the one the application runs with would certify heights
|
||||
/// for a window nobody sees, which is the failure the opening paragraph of this file is about.
|
||||
/// for a window nobody sees, which is the failure the opening paragraph of this file is about. It was
|
||||
/// Inter until the v3 design; now it is Montserrat, embedded the same way, and Inter moves down to
|
||||
/// <see cref="InterFamily"/> as the fallback both <c>BuildAvaloniaApp</c> methods name it as.
|
||||
/// </remarks>
|
||||
internal const string DefaultFamily = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat";
|
||||
|
||||
/// <summary>The family <c>WithInterFont</c> registers.</summary>
|
||||
/// <remarks>
|
||||
/// No longer the default — see <see cref="DefaultFamily"/> — but still registered and still named
|
||||
/// here, because it is still the fallback both heads' <c>BuildAvaloniaApp</c> fall back to, and a test
|
||||
/// measuring a glyph Montserrat does not cover has to fall back to the same font the application does.
|
||||
/// </remarks>
|
||||
internal const string InterFamily = "avares://Avalonia.Fonts.Inter/Assets#Inter";
|
||||
}
|
||||
|
||||
@@ -18,26 +18,36 @@ using NSubstitute;
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// How the grid of host cards answers a pointer.
|
||||
/// How the board of host cards answers a pointer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
|
||||
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
|
||||
/// <c>HostsScreen.axaml.cs</c>.
|
||||
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on <see cref="HostsScreen"/>
|
||||
/// now, and so is every handler that was wired to them. See <c>HostsScreen.axaml.cs</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ v5: THE GROUP-CARD HALF OF THIS SUITE IS GONE, AND IT IS NOT REPLACED HERE. The desktop used to hold a
|
||||
/// wrap of group cards above the hosts — one level of the tree at a time, opened by a double-click, filed
|
||||
/// into by dragging a host card onto one. All of that left with the cards: v5 draws every group as a
|
||||
/// section heading instead, see <c>VaultViewModel.HostSections</c>, and a host is filed through its own
|
||||
/// editor or the chosen-hosts menu's "Change group…" rather than a drag. The card-navigation surface this
|
||||
/// was written against — <c>OpenGroupCommand</c>, <c>VisibleGroups</c>, <c>GroupTrail</c>,
|
||||
/// <c>SelectedGroup</c>, <c>GroupCrumbViewModel</c>, and the drag command <c>MoveHostToGroupCommand</c> dragged
|
||||
/// onto — is no longer on <c>VaultViewModel</c> at all, confirmed unreachable from both heads' markup before
|
||||
/// removal; <c>GroupFilter</c> stays, since group-heading commands still fall back to it. Section-level
|
||||
/// coverage — flattening order, the headerless invariant, per-section collapse, filter composition, monogram
|
||||
/// stability — is in <c>HostSectionsTests</c> instead, which is where a heading now belongs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
||||
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
|
||||
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
|
||||
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
|
||||
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
||||
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
|
||||
/// quieter still.
|
||||
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring:
|
||||
/// a right click has to move the selection <em>before</em> the menu opens, because the commands on both of
|
||||
/// that menu's halves read the vault's selection. A menu that quietly acted on whichever host happened to be
|
||||
/// selected would delete the wrong machine, which is the version of this mistake worth a suite.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
||||
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
|
||||
/// one: compiled bindings resolve against the declared type, and the board is built out of the vault's own
|
||||
/// hosts and groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -102,27 +112,24 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task ARightClickSelectsTheHostUnderThePointer()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var first = Row(vault, "prod-db");
|
||||
var other = Row(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = first;
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
var card = CardFor(screen, other);
|
||||
RightClick(card, window);
|
||||
|
||||
vault.SelectedHost.ShouldBeSameAs(other);
|
||||
|
||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
||||
var menu = SectionMenuFor(card).ShouldNotBeNull();
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
|
||||
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
||||
// than in the item template: a menu inside the template would have the row for its data context,
|
||||
// and every one of these would silently bind to nothing.
|
||||
//
|
||||
// Drawn rather than merely present, because this menu carries a second Edit… that is about the
|
||||
// ticked set rather than about the selection; with nothing ticked that one is collapsed. See
|
||||
// TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
|
||||
// The commands are the vault's, which is the other half of putting the menu on each section's
|
||||
// own list rather than in the card's item template: a menu inside the template would have the
|
||||
// row for its data context, and every one of these would silently bind to nothing.
|
||||
var edit = menu.Items.OfType<MenuItem>()
|
||||
.Single(item => item.IsVisible && item.Header is "Edit…");
|
||||
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
||||
@@ -135,388 +142,38 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
|
||||
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
|
||||
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
|
||||
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
|
||||
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
|
||||
/// </para>
|
||||
/// The space around the cards and below them is part of <c>Board</c> rather than of any one section's
|
||||
/// list, and a menu offering Connect, Edit and Delete over it would be three entries acting on whichever
|
||||
/// machine happened to be selected — which is the whole mistake this handler exists to prevent, reached
|
||||
/// by clicking nothing at all. Raised on <c>Board</c> itself, which is where the code-behind's tunnelled
|
||||
/// handler is attached; see <c>HostsScreen.axaml.cs</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
await OnTheBoardAsync((screen, _) =>
|
||||
{
|
||||
var selected = Row(vault, "prod-db");
|
||||
vault.SelectedHost = selected;
|
||||
|
||||
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
|
||||
screen.Board.RaiseEvent(new ContextRequestedEventArgs
|
||||
{
|
||||
RoutedEvent = Control.ContextRequestedEvent,
|
||||
Source = screen.HostGrid,
|
||||
Source = screen.Board,
|
||||
});
|
||||
|
||||
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
||||
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||
|
||||
screen.GetVisualDescendants().OfType<ListBox>()
|
||||
.Where(list => list.Classes.Contains("sectioncards"))
|
||||
.Select(list => list.ContextMenu)
|
||||
.OfType<ContextMenu>()
|
||||
.ShouldAllBe(menu => !menu.IsOpen, "no section's menu opened over the empty space");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same rule on the cards above, where getting it wrong is quieter and worse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
|
||||
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
|
||||
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
|
||||
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
|
||||
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
|
||||
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickSelectsTheGroupUnderThePointer()
|
||||
{
|
||||
await AddGroupAsync("staging");
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = GroupRow(vault, "production");
|
||||
var other = GroupRow(vault, "staging");
|
||||
|
||||
vault.SelectedGroup = first;
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
|
||||
vault.SelectedGroup.ShouldBeSameAs(other);
|
||||
|
||||
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
|
||||
var items = menu.Items.OfType<MenuItem>().ToList();
|
||||
|
||||
var open = items.Single(item => item.Header is "Open");
|
||||
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
|
||||
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
|
||||
|
||||
var edit = items.Single(item => item.Header is "Edit…");
|
||||
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
|
||||
|
||||
edit.Command!.Execute(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe(
|
||||
other.Label, "the card that was right-clicked, not the one selected before");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
|
||||
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
|
||||
/// trail ends with — which, once it is open, is not a card on screen at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var selected = GroupRow(vault, "production");
|
||||
vault.SelectedGroup = selected;
|
||||
|
||||
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
|
||||
{
|
||||
RoutedEvent = Control.ContextRequestedEvent,
|
||||
Source = screen.GroupGrid,
|
||||
});
|
||||
|
||||
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
||||
screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host held over a group card would be filed there, and one held over another host card would not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The group headings that used to sit between the cards are gone — see
|
||||
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
|
||||
/// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
|
||||
/// dropped onto another card used to file it beside that one, which was legible while a heading said
|
||||
/// which group the band of cards belonged to and is guesswork now that none does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
|
||||
/// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
|
||||
/// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
|
||||
/// covers is in docs/manual-checks.md 7.6.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
var over = Over(onto, carried);
|
||||
|
||||
onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
|
||||
over.DragEffects.ShouldBe(DragDropEffects.Move);
|
||||
|
||||
var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);
|
||||
|
||||
refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
|
||||
refused.DragEffects.ShouldBe(
|
||||
DragDropEffects.None,
|
||||
"a card dropped onto another card would be filed somewhere nothing on screen names");
|
||||
|
||||
// And the group card it was over a moment ago stops offering to take it, which is the half of
|
||||
// this that is wrong far more often than the mark appearing at all.
|
||||
onto.Classes.ShouldNotContain("droptarget");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The rule one press was split into two gestures for. Selecting a group marks it and does nothing else;
|
||||
/// opening one is what narrows the grid, and the trail is the way back out of it. While a single press
|
||||
/// meant both, a group could not be named without every host outside it leaving the screen at the same
|
||||
/// moment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Driven through the properties the cards bind rather than through a click, because what is worth
|
||||
/// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would
|
||||
/// otherwise be testing Avalonia's <c>SelectedItem</c> binding, which is not this application's code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
|
||||
{
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
||||
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
|
||||
|
||||
// Re-found after the move, because the reload it ends with replaces every row in the list.
|
||||
var production = vault.VisibleGroups.Single();
|
||||
|
||||
vault.SelectedGroup = production;
|
||||
|
||||
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
|
||||
vault.GroupTarget.ShouldBeSameAs(production, "what a group command with no argument acts on");
|
||||
|
||||
vault.OpenGroupCommand.Execute(production);
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["prod-db"], "only what is filed under the group that is open");
|
||||
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
||||
|
||||
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
|
||||
vault.GroupTarget.ShouldBeSameAs(
|
||||
production, "so an unaimed command falls back to the group whose contents are showing");
|
||||
|
||||
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
|
||||
// same job and went with the change.
|
||||
vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
|
||||
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two grids share one selection, so at most one card on the screen is ever lit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
|
||||
/// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
|
||||
/// which only one acts on whichever card the eye has settled on. The vault is what joins them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Driven on the screen rather than on the view model alone, because half of the rule lives in the
|
||||
/// controls: clearing the property has to reach the list that is drawing the card, and a selection
|
||||
/// nulled in the view model while the card stays highlighted is the exact failure this is about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostAndGroupGridsShareOneSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var host = Row(vault, "stage-web");
|
||||
|
||||
vault.OpenHostPaneCommand.Execute(host);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup = vault.VisibleGroups.Single();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
|
||||
vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
|
||||
screen.HostGrid.SelectedItem.ShouldBeNull();
|
||||
CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
|
||||
vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");
|
||||
|
||||
vault.SelectedHost = host;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
|
||||
screen.GroupGrid.SelectedItem.ShouldBeNull();
|
||||
GroupCard(screen).IsSelected.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
|
||||
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
|
||||
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
|
||||
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
|
||||
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
vault.SelectedGroup = vault.VisibleGroups.Single();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
screen.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|
||||
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
|
||||
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
|
||||
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
|
||||
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
|
||||
{
|
||||
var production = vault.VisibleGroups.Single();
|
||||
|
||||
vault.SelectedGroup = production;
|
||||
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
|
||||
vault.SelectedGroup
|
||||
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
|
||||
.EntityId.ShouldBe(production.EntityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
|
||||
/// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
|
||||
/// what it asserts is the half that would go unnoticed: one press must still only select, or the split
|
||||
/// bought nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DoubleClickingAGroupCardOpensIt()
|
||||
{
|
||||
// One press, in a window of its own, because a second pair of clicks in the same one is the gesture
|
||||
// this is separating that press from.
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var centre = Centre(GroupCard(screen), window);
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
|
||||
vault.GroupFilter.ShouldBeNull("and opens nothing");
|
||||
});
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var centre = Centre(GroupCard(screen), window);
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
||||
|
||||
// And out again through the trail as it is actually drawn, rather than through the command. A
|
||||
// crumb is an item in a template and the command it presses is the vault's, so the two are
|
||||
// joined by a $parent binding — which is a string that compiles whether or not it resolves, and
|
||||
// would leave a trail of buttons that do nothing.
|
||||
var back = screen.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(button => button.DataContext is GroupCrumbViewModel { Group: null });
|
||||
|
||||
back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);
|
||||
|
||||
vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
|
||||
vault.VisibleHosts.Count.ShouldBe(2);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cards are one level of the tree, and the trail is how that level was reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The grid used to draw every group at once, which was the only honest thing to do while a card was a
|
||||
/// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
|
||||
/// navigation the cards became its contents — and a level with no name and no way back is a grid that
|
||||
/// has quietly hidden things, which is what the trail is for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
|
||||
{
|
||||
await AddGroupAsync("estate");
|
||||
await FileGroupUnderAsync("production", "estate");
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label)
|
||||
.ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
||||
|
||||
vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);
|
||||
|
||||
// A middle crumb goes back one level rather than all the way out, which is the whole reason the
|
||||
// trail is a row of buttons instead of a sentence saying where you are.
|
||||
vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);
|
||||
|
||||
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
|
||||
/// Choosing a host costs nothing, and the pencil on its card is what spends the 320 pixels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -527,13 +184,14 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// <para>
|
||||
/// Driven through the card's own button rather than by executing the command, since the thing most
|
||||
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
|
||||
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
|
||||
/// <c>#Board</c> path that resolves to nothing compiles, draws, and does nothing when pressed. See the
|
||||
/// remark on that idiom at the top of <c>HostsScreen.axaml</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
await OnTheBoardAsync((screen, _) =>
|
||||
{
|
||||
var host = Row(vault, "stage-web");
|
||||
|
||||
@@ -559,14 +217,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
|
||||
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
|
||||
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
|
||||
/// which is the behaviour the pencil exists to remove.
|
||||
/// selection that goes away entirely has to take it with it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
||||
{
|
||||
await OnTheGridAsync((_, _) =>
|
||||
await OnTheBoardAsync((_, _) =>
|
||||
{
|
||||
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
|
||||
vault.IsDrawerOpen.ShouldBeTrue();
|
||||
@@ -583,15 +239,15 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
//
|
||||
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
|
||||
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
|
||||
// gesture means what, and the rule that keeps the list's own selection and the set from ever both being
|
||||
// about something at the same moment. See HostsScreen.axaml.cs.
|
||||
// gesture means what, and the rule that keeps a section list's own selection and the set from ever both
|
||||
// being about something at the same moment. See HostsScreen.axaml.cs.
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
|
||||
/// also moved the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
|
||||
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
|
||||
/// down rather than acted on as it bubbles.
|
||||
/// also moved a section's own <c>ListBox</c> mark would light the card it had just unticked and open the
|
||||
/// drawer on a machine somebody is removing from a set. Stopping that is why the press is handled on the
|
||||
/// way down rather than acted on as it bubbles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||
@@ -601,7 +257,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var first = Row(vault, "prod-db");
|
||||
var second = Row(vault, "stage-web");
|
||||
@@ -614,7 +270,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
vault.ChosenHostCount.ShouldBe(1);
|
||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
|
||||
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
@@ -632,16 +288,17 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
|
||||
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
|
||||
/// the grid's own, so "between" means between as the cards are laid out.
|
||||
/// the board's own — <c>VaultViewModel.HostBoardOrder</c> — so "between" means between as the cards are
|
||||
/// laid out, across sections if the run crosses one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
||||
{
|
||||
await AddHostAsync("dev-box");
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var order = vault.VisibleHosts.ToList();
|
||||
var order = vault.HostBoardOrder.ToList();
|
||||
|
||||
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
|
||||
|
||||
@@ -665,8 +322,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// <para>
|
||||
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
|
||||
/// selection or the start of a drag of that host, and the band is what the space between and below them
|
||||
/// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
|
||||
/// and a band that had to contain one would tick nothing at all when drawn down a column.
|
||||
/// is for. The rectangle ticks what it touches rather than what it swallows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The second half is the same press without the drag: a click on the empty space is how somebody who
|
||||
@@ -676,7 +332,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var first = CardFor(screen, Row(vault, "prod-db"));
|
||||
var last = CardFor(screen, Row(vault, "stage-web"));
|
||||
@@ -718,23 +374,24 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
|
||||
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
|
||||
/// must never happen is both being offered at once: a Delete… asking about the card under the pointer
|
||||
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
|
||||
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
|
||||
/// must never happen is both being offered at once. A right click outside the set is what drops it, so
|
||||
/// the two are never both meaningful.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var ticked = Row(vault, "prod-db");
|
||||
var other = Row(vault, "stage-web");
|
||||
|
||||
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
|
||||
var tickedCard = CardFor(screen, ticked);
|
||||
|
||||
RightClick(CardFor(screen, ticked), window);
|
||||
Click(tickedCard, window, RawInputModifiers.Control);
|
||||
|
||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
||||
RightClick(tickedCard, window);
|
||||
|
||||
var menu = SectionMenuFor(tickedCard).ShouldNotBeNull();
|
||||
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
|
||||
@@ -748,22 +405,23 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
menu.Close();
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
var otherCard = CardFor(screen, other);
|
||||
RightClick(otherCard, window);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
|
||||
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
|
||||
|
||||
menu.Items.OfType<MenuItem>()
|
||||
SectionMenuFor(otherCard).ShouldNotBeNull().Items.OfType<MenuItem>()
|
||||
.Where(item => item.IsVisible)
|
||||
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
|
||||
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
|
||||
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
|
||||
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
||||
/// Ctrl+A is every card <em>on the board</em> and not every host in the keychain, which is the difference
|
||||
/// that matters the moment there is something in the find box: a shortcut that quietly ticked the
|
||||
/// machines it is not showing would be the worst possible input to Delete. Esc is the way back out, and
|
||||
/// <c>Board</c> is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
|
||||
@@ -772,14 +430,14 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
||||
|
||||
screen.HostGrid.Focus();
|
||||
screen.Board.Focus();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");
|
||||
screen.Board.IsFocused.ShouldBeTrue("the keys are the board's");
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
|
||||
|
||||
@@ -791,64 +449,11 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
|
||||
/// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
|
||||
/// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
|
||||
/// would be a drop the user can see the point of and cannot make.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];
|
||||
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, set));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
var over = Over(onto, carried);
|
||||
|
||||
over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
||||
/// <remarks>
|
||||
/// Declared again here rather than made visible, because what the two have in common is the contract —
|
||||
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
|
||||
/// started carrying something else under it.
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
||||
/// <remarks>
|
||||
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
|
||||
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
|
||||
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
|
||||
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
|
||||
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
|
||||
/// </remarks>
|
||||
private static DragEventArgs Over(Interactive target, DataTransfer carried)
|
||||
private static void RightClick(Visual card, Visual window)
|
||||
{
|
||||
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
|
||||
|
||||
target.RaiseEvent(over);
|
||||
|
||||
return over;
|
||||
}
|
||||
|
||||
private static void RightClick(Visual row, Visual window)
|
||||
{
|
||||
var at = Centre(row, window);
|
||||
var at = Centre(card, window);
|
||||
|
||||
((Window)window).MouseDown(at, MouseButton.Right);
|
||||
((Window)window).MouseUp(at, MouseButton.Right);
|
||||
@@ -874,7 +479,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
control.TranslatePoint(default, window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
||||
private Task OnTheBoardAsync(Action<HostsScreen, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
@@ -894,53 +499,27 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
private static ListBoxItem GroupCard(HostsScreen screen) =>
|
||||
screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
|
||||
private static ListBoxItem CardFor(Visual screen, object row) =>
|
||||
/// <summary>The card for a host, out of whichever section's own list is drawing it.</summary>
|
||||
private static ListBoxItem CardFor(Visual screen, HostRowViewModel row) =>
|
||||
screen.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => ReferenceEquals(item.DataContext, row));
|
||||
|
||||
/// <summary>The <c>ContextMenu</c> of the section list a card belongs to.</summary>
|
||||
/// <remarks>
|
||||
/// Every section draws its own <c>ListBox</c> — see the remarks on <c>HostSectionViewModel</c> — so
|
||||
/// there is no longer one menu for the whole board; this is what "the menu" now means for a given card.
|
||||
/// </remarks>
|
||||
private static ContextMenu? SectionMenuFor(ListBoxItem card) =>
|
||||
card.FindAncestorOfType<ListBox>()?.ContextMenu;
|
||||
|
||||
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
||||
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <remarks>
|
||||
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
|
||||
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
|
||||
/// </remarks>
|
||||
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
|
||||
vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
|
||||
private async Task FileGroupUnderAsync(string group, string parent)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
|
||||
private async Task AddHostAsync(string label)
|
||||
{
|
||||
@@ -953,7 +532,11 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
|
||||
/// <remarks>
|
||||
/// Two hosts and a group, so there is a heading in the board and a selection to move off. Neither host
|
||||
/// is filed under the group — it stays in the seed only because several of the suites in this project
|
||||
/// still ask for one to exist, empty though it is here.
|
||||
/// </remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
foreach (var label in new[] { "prod-db", "stage-web" })
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="VaultViewModel.HostSections"/>: the desktop board's own flattening, one section per group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out of <see cref="HostGridTests"/> rather than folded into it, because none of what is here needs a
|
||||
/// realized <c>HostsScreen</c> — it is <see cref="VaultViewModel"/> alone, the same surface
|
||||
/// <c>VaultViewModel.RebuildHostSections</c>, <c>FlattenIntoSections</c> and the monogram properties on
|
||||
/// <see cref="HostRowViewModel"/> already carry doc comments for. This is where those comments are held to
|
||||
/// account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason every other suite in this
|
||||
/// project uses one: compiled bindings resolve against the declared type, and a stand-in would still have to
|
||||
/// be it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostSectionsTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"host-sections-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The invariant <c>HostsScreen.axaml</c> depends on for a groupless keychain to look exactly as it did
|
||||
/// before groups existed: one section, and its <c>Header</c> is null rather than a heading with nothing
|
||||
/// worth saying. See <c>HostSectionViewModel.HasHeader</c> and the XAML's own <c>IsVisible</c> on it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrouplessKeychainDrawsOneHeaderlessSection()
|
||||
{
|
||||
await AddHostAsync("prod-db");
|
||||
await AddHostAsync("stage-web");
|
||||
|
||||
vault.HostSections.ShouldHaveSingleItem();
|
||||
vault.HostSections[0].HasHeader.ShouldBeFalse();
|
||||
vault.HostSections[0].Hosts.Select(row => row.Label)
|
||||
.ShouldBe(["prod-db", "stage-web"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every group is a section, in label order, "No group" first — the flattening the phone's
|
||||
/// <c>SidebarRows</c> has always drawn, adopted by the desktop board in v5.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three groups are made out of label order on purpose — "zeta" before "alpha" before "mid" — so a
|
||||
/// pass that happened to preserve creation order rather than sorting would still be caught.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SectionsAreInLabelOrderWithNoGroupFirst()
|
||||
{
|
||||
await AddGroupAsync("zeta");
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("mid");
|
||||
|
||||
await AddHostAsync("loose");
|
||||
await AddHostInGroupAsync("z-host", "zeta");
|
||||
await AddHostInGroupAsync("a-host", "alpha");
|
||||
await AddHostInGroupAsync("m-host", "mid");
|
||||
|
||||
vault.HostSections.Select(section => section.Header?.Label ?? "No group")
|
||||
.ShouldBe(["No group", "alpha", "mid", "zeta"]);
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "alpha", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["a-host"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty group still gets a heading; a filter that empties a group's contents does not remove it, but
|
||||
/// the ungrouped heading disappears entirely once nothing is left under it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AnEmptyNamedGroupKeepsItsHeadingAndAnEmptyUngroupedHeadingDropsAway()
|
||||
{
|
||||
await AddGroupAsync("empty-shelf");
|
||||
await AddHostInGroupAsync("only-host", "empty-shelf");
|
||||
|
||||
vault.HostSections.Select(section => section.Header?.Label)
|
||||
.ShouldNotContain("No group", "nothing is ungrouped, so that heading is not drawn at all");
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "empty-shelf", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["only-host"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Collapsing a section keeps its heading — the count on it is still real — and empties its own card
|
||||
/// list; expanding it again restores the cards. Both go through <c>ToggleGroupCommand</c>, the same
|
||||
/// command the heading's chevron button is bound to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TogglingASectionEmptiesItsCardsWithoutDroppingItsHeading()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
await AddHostInGroupAsync("prod-db", "production");
|
||||
await AddHostInGroupAsync("prod-web", "production");
|
||||
|
||||
var header = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal)).Header!;
|
||||
|
||||
header.IsExpanded.ShouldBeTrue("nothing has folded it yet");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(header);
|
||||
|
||||
var collapsed = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal));
|
||||
collapsed.Header!.IsExpanded.ShouldBeFalse();
|
||||
collapsed.Header.Count.ShouldBe(2, "the heading still says how many are under it");
|
||||
collapsed.Hosts.ShouldBeEmpty("but the card list under it is folded away");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(collapsed.Header);
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["prod-db", "prod-web"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Collapse all folds every section at once and Expand all — the same command, its label swapped by
|
||||
/// <c>CollapseAllLabel</c> — brings every one of them back. See the remark on that property for why it
|
||||
/// reads "collapse" until every section already is one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CollapseAllFoldsEverySectionAndExpandAllRestoresThem()
|
||||
{
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("beta");
|
||||
await AddHostInGroupAsync("a-host", "alpha");
|
||||
await AddHostInGroupAsync("b-host", "beta");
|
||||
await AddHostAsync("loose");
|
||||
|
||||
vault.CollapseAllLabel.ShouldBe("Collapse all", "something is still open, so this is the fold action");
|
||||
|
||||
vault.ToggleAllGroupsCommand.Execute(null);
|
||||
|
||||
vault.HostSections.SelectMany(section => section.Hosts).ShouldBeEmpty("every section folded at once");
|
||||
vault.CollapseAllLabel.ShouldBe("Expand all");
|
||||
|
||||
vault.ToggleAllGroupsCommand.Execute(null);
|
||||
|
||||
vault.HostSections.SelectMany(section => section.Hosts).Select(row => row.Label)
|
||||
.ShouldBe(["loose", "a-host", "b-host"], ignoreOrder: true);
|
||||
vault.CollapseAllLabel.ShouldBe("Collapse all");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group ▾, Tag ▾ and the find box all narrow the same board at once, and a host has to clear every one
|
||||
/// of them that is active — the toolbar's two flyouts are an AND with each other and with the box, even
|
||||
/// though a single flyout's own ticks are an OR among themselves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GroupFilterTagFilterAndFindComposeAsAnIntersection()
|
||||
{
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("beta");
|
||||
|
||||
await AddHostInGroupWithTagAsync("a-prod", "alpha", "prod");
|
||||
await AddHostInGroupWithTagAsync("a-dev", "alpha", "dev");
|
||||
await AddHostInGroupWithTagAsync("b-prod", "beta", "prod");
|
||||
|
||||
var alphaChoice = vault.GroupFilterChoices.Single(choice => choice.Label == "alpha");
|
||||
vault.ToggleGroupFilterCommand.Execute(alphaChoice);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod", "a-dev"], ignoreOrder: true, "narrowed to alpha, both its hosts");
|
||||
|
||||
var prodChoice = vault.TagFilterChoices.Single(choice => choice.Label == "prod");
|
||||
vault.ToggleTagFilterCommand.Execute(prodChoice);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod"], "alpha AND prod, not either alone — b-prod fails the group half");
|
||||
|
||||
vault.HostFilter = "a-dev";
|
||||
|
||||
vault.HostBoardOrder.ShouldBeEmpty(
|
||||
"the find box adds a third condition none of alpha's prod-tagged hosts can pass");
|
||||
|
||||
vault.HostFilter = string.Empty;
|
||||
vault.ResetTagFilterCommand.Execute(null);
|
||||
vault.ResetGroupFilterCommand.Execute(null);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod", "a-dev", "b-prod"], ignoreOrder: true, "every filter cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same label always draws the same two letters and the same hue — across a rebuild, which replaces
|
||||
/// every row object outright, and that is exactly the case worth holding: nothing on the row itself
|
||||
/// survives a reload, so the answer has to come from the label alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FNV-1a rather than <see cref="string.GetHashCode()"/> is the reason this can be asserted at all — see
|
||||
/// <c>HostRowViewModel.MonogramHue</c>. A hash randomised per process would still pass a same-instance
|
||||
/// comparison but never a same-label-after-reload one, which is the case that matters: a card must not
|
||||
/// repaint itself a different colour because the application happened to restart.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSameLabelDrawsTheSameMonogramAcrossAReload()
|
||||
{
|
||||
await AddHostAsync("prod-db-01");
|
||||
|
||||
var before = Host("prod-db-01");
|
||||
var monogram = before.Monogram;
|
||||
var hue = before.MonogramHue;
|
||||
|
||||
monogram.ShouldBe("pd", "the first letters of the first two hyphen-separated words, lowercased");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var after = Host("prod-db-01");
|
||||
after.ShouldNotBeSameAs(before, "the reload replaces every row — this is the case worth holding");
|
||||
after.Monogram.ShouldBe(monogram);
|
||||
after.MonogramHue.ShouldBe(hue);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one-word rule is a different branch of <c>HostRowViewModel.Monogram</c> than the two-word one
|
||||
/// above, and worth its own case: it is the first two characters of the label rather than the first
|
||||
/// letter of two words, which for a one-word host name are not the same computation by coincidence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AOneWordLabelDrawsItsFirstTwoCharacters()
|
||||
{
|
||||
await AddHostAsync("Bastion");
|
||||
|
||||
Host("Bastion").Monogram.ShouldBe("ba");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private HostRowViewModel Host(string label) =>
|
||||
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.GroupEditorLabel = label;
|
||||
await vault.SaveGroupCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostAsync(string label)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostInGroupAsync(string label, string groupLabel)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, groupLabel, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostInGroupWithTagAsync(string label, string groupLabel, string tag)
|
||||
{
|
||||
await AddHostInGroupAsync(label, groupLabel);
|
||||
|
||||
vault.SelectedHost = Host(label);
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewTag = tag;
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(Token);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,11 @@ internal static class LayoutHarness
|
||||
/// <remarks>
|
||||
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
|
||||
/// drawer states its own width instead — it is the only thing in its column and the column is
|
||||
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it.
|
||||
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it. It was 304 through
|
||||
/// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and <c>App.axaml</c>'s
|
||||
/// <c>Border.tile</c> narrowed to keep two columns fitting the grid beside it at the window's minimum.
|
||||
/// </remarks>
|
||||
internal const double HostDrawerWidth = 304;
|
||||
internal const double HostDrawerWidth = 320;
|
||||
|
||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||
internal const double NavRailWidth = 190;
|
||||
|
||||
@@ -141,7 +141,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
//
|
||||
// This was the host sidebar's section. The control kept the half of that column that is about one host
|
||||
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
|
||||
// the right.
|
||||
// the right. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
|
||||
// LayoutHarness.HostDrawerWidth.
|
||||
|
||||
/// <remarks>
|
||||
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
|
||||
@@ -161,7 +162,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
|
||||
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
|
||||
/// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
|
||||
/// wrapped row of tag chips, a checkbox, a relay card, QUICK ACCESS's own rows and its add row, a
|
||||
/// paragraph of hint text and three buttons.
|
||||
///
|
||||
/// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
|
||||
/// harness skips the scrolled content's height (see the remark above
|
||||
/// <see cref="TheHostDrawerFitsWithADeletionInQuestion"/>) but not its width, and a row's own mono path
|
||||
/// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
|
||||
@@ -177,6 +184,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.First(choice => choice.Kind is AuthenticationKind.Credential);
|
||||
|
||||
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
@@ -226,7 +236,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
|
||||
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
|
||||
/// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
|
||||
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
|
||||
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
|
||||
/// </para>
|
||||
@@ -287,7 +297,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
await OnTheHostsScreenAsync((screen, window) =>
|
||||
{
|
||||
var card = screen.HostGrid.GetVisualDescendants()
|
||||
var card = screen.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => item.DataContext is HostRowViewModel);
|
||||
|
||||
@@ -332,22 +342,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
await OnTheHostsScreenAsync((screen, _) =>
|
||||
{
|
||||
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
|
||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
|
||||
screen.KeyboardTarget.ShouldBeSameAs(screen.Board);
|
||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the board has cards on it");
|
||||
});
|
||||
|
||||
vault.HostFilter = "nothing matches this";
|
||||
vault.HasVisibleHosts.ShouldBeFalse();
|
||||
vault.HasHostBoardEntries.ShouldBeFalse();
|
||||
|
||||
await OnTheHostsScreenAsync((screen, _) =>
|
||||
{
|
||||
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
|
||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it");
|
||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the board is empty, so the find box takes it");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The editor open with the grid still on screen beside it, which is the state a user is most likely to
|
||||
/// The editor open with the board still on screen beside it, which is the state a user is most likely to
|
||||
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -357,56 +367,61 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
await OnTheHostsScreenAsync((screen, _) =>
|
||||
{
|
||||
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
|
||||
screen.Board.IsEffectivelyVisible.ShouldBeTrue();
|
||||
screen.KeyboardTarget.Focus().ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The grid is cards and nothing else, whatever the vault has been filed into.
|
||||
/// v5: each section's own card list holds cards and nothing else — the heading is a sibling, not a row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
|
||||
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
|
||||
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
|
||||
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
|
||||
/// that spans the width is as inside it as a card is.
|
||||
/// This used to hold for one flat grid: no group headings were mixed into the host rows, because the
|
||||
/// group cards above it were where a group's name was drawn. The board replaces that grid with one
|
||||
/// section per group — see <c>VaultViewModel.HostSections</c> — and the invariant moves down a level
|
||||
/// with it: it is each section's own <c>ListBox</c> (<c>Classes="sectioncards"</c>) that must hold only
|
||||
/// <see cref="HostRowViewModel"/>s, because <c>HostsScreen.axaml.cs</c> finds every card by walking every
|
||||
/// list wearing that class and would misfire on a heading it found mixed in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured with every host filed and that group open, which is the shape that used to produce the most
|
||||
/// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside
|
||||
/// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid.
|
||||
/// See <c>VaultViewModel.Matches</c>.
|
||||
/// Measured with every host filed under one group, which is the shape that puts the most rows behind one
|
||||
/// heading.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
|
||||
public async Task EachSectionsCardListHoldsCardsAndNothingElse()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
// Filed through the host editor, same as SeedGroupsAsync itself does — the drag this used to exercise
|
||||
// left with the group cards in v5; see VaultViewModel.EditGroup's own remarks.
|
||||
foreach (var host in vault.Hosts.ToArray())
|
||||
{
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
||||
new HostGroupMove(host, vault.Groups[0].EntityId));
|
||||
}
|
||||
vault.SelectedHost = host;
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups[0]);
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices
|
||||
.First(choice => choice.EntityId == vault.Groups[0].EntityId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
await OnTheHostsScreenAsync((screen, _) =>
|
||||
{
|
||||
var rows = screen.HostGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
var rows = screen.GetVisualDescendants()
|
||||
.OfType<ListBox>()
|
||||
.Where(list => list.Classes.Contains("sectioncards"))
|
||||
.SelectMany(list => list.GetVisualDescendants().OfType<ListBoxItem>())
|
||||
.Select(item => item.DataContext)
|
||||
.ToList();
|
||||
|
||||
rows.ShouldNotBeEmpty("the seed has to put hosts in the grid");
|
||||
rows.ShouldNotBeEmpty("the seed has to put hosts in one of the sections");
|
||||
rows.ShouldAllBe(row => row is HostRowViewModel);
|
||||
});
|
||||
|
||||
await MeasureHostsAsync(
|
||||
faults => faults.ShouldBeEmpty("with every host filed and the group holding them open"));
|
||||
faults => faults.ShouldBeEmpty("with every host filed under one group and four sections drawn"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -447,15 +462,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
await OnTheHostsScreenAsync((screen, window) =>
|
||||
{
|
||||
var cards = screen.HostGrid
|
||||
.GetVisualDescendants()
|
||||
var cards = screen.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Where(item => item.DataContext is HostRowViewModel)
|
||||
.Select(item => item.TranslatePoint(default, window)
|
||||
?? throw new InvalidOperationException("a card is not in this window's tree"))
|
||||
.ToList();
|
||||
|
||||
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid");
|
||||
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host on the board");
|
||||
|
||||
cards.GroupBy(point => Math.Round(point.Y))
|
||||
.Max(row => row.Count())
|
||||
@@ -589,8 +603,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SelectedGroup = vault.Groups[0];
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
// Passed directly, the way DeleteGroupFromHeading resolves and hands in a row now that there is no
|
||||
// group-card selection to fall back on.
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups[0]);
|
||||
|
||||
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasChoice
|
||||
@@ -617,8 +632,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SelectedGroup = vault.Groups[0];
|
||||
|
||||
vault.MoveGroupVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
@@ -996,6 +997,126 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
IsReadOnly: false),
|
||||
isLive: false);
|
||||
|
||||
// ---- Last connected ----
|
||||
//
|
||||
// The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
|
||||
// is the log read, and the two triggers below — the hosts screen coming back on screen and a session
|
||||
// ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
|
||||
|
||||
/// <remarks>
|
||||
/// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
|
||||
/// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
|
||||
/// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(0, "just now")]
|
||||
[InlineData(59, "just now")]
|
||||
[InlineData(60, "1 min ago")]
|
||||
[InlineData(150, "2 min ago")]
|
||||
[InlineData(3599, "59 min ago")]
|
||||
[InlineData(3600, "1 hr ago")]
|
||||
[InlineData(7200, "2 hr ago")]
|
||||
[InlineData(86399, "23 hr ago")]
|
||||
[InlineData(86400, "1 day ago")]
|
||||
[InlineData(172800, "2 days ago")]
|
||||
public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
|
||||
VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||
|
||||
/// <remarks>
|
||||
/// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
|
||||
/// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
|
||||
/// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
|
||||
/// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
|
||||
/// level, which fired already.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.Session.ConnectionLog.CreateAsync(
|
||||
vault.Session.ActiveVaultId,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = host.Label,
|
||||
Address = host.Address,
|
||||
HostId = host.EntityId,
|
||||
StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
|
||||
DeviceName = "a workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => host.LastConnectedText.Length > 0,
|
||||
"activating the hosts screen should have read the log");
|
||||
|
||||
host.LastConnectedText.ShouldBe("3 days ago");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A host the log has never named. Empty rather than a dash or the word "never" — see
|
||||
/// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
|
||||
/// whose log simply has not been read yet look identical from this row, and neither is a claim it can
|
||||
/// make on its own.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.RefreshLastConnectedAsync(Token);
|
||||
|
||||
host.LastConnectedText.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Decision 4's second half: a host with a session open right now shows the green dot instead of an
|
||||
/// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
|
||||
/// and the older answer is the one likeliest to be misread as current.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
var host = vault.Hosts[0];
|
||||
|
||||
await vault.Session.ConnectionLog.CreateAsync(
|
||||
vault.Session.ActiveVaultId,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = host.Label,
|
||||
Address = host.Address,
|
||||
HostId = host.EntityId,
|
||||
StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
|
||||
DeviceName = "a workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
host.IsConnected.ShouldBeTrue();
|
||||
|
||||
await vault.RefreshLastConnectedAsync(Token);
|
||||
|
||||
host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
|
||||
}
|
||||
|
||||
// The other trigger — a session ending on its own — has no test here. It runs inside
|
||||
// MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
|
||||
// RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
|
||||
// window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
|
||||
// queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
|
||||
// nothing about the one line this wave added, since the untestable half is wiring this wave did not
|
||||
// write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
|
||||
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
||||
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||
@@ -2774,8 +2895,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await AddGroupAsync(vault, "platform");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
@@ -4267,17 +4387,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
|
||||
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
|
||||
/// machines through the editor is thirty rounds of open, pick, save.
|
||||
/// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
|
||||
/// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
|
||||
/// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
|
||||
/// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
|
||||
/// nothing sets it through a command — see that property's own remarks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
|
||||
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
|
||||
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
|
||||
/// read that property, and none of them should be aimed at a card that has left the screen. See
|
||||
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
|
||||
/// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
|
||||
/// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||
/// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -4290,13 +4409,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
var group = vault.Groups.Single().EntityId;
|
||||
var host = vault.Hosts.Single();
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
||||
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
||||
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
|
||||
|
||||
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
||||
// tree flattened, so the host is still in it.
|
||||
@@ -4310,22 +4427,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
||||
|
||||
// Opening the group is where it went, and the way to it.
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
// And back out again, which is what the host's own editor is for now that the drop has one target.
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
// And back out again.
|
||||
vault.GroupFilter = null;
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => choice.EntityId is null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
||||
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
||||
|
||||
// Coming out of a group is the direction that lands the host back on this level, so here the
|
||||
// selection does survive the move.
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -4355,11 +4472,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
||||
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
||||
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
vault.GroupFilter = null;
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
||||
}
|
||||
@@ -4392,8 +4509,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
||||
|
||||
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
||||
vault.OpenGroupCommand.Execute(
|
||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||
vault.GroupFilter =
|
||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
@@ -4427,8 +4544,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
||||
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
|
||||
/// is standing in, and the picker shows it before anything is saved.
|
||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group
|
||||
/// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
|
||||
/// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
|
||||
/// that property's own remarks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
||||
@@ -4438,7 +4557,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
vault.GroupFilter = vault.Groups.Single();
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
@@ -4457,31 +4576,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
|
||||
/// one is open would be a save nobody asked for, and one they could then not cancel.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
||||
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
|
||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||
vault.Status.ShouldContain("editing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
||||
@@ -4505,8 +4599,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||
|
||||
@@ -4549,8 +4642,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
@@ -4580,17 +4672,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "staging");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
|
||||
vault.DeletionTakesTheHostsToo = true;
|
||||
vault.CancelDeleteCommand.Execute(null);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
|
||||
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
|
||||
|
||||
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
||||
}
|
||||
@@ -4615,8 +4703,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorLabel = "half-typed";
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||
@@ -4676,8 +4763,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single();
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
||||
|
||||
@@ -4879,10 +4965,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorParentChoices
|
||||
.Select(choice => choice.Label)
|
||||
@@ -5355,24 +5439,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The desktop's drag, once more than one card is ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dragging one host onto a group card has always been <c>MoveHostToGroup</c>; a set dragged onto one has
|
||||
/// to file all of it, because moving whichever card the pointer happened to be holding and leaving the
|
||||
/// other five where they are is a gesture that quietly does a fraction of what it looks like it does. It
|
||||
/// is the picker's write with the picker skipped — see <c>ConfirmRegroupChosenHosts</c>, which shares it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The refusal is the one <c>RefusesTheDrop</c> makes for a single card, made once for the set: a drop is
|
||||
/// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
|
||||
/// and could not then cancel.
|
||||
/// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
|
||||
/// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
|
||||
/// covers what a set files to now that the group picker is the only route. What survives here is the
|
||||
/// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
|
||||
/// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
|
||||
/// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
|
||||
/// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
|
||||
/// way a card once was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
|
||||
public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -5385,25 +5465,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||
|
||||
var card = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
|
||||
vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
|
||||
vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
|
||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
|
||||
vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
|
||||
|
||||
var group = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
|
||||
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
|
||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
|
||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -5555,12 +5639,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Transfers.Status.ShouldContain("password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
|
||||
// ---- The terminal pin strip ----
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
||||
/// the connection already open and the tab it opened already selected.
|
||||
/// </summary>
|
||||
private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
|
||||
{
|
||||
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
|
||||
// selection bounces back to the host on purpose. The command has to work off the heading it was
|
||||
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = path;
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue();
|
||||
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
|
||||
// that happens to be selected on the hosts screen.
|
||||
shell.ShowsPinStrip.ShouldBeFalse();
|
||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
|
||||
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue();
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
|
||||
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
|
||||
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
||||
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
||||
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
||||
/// entry independent of Requests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClickingAPinChip_OpensFilesAtThatPath()
|
||||
{
|
||||
var vault = await ConnectedHostWithAPinAsync();
|
||||
|
||||
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||
|
||||
shell.IsTransfersShowing.ShouldBeTrue();
|
||||
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||
shell.Transfers.RemotePath.ShouldBe("/var/www/app");
|
||||
|
||||
ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
||||
{
|
||||
// Both heads' only route into a group editor since v5: neither draws a group card to select one
|
||||
// from any more, so the command has to work off the heading it was pressed on. Two groups exist here
|
||||
// so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
@@ -5569,23 +5760,19 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
|
||||
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
|
||||
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
|
||||
/// nothing on screen to say where it had gone, or how to get it back.
|
||||
/// The heading hands its group straight to the editor rather than selecting a card first, and this is
|
||||
/// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
|
||||
/// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
||||
@@ -5920,6 +6107,226 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
|
||||
}
|
||||
|
||||
// ---- Pinned paths (QUICK ACCESS) ----
|
||||
|
||||
[Fact]
|
||||
public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
|
||||
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
|
||||
{
|
||||
// Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
|
||||
// to preserve it as faithfully as the domain type it is about to become.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
|
||||
{
|
||||
vault.EditorNewPin = path;
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
}
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths
|
||||
.ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
vault.EditorNewPin = "/etc/nginx";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.RemoveEditorPinCommand.Execute("/var/www/app");
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingAHostEdit_DropsThePinningEntirely()
|
||||
{
|
||||
// Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
|
||||
// than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
|
||||
// Tag holds a tag's own name to.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewHostsEditor_OpensWithNoPinsStaged()
|
||||
{
|
||||
// NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
|
||||
// reason every other editor field is reset there.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ABlankPin_IsRefusedAtTheAddBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = " ";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain("blank");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
vault.EditorNewPin = "/var/www/app";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldHaveSingleItem();
|
||||
vault.Status.ShouldContain("already pinned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
|
||||
{
|
||||
// The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
|
||||
// that waits for SAVE throws away every other field typed on the form since, where one at the box
|
||||
// that caused it costs nothing else.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||
vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
|
||||
{
|
||||
vault.EditorNewPin = $"/pin/{i}";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
}
|
||||
|
||||
vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
|
||||
|
||||
vault.EditorNewPin = "/one/too/many";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
vault.EditorPinnedPaths.Count.ShouldBe(
|
||||
HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
|
||||
vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
|
||||
{
|
||||
@@ -6014,10 +6421,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
string? username = null,
|
||||
string? key = null)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorDefaultPort = port;
|
||||
vault.GroupEditorDefaultUsername = username ?? string.Empty;
|
||||
@@ -6033,10 +6438,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||
|
||||
@@ -1342,8 +1342,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
|
||||
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
|
||||
|
||||
vault.SelectedGroup = group;
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
vault.EditGroupCommand.Execute(group);
|
||||
|
||||
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||
|
||||
@@ -1484,10 +1483,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
production.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
|
||||
production.Group.ParentId.ShouldNotBeNull("it was nested, which is what has to stay behind");
|
||||
|
||||
// As the card's menu does before it runs the command; see HostsScreen.OnGroupContextRequested.
|
||||
vault.SelectedGroup = production;
|
||||
|
||||
vault.MoveGroupCommand.Execute(null);
|
||||
// Passed directly, the way MoveGroupFromHeading resolves and hands in a row now that there are no
|
||||
// group cards to select one from.
|
||||
vault.MoveGroupCommand.Execute(production);
|
||||
|
||||
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
|
||||
vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);
|
||||
@@ -1520,7 +1518,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
// The group it was nested under is the one thing that stayed, and it stayed where it was.
|
||||
Named(vault, "estate").VaultId.ShouldBe(production.VaultId);
|
||||
|
||||
vault.SelectedGroup?.EntityId.ShouldBe(moved.EntityId, "the buttons follow the group they moved");
|
||||
vault.Status.ShouldContain("Platform secrets");
|
||||
vault.Status.ShouldContain("top level");
|
||||
}
|
||||
@@ -1544,9 +1541,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.GroupEditorLabel = "production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
||||
|
||||
vault.MoveGroupCommand.Execute(null);
|
||||
vault.MoveGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
|
||||
|
||||
vault.IsMovingGroup.ShouldBeFalse();
|
||||
vault.MoveGroupVaultChoices.ShouldBeEmpty();
|
||||
@@ -1655,66 +1650,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dragging a host card onto a group card is the one gesture that files a host without opening its
|
||||
/// editor, and it can now be aimed across a vault boundary, because both grids draw every readable
|
||||
/// vault. The write it would make is the exact thing the host editor's group picker was fixed to
|
||||
/// prevent: an id only the other vault's holders can resolve.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing
|
||||
/// something, and unfiling it instead would be the wrong answer delivered silently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.GroupEditorLabel = "production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
// The host stays in the personal vault, which is where a new one goes without being told otherwise.
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
var group = vault.Groups.Single(row => row.VaultId == sharedVaultId);
|
||||
|
||||
host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId));
|
||||
|
||||
vault.Status.ShouldContain("its own vault");
|
||||
|
||||
vault.Hosts
|
||||
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
|
||||
.Host.GroupId
|
||||
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same rule as the drag above, arrived at from the other end: a group lives in exactly one vault, so
|
||||
/// + NEW HOST inside one has to open on that vault as well as on that group. The two defaults were
|
||||
/// decided separately — the group came from the screen, the vault from the standing "new items go to"
|
||||
/// preference — so pressing the button inside a shared vault's group opened a form bound for the personal
|
||||
/// vault, with the group silently dropped by the picker that keeps the two in step.
|
||||
/// A group lives in exactly one vault, so + NEW HOST inside one has to open on that vault as well as on
|
||||
/// that group. The two defaults were decided separately — the group from <see cref="VaultViewModel.GroupTarget"/>,
|
||||
/// the vault from the standing "new items go to" preference — so pressing the button inside a shared
|
||||
/// vault's group opened a form bound for the personal vault, with the group silently dropped by the
|
||||
/// picker that keeps the two in step. <c>GroupFilter</c> is written directly here rather than through
|
||||
/// <c>OpenGroupCommand</c>, which left with the group cards it used to open — see that property's own
|
||||
/// remarks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The second half is the same test read backwards, and it is what says which of the two wins. The
|
||||
@@ -1752,7 +1694,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.GroupEditorLabel = "production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.OpenGroupCommand.Execute(Named(vault, "production"));
|
||||
vault.GroupFilter = Named(vault, "production");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
@@ -1773,7 +1715,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
// And backwards: the preference names the shared vault and the open group is in the personal one.
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.OpenGroupCommand.Execute(Named(vault, "staging"));
|
||||
vault.GroupFilter = Named(vault, "staging");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ internal static class HostFactory
|
||||
Guid? credentialId = null,
|
||||
bool? asksForPassword = null,
|
||||
Guid? groupId = null,
|
||||
Guid[]? tags = null) =>
|
||||
Guid[]? tags = null,
|
||||
string[]? pinnedPaths = null) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
@@ -57,6 +58,7 @@ internal static class HostFactory
|
||||
AsksForPassword = asksForPassword,
|
||||
GroupId = groupId,
|
||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -17,9 +17,9 @@ public sealed class HostSecretCodecTests
|
||||
/// <remarks>
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group and a pair of tags —
|
||||
/// which are orthogonal to the binding and are what make this host reach the highest schema version a
|
||||
/// valid host can.
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
|
||||
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
|
||||
/// highest schema version a valid host can.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
@@ -37,7 +37,8 @@ public sealed class HostSecretCodecTests
|
||||
relayEnabled: true,
|
||||
credentialId: credentialId,
|
||||
groupId: Production,
|
||||
tags: [Pci, EuWest]);
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"]);
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
@@ -219,6 +220,90 @@ public sealed class HostSecretCodecTests
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostPinningPaths_IsWrittenAtTheVersionThatIntroducedThem()
|
||||
{
|
||||
HostSecretCodec
|
||||
.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: ["/var/www/app"])), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
|
||||
document.Host.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostWithNoPinnedPaths_IsWrittenAtTheVersionItWouldHaveHadWithout()
|
||||
{
|
||||
// Pinning nothing is not using the feature, for the same reason an empty tag set is not: bumping
|
||||
// the version for it would have made every host in every vault read-only on every machine that had
|
||||
// not upgraded yet.
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: [])), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATaggedHostThatAlsoPinsPaths_IsWrittenAtTheHigherOfTheTwoVersions()
|
||||
{
|
||||
// Independent fields, so the version is a maximum over both rather than whichever this switch
|
||||
// happened to check last — the same defect the group-and-binding case guards against above.
|
||||
HostSecretCodec
|
||||
.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci], pinnedPaths: ["/var/www/app"])), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostPinningPaths_WritesThemLastAndInListOrder()
|
||||
{
|
||||
// Last, so every field that existed before them keeps its bytes; in list order rather than sorted,
|
||||
// unlike tags — order is the meaning of a pinned-path list, so canonicalising it away would lose
|
||||
// exactly what the feature is for.
|
||||
var bytes = HostSecretCodec.Encode(
|
||||
Host(username: null, notes: null, pinnedPaths: ["/var/www/app", "/var/log"]));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"pinnedPaths":["/var/www/app","/var/log"]}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayloadWithoutPinnedPaths_DecodesAsPinningNothing()
|
||||
{
|
||||
// The compatibility case this field exists to pass: a payload written before pinned paths existed
|
||||
// must read cleanly as a host that pins nothing, not fail to decode.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.PinnedPaths.ShouldBe(PinnedPathList.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayloadRepeatingAPinnedPath_DecodesKeepingTheFirstOccurrence()
|
||||
{
|
||||
// Written by some other client, and it must not compare unequal to the same list written once —
|
||||
// or the engine would push this host as changed on every pass for ever.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/www/app","/var/log","/var/www/app"]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.PinnedPaths
|
||||
.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class HostSecretMergeTests
|
||||
SshKeyId = DeployKey,
|
||||
GroupId = Production,
|
||||
TagIds = TagSet.Create([Pci]),
|
||||
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
@@ -160,6 +161,102 @@ public sealed class HostSecretMergeTests
|
||||
|
||||
private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : [];
|
||||
|
||||
[Fact]
|
||||
public void TwoPeoplePinningDifferentPathsToOneHost_BothKeepTheirs()
|
||||
{
|
||||
// The same reason TagIds merges per tag rather than as a whole value: a whole-value merge would
|
||||
// take one side's list entire and drop the other's.
|
||||
var ancestor = Host();
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app"]) },
|
||||
ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) });
|
||||
|
||||
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PinnedPathAdditions_ComeOutInBaseOrderThenLocalThenRemote()
|
||||
{
|
||||
// The order guarantee this merge exists to keep: unlike a tag chip, a pinned path's position is
|
||||
// part of what the user set, so the merge must produce a deterministic order rather than whatever
|
||||
// a set union happens to yield.
|
||||
var ancestor = Host(pinnedPaths: ["/base/one", "/base/two"]);
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with
|
||||
{
|
||||
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/local/added"]),
|
||||
},
|
||||
ancestor with
|
||||
{
|
||||
PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/remote/added"]),
|
||||
});
|
||||
|
||||
result.Merged.PinnedPaths.ShouldBe(
|
||||
PinnedPathList.Create(["/base/one", "/base/two", "/local/added", "/remote/added"]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneSideRemovingAPinnedPathWhileTheOtherAddsAnother_KeepsBothDecisions()
|
||||
{
|
||||
// Each path is resolved on its own, so a removal on one side and an addition on the other are two
|
||||
// independent answers rather than two versions of one. A whole-value merge would have to pick.
|
||||
var ancestor = Host(pinnedPaths: ["/var/www/app"]);
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PinnedPaths = PinnedPathList.Empty },
|
||||
ancestor with
|
||||
{
|
||||
PinnedPaths = PinnedPathList.Create(["/var/www/app", "/var/log"]),
|
||||
});
|
||||
|
||||
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathRemovedOnBothSides_IsNotResurrected()
|
||||
{
|
||||
var ancestor = Host(pinnedPaths: ["/var/www/app", "/var/log"]);
|
||||
var withoutApp = ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, withoutApp, withoutApp);
|
||||
|
||||
result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="NoArrangementOfOneTag_ProducesAConflict" />
|
||||
[Theory]
|
||||
[InlineData(true, true, true)]
|
||||
[InlineData(true, true, false)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(true, false, false)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(false, true, false)]
|
||||
[InlineData(false, false, true)]
|
||||
[InlineData(false, false, false)]
|
||||
public void NoArrangementOfOnePinnedPath_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
|
||||
{
|
||||
var result = HostSecretMerge.Merge(
|
||||
Host(pinnedPaths: Pinning(inAncestor)),
|
||||
Host(pinnedPaths: Pinning(inLocal)),
|
||||
Host(pinnedPaths: Pinning(inRemote)));
|
||||
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
|
||||
result.Merged.PinnedPaths.Contains("/var/www/app")
|
||||
.ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
|
||||
}
|
||||
|
||||
private static string[] Pinning(bool pinned) => pinned ? ["/var/www/app"] : [];
|
||||
|
||||
[Fact]
|
||||
public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
|
||||
{
|
||||
|
||||
@@ -189,6 +189,76 @@ public sealed class ValueSemanticsTests
|
||||
map[EuWest].ShouldBe(EuWest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathList_ComparesByContentsAndOrder()
|
||||
{
|
||||
// The half that differs from a tag set and matches a jump chain: a pinned-path list is what a
|
||||
// shortcut menu draws top to bottom, so reordering it is a change, not a no-op.
|
||||
PinnedPathList.Create(["/var/log", "/var/www/app"])
|
||||
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
|
||||
.ShouldBeTrue();
|
||||
|
||||
(PinnedPathList.Create(["/var/log", "/var/www/app"])
|
||||
== PinnedPathList.Create(["/var/log", "/var/www/app"])).ShouldBeTrue();
|
||||
|
||||
PinnedPathList.Create(["/var/log", "/var/www/app"])
|
||||
.Equals(PinnedPathList.Create(["/var/www/app", "/var/log"]))
|
||||
.ShouldBeFalse();
|
||||
|
||||
PinnedPathList.Create(["/var/log"])
|
||||
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
|
||||
.ShouldBeFalse();
|
||||
|
||||
PinnedPathList.Create([]).Equals(PinnedPathList.Empty).ShouldBeTrue();
|
||||
PinnedPathList.Create(["/var/log"]).Equals(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathList_HashesByContentsAndOrder()
|
||||
{
|
||||
PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode()
|
||||
.ShouldBe(PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathList_ComparesPathsOrdinally()
|
||||
{
|
||||
// A remote path is meaningful only to the far end, and that end may run a case-sensitive
|
||||
// filesystem — so folding case here would be a decision this client has no business making.
|
||||
PinnedPathList.Create(["/var/log"]).Equals(PinnedPathList.Create(["/VAR/LOG"])).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathList_CollapsesARepeatedPathKeepingTheFirstOccurrence()
|
||||
{
|
||||
// A repeat arrives from a payload some other client wrote, or from the same path pinned twice on
|
||||
// one machine. Left alone it would compare unequal to the same list written once, and the engine
|
||||
// would push the host as changed on every pass for ever.
|
||||
PinnedPathList.Create(["/var/log", "/var/log"]).Equals(PinnedPathList.Create(["/var/log"]))
|
||||
.ShouldBeTrue();
|
||||
|
||||
PinnedPathList.Create(["/var/log", "/var/log"]).Count.ShouldBe(1);
|
||||
|
||||
// The first occurrence survives rather than the last, so a later repeat cannot silently move an
|
||||
// earlier entry to a new position in the list.
|
||||
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"])[0].ShouldBe("/var/log");
|
||||
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"]).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APinnedPathList_MapsToItsOwnPathsInListOrder()
|
||||
{
|
||||
// The value repeats the key on purpose, exactly as TagSet.ToIdMap's does: no key can then hold two
|
||||
// different values, so the only disagreement a keyed merge can report is one side adding what the
|
||||
// other removed. Unlike TagSet, the map is built in list order rather than a canonical one, which
|
||||
// is what lets the merge reproduce "base order, then additions".
|
||||
var map = PinnedPathList.Create(["/var/log", "/var/www/app"]).ToPathMap();
|
||||
|
||||
map.Keys.ShouldBe(new[] { "/var/log", "/var/www/app" });
|
||||
map["/var/log"].ShouldBe("/var/log");
|
||||
map["/var/www/app"].ShouldBe("/var/www/app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_RejectsWhatCannotBeStored()
|
||||
{
|
||||
@@ -200,11 +270,39 @@ public sealed class ValueSemanticsTests
|
||||
Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
|
||||
Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
|
||||
Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
|
||||
Host(pinnedPaths: [" "]).TryValidate(out _).ShouldBeFalse();
|
||||
Host(pinnedPaths: ["/has\0nul"]).TryValidate(out _).ShouldBeFalse();
|
||||
|
||||
// Null is "take the group's port", not an absent one, and it has to be storable — it is the whole
|
||||
// of what inheritance stores.
|
||||
Host(port: null).TryValidate(out _).ShouldBeTrue();
|
||||
Host().TryValidate(out _).ShouldBeTrue();
|
||||
|
||||
// A relative path is exactly as valid as an absolute one: it resolves against the account's home,
|
||||
// which this client never needs to know.
|
||||
Host(pinnedPaths: ["relative/within/home"]).TryValidate(out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_BoundsThePinnedPaths()
|
||||
{
|
||||
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength)])
|
||||
.TryValidate(out _).ShouldBeTrue();
|
||||
|
||||
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength + 1)])
|
||||
.TryValidate(out var tooLong).ShouldBeFalse();
|
||||
tooLong.ShouldNotBeNull().ShouldContain("longer than");
|
||||
|
||||
var atLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths)
|
||||
.Select(i => $"/path/{i}")
|
||||
.ToArray();
|
||||
Host(pinnedPaths: atLimit).TryValidate(out _).ShouldBeTrue();
|
||||
|
||||
var overLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths + 1)
|
||||
.Select(i => $"/path/{i}")
|
||||
.ToArray();
|
||||
Host(pinnedPaths: overLimit).TryValidate(out var tooMany).ShouldBeFalse();
|
||||
tooMany.ShouldNotBeNull().ShouldContain("cannot pin more than");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user