Make the host pane something you ask for, and draw it as cards

THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is
selected", so touching any card took 304 pixels off the grid — including every
card arrowed past on the way to the one somebody wanted. Choosing among forty
machines was charged the price of the pane for one of them. A pencil now appears
on the card under the pointer and on the selected card, and that is what opens
it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so
the pane is reachable without a pointer, which a hover-only control is not.

Once open it follows the selection rather than pinning the host it was opened
on: a pane about one host beside a grid marking a different one is two answers
to the same question. Losing the selection closes it and clears the flag, or a
filter matching nothing would leave the pane armed to spring open again on the
next card merely selected — which is the behaviour the pencil exists to remove.

The pencil is drawn over the card rather than in a column of its own. A column
would have cost the name 30 pixels of a 232-pixel tile, permanently, for a
control that is only there while the pointer is; the dot and the pencil stack in
the two corners of that edge instead. IsVisible and not opacity, because a
button at zero opacity still takes the click and the card underneath does not.

A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change
in the pane. The header names what the drawer is about and carries the two
things true of every panel; the footer carries the one thing each panel is for —
CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so
the button somebody came here to press can no longer be below the fold, which
CONNECT could be on a host with fifteen tags. That also widens what the layout
harness certifies: it skips anything inside a ScrollViewer, and the control each
panel exists to offer is now outside one.

THE SAME THREE CARDS TWICE. 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: the design draws every fact as a filled box, and
rather than draw an input that refuses the pointer, pressing one leads to the
same card with a real box in it. Nothing here saves as you type, and that is not
timidity — saving validates the key-or-credential exclusion and writes one
encrypted payload, so a box committing per keystroke would be a save per
character and a half-typed hostname on the wire.

Every value the pane prints is the resolved one, and says "inherited" beside it
where a group supplied it. The number is the same either way and the edit is
not: clearing a group's default moves every host that never overrode it.

A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci,
eu-west-1" — the transport, the resolved account, then every tag — replacing
both the user@host:port line and the wrapped row of tag chips under it. The
address went to the card's tooltip rather than nowhere: a card is read while
scanning forty machines, where the name and the kind of machine are what is
being looked for, and an address is what you read once you have found it.

"ssh" is a constant today and is printed anyway, which is the one thing here
that argues with this codebase's own rule about constants dressed as readings.
It is the first item of a list whose other items vary, and a list beginning with
the account on one card and a tag on the next has no shape to scan. The remark
on HostRowViewModel.Summary says so rather than leaving it to be discovered.

WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet,
"SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's
chevron and Show more. Sharing is per vault and not per item, every session here
is an SSH channel, there are no identity or certificate item types, nothing
carries a terminal setting to the renderer, and an item cannot be moved between
vaults at all. Six controls with nothing behind them, listed in
docs/design-import-gaps.md with what ships instead, and none drawn disabled.

The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the
keychain. U+26BF is outside both faces this application substitutes for the
design's fonts, so it lands on whatever the platform's fallback has; every other
glyph in the pane is from Geometric Shapes, which both carry.
This commit is contained in:
2026-08-03 16:26:43 +02:00
parent fd8497bb76
commit e3dfe5c371
7 changed files with 1219 additions and 382 deletions
+37
View File
@@ -116,6 +116,43 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Serial** beside **Terminal** in the toolbar | Omitted. Every session here is an SSH channel; a serial transport is not a button, it is a second session kind. | > | **Serial** beside **Terminal** in the toolbar | Omitted. Every session here is an SSH channel; a serial transport is not a button, it is a second session kind. |
> | The strip's tabs inside the titlebar row, with a hamburger | A row of its own under the titlebar. The titlebar already carries the drag region, the search box, the sync light and three window controls, and the strip scrolls — putting both on one 44-pixel row would make the thing that scrolls fight the thing that drags. | > | The strip's tabs inside the titlebar row, with a hamburger | A row of its own under the titlebar. The titlebar already carries the drag region, the search box, the sync light and three window controls, and the strip scrolls — putting both on one 44-pixel row would make the thing that scrolls fight the thing that drags. |
> | A count on the S3 tab | Omitted. The rail entry it replaces carried one; a number on two of five tabs reads as a fact about those two rather than as the tab's own state, and a terminal tab has nothing to count. The count is on the S3 screen. | > | A count on the S3 tab | Omitted. The rail entry it replaces carried one; a number on two of five tabs reads as a fact about those two rather than as the tab's own state, and a terminal tab has nothing to count. The count is on the S3 screen. |
>
> ## The desktop's v4 — the host detail pane
>
> A fourth pass, narrower than v3: one pane and the cards beside it. It changes the *shape* of the drawer
> rather than what is in it, and it changes one behaviour that is not cosmetic at all.
>
> **The drawer is asked for now.** Selecting a host used to open it, which charged 304 pixels of grid for
> the act of choosing — including every card arrowed past on the way to the one you wanted. A pencil appears
> on a card under the pointer, and on the selected card, and that is what opens the pane;
> `VaultViewModel.IsHostPaneOpen` is the flag, and the context menu has a **Details…** entry so the pane is
> reachable without a pointer. Once open the pane follows the selection, because a pane about one host
> beside a grid marking a different one is two answers to the same question.
>
> **The pane is three cards, a fixed header and a fixed footer.** Address, General, Connection — the same
> three in the detail pane and in the host editor, the first as rows and the second as boxes. Only the
> middle scrolls, so the button each panel exists to offer is never below the fold, which CONNECT could be
> on a host with fifteen tags. The detail pane's rows are *buttons that open the editor*: the design draws
> every fact as a filled box, and rather than draw an input that refuses the pointer, pressing one leads to
> the same card with a real box in it. Saving is a whole-host operation — it validates the key-or-credential
> exclusion and writes one encrypted payload — so a box committing per keystroke was never on offer.
>
> **A host card is two lines and no chips.** The subtitle is `ssh, root, pci, eu-west-1`: the transport, the
> resolved account, then every tag. It replaces the `user@host:port` line *and* the wrapped row of tag chips
> below it. The address moved to the card's tooltip rather than nowhere — a card is read while scanning
> forty machines, where the name and the kind of machine are what is being looked for, and an address is
> what you read once you have found it.
>
> | v4 element | What ships instead |
> | --- | --- |
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
> | The **chevron beside the vault name** | The name alone. An item cannot be moved between vaults: the two are encrypted under different keys, so moving one is a delete and a retype. Where a *new* item is filed is chosen on the keychain screen, which is the only vault question with an answer. |
> | **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. |
Most of it landed. This file is the rest: every element of that design with nothing behind it, which 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 project each piece would have to land in, and **what the shipped interface does instead**. That last
+149
View File
@@ -625,6 +625,155 @@
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<!--
◆ THE PENCIL ON A HOST CARD, which is the only way the drawer opens from the grid.
It exists because selecting a host stopped opening it. A card that took 304 pixels off the grid the
moment it was touched charged the price of the pane for the act of choosing — most of which is arrowing
past machines you did not want — so opening is now a thing you ask for and this is the asking. See
VaultViewModel.IsHostPaneOpen.
Hidden until the pointer is on the card, and shown on the selected one whether or not the pointer is
there, because the selected card is the one whose pane you are most likely to want and a control that
vanished from under the card you just clicked would have to be hunted for. Both are declared after the
base rule: Avalonia has no specificity and settles two matching rules by declaration order, which this
file records three times over.
IsVisible rather than Opacity. A button at zero opacity is still a button — it takes the click, and the
card underneath does not — so half the grid would swallow presses aimed at selecting a host. The space
is held open by the Panel around it in the item template rather than by this, so the card does not
reflow as the pointer crosses it; see HostsScreen.axaml.
-->
<Style Selector="Button.rowedit">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="Button.rowedit /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style Selector="Button.rowedit:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="ListBoxItem:pointerover Button.rowedit">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="ListBoxItem:selected Button.rowedit">
<Setter Property="IsVisible" Value="True" />
</Style>
<!--
◆ THE DRAWER'S FURNITURE: a card, its heading, and the rows inside it.
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.
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.
-->
<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="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
captions on a card, so they are sentence case at the body size.
-->
<Style Selector="TextBlock.sectionhead">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
◆ A ROW IN THE DETAIL PANE, AND IT IS A BUTTON THAT LOOKS LIKE A FIELD.
The design draws every fact about a host as a filled box, which is the shape of an input — so a reader
arrives expecting to type in one. They cannot: saving here is a whole-host operation that validates the
key-or-credential exclusion and writes one encrypted payload, so a box that committed on every
keystroke would be a save per character and a half-typed hostname on the wire.
Rather than draw an input that refuses the pointer, each row *is* the door to the editor. Pressing any
of them opens the host editor, which is the same three cards with real boxes in them — so the thing
that looked editable turns out to be editable, one step further along, and nothing on screen is a
control that does nothing. The alternative was a flat read-only pane with one EDIT button, which is
what this replaced.
It borrows the TextBox's own geometry rather than sharing it, for the reason Button.chiptoggle borrows
Border.chip's: a Button is not a TextBox and Avalonia's selectors are structural. The numbers are
repeated and the two have to move together, which is why they are adjacent in this file.
-->
<Style Selector="Button.fieldrow">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="8,5" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.fieldrow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.fieldrow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
</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
says is to its right.
-->
<Style Selector="TextBlock.fieldglyph">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Width" Value="16" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
are flat and quiet until the pointer finds them.
-->
<Style Selector="Button.paneicon">
<Setter Property="Width" Value="26" />
<Setter Property="Height" Value="26" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.paneicon /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style Selector="Button.paneicon:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!-- <!--
The status dot, in one place rather than as a converter in code. The status dot, in one place rather than as a converter in code.
+538 -275
View File
@@ -18,329 +18,592 @@
Renamed with the job rather than kept as HostSidebar, unlike NavRail, which kept its name when it only Renamed with the job rather than kept as HostSidebar, unlike NavRail, which kept its name when it only
changed width. This one changed what it holds, which side it is on, and whether it is there at all. changed width. This one changed what it holds, which side it is on, and whether it is there at all.
── A HEADER, A BODY THAT SCROLLS, AND A FOOTER. ─────────────────────────────────────────────────────
Three rows rather than one column, which is the shape the v4 design draws and the one change here that
is structural rather than cosmetic. The header names what the drawer is about and carries the two
controls that are true of every panel — the overflow menu and the arrow that puts the drawer away. The
footer carries the one thing each panel is for: CONNECT, or SAVE, or the question about deleting. Only
the middle scrolls, so the button somebody came here to press is never below the fold — which it was,
on a host with fifteen tags.
── EXACTLY ONE OF THE THREE PANELS IS SHOWING ─────────────────────────────────────────────────────── ── EXACTLY ONE OF THE THREE PANELS IS SHOWING ───────────────────────────────────────────────────────
Detail, host editor, group editor. They are exclusive by construction rather than by three flags that Detail, host editor, group editor. They are exclusive by construction rather than by three flags that
could disagree: IsShowingHostDetail is defined as "neither editor is open and something is selected", could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
so no two of these can be true at once. The whole control collapses when none of them is — see 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 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. 300-pixel column beside a grid is 300 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.
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
screen hands it over. screen hands it over.
Nothing in here may be laid over the terminal's rectangle: it is a column of the hosts screen, and the Nothing in here may be laid over the terminal's rectangle: it is a column of the hosts screen, and the
hosts screen is a sibling of the WebView. See MainWindow.axaml's occlusion rule. hosts screen is a sibling of the WebView. See MainWindow.axaml's occlusion rule.
── WHAT THE DESIGN DRAWS HERE AND THIS PANE HAS NOT GOT ─────────────────────────────────────────────
Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row and the vault
picker's chevron. Five controls with nothing behind them: sharing is per vault and not per item, every
session here is an SSH channel, there are no identity or certificate item types, nothing carries a
terminal setting to the renderer, and an item cannot be moved between vaults at all. They are listed in
docs/design-import-gaps.md with what ships instead, and none of them is drawn disabled.
--> -->
<Border Width="304" Background="{StaticResource Sidebar}" <Border Width="304" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"> BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<!-- <Grid RowDefinitions="Auto,*,Auto">
◆ IT SCROLLS AS A WHOLE, and the host editor no longer carries a MaxHeight of its own.
The old column gave the editor 300 pixels and let the list above have the rest, so the editor had to <!-- ============ THE HEADER ============ -->
be bounded separately. Here the drawer is the only thing in its column, so one ScrollViewer over all <!--
three panels is both simpler and more honest: whichever panel is up gets the whole height, and the One row for all three panels, which is why what it says is on the view model rather than repeated
one that overflows is the one that scrolls. three times here. See VaultViewModel.DrawerTitle.
The cost is the one the old note recorded and it has not changed: the layout harness skips anything The subtitle is the keychain this host is filed in, and the design's chevron beside it is not drawn:
with a ScrollViewer in its ancestry — see LayoutHarness.IsScrollable — so from here on it certifies an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
that this pane fits the column rather than that every field inside it does. That is the true claim is a delete and a retype — and a picker offering the move would be offering something no layer below
about a pane that scrolls, and the tag picker is why it has to scroll: its height is a chip per tag this can do.
in the keychain, wrapped, so no fixed height holds it for somebody with fifteen. -->
--> <Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
<ScrollViewer HorizontalScrollBarVisibility="Disabled"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Panel> <Grid ColumnDefinitions="*,Auto,Auto">
<!-- ============ WHAT THIS HOST IS ============ --> <StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
<StackPanel Margin="16" Spacing="12" IsVisible="{Binding IsShowingHostDetail}"> <TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
<Ellipse Classes="dot" Classes.live="{Binding SelectedHost.IsConnected}" Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" /> IsVisible="{Binding DrawerSubtitle,
<TextBlock Text="{Binding SelectedHost.Label}" FontSize="16" FontWeight="SemiBold" Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding SelectedHost.Address}" FontSize="12" <!--
The host's own two actions, behind a menu rather than as a row of buttons under the pane. They
are what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and
the other two are things you go looking for. It hides with the deletion question for the reason
the buttons did — see VaultViewModel.ShowsHostPaneActions.
-->
<Button Grid.Column="1" Classes="flat paneicon" Content="⋯"
IsVisible="{Binding ShowsHostPaneActions}"
ToolTip.Tip="More things to do with this host">
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</MenuFlyout>
</Button.Flyout>
</Button>
<Button Grid.Column="2" Classes="flat paneicon" Content="⇥"
Command="{Binding CloseDrawerCommand}"
ToolTip.Tip="Closes this pane and gives the grid the width back. The host stays selected." />
</Grid>
</Border>
<!--
◆ THE BODY SCROLLS AND THE ROWS ABOVE AND BELOW IT DO NOT.
The layout harness skips anything with a ScrollViewer in its ancestry — see
LayoutHarness.IsScrollable — so from here on it certifies that the header, the footer and the
column itself fit, rather than that every field inside the cards does. That is the true claim about
a pane that scrolls, and it is a better one than before: what the harness now measures includes the
button each panel exists to offer, which is exactly the control that used to be able to fall off the
bottom. The tag picker is why the middle has to scroll at all — its height is a chip per tag in the
keychain, wrapped, so no fixed height holds it for somebody with fifteen.
-->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled">
<Panel>
<!-- ============ WHAT THIS HOST IS ============ -->
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
<!--
============ ADDRESS ============
The hostname alone, beside the same mark the host's card in the grid carries. The account and
the port are in the connection card below rather than crammed into one user@host:port string:
this is the pane with room to separate them, and the two are edited in different boxes.
-->
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionhead" Text="Address" />
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Button Grid.Column="1" Classes="fieldrow" 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"
TextTrimming="CharacterEllipsis" />
</Button>
</Grid>
</StackPanel>
</Border>
<!--
============ GENERAL ============
The name, where it is filed, what it wears, and whatever was written about it. Notes are the
one row that is not always there: an empty box labelled nothing is a row that says a host has
no notes, which is not a fact anybody came here for.
-->
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<TextBlock Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
</Button>
<!--
The group, with the same mark its card carries in the grid. A host in none says so rather
than showing an empty box, for the reason the notes row is absent: blank and "none" look
identical and only one of them is an answer.
-->
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="fieldglyph" Text="▤" />
<TextBlock Grid.Column="1" Text="{Binding SelectedHost.GroupLabel}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding SelectedHost.HasGroup}" />
<TextBlock Grid.Column="1" Text="No group"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !SelectedHost.HasGroup}" />
</Grid>
</Button>
<!--
The tags it wears, as the same chips the card draws — repeated rather than shared with the
card's template because the two are different shapes and a shared template would have to
be told which.
-->
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="fieldglyph" Text="#" VerticalAlignment="Top"
Margin="0,3,0,0" />
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.TagLabels}"
IsVisible="{Binding SelectedHost.HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="6,1" Margin="0,0,4,2">
<TextBlock Text="{Binding}" FontSize="9.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Grid.Column="1" Text="No tags" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !SelectedHost.HasTags}" />
</Grid>
</Button>
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}"
IsVisible="{Binding SelectedHost.Host.Notes,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="fieldglyph" Text="✎" VerticalAlignment="Top"
Margin="0,2,0,0" />
<TextBlock Grid.Column="1" Text="{Binding SelectedHost.Host.Notes}" FontSize="12"
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" /> Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
</Grid>
</Button>
<!-- </StackPanel>
Which of the three ways this host authenticates, and where it came from. The note rather than </Border>
the one-word Authentication the card shows: a host that inherits its group's key is the case
where the word alone is misleading, and there is room for the sentence here.
-->
<TextBlock Classes="hint" FontSize="12" Text="{Binding SelectedHostAuthenticationNote}" />
<!-- <!--
The tags it wears, as the same chips the card draws. Repeated rather than shared with the card's ============ CONNECTION ============
template because the two are different shapes — the card wraps them under a two-line summary and What this host dials and what it authenticates with — the two things CONNECT in the footer is
this is a column 304 wide — and a shared template would have to be told which. about, which is why they share a card with the box that some hosts need filled in.
-->
<ItemsControl ItemsSource="{Binding SelectedHost.TagLabels}"
IsVisible="{Binding SelectedHost.HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="6,1" Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="9.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<SelectableTextBlock Text="{Binding SelectedHost.Host.Notes}" FontSize="12" Every value here is the *resolved* one, so a host that states nothing of its own shows what it
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" would actually use rather than a blank. Where that came from a group the row says "inherited"
IsVisible="{Binding SelectedHost.Host.Notes, beside it: the number is the same either way and the edit is not, because clearing a group's
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> default moves every host that never overrode it.
-->
<Border Classes="section">
<StackPanel Spacing="6">
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,2" /> <Grid ColumnDefinitions="Auto,72,*">
<TextBlock Grid.Column="0" Classes="sectionhead" Text="SSH on"
VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="fieldrow" Margin="8,0"
Command="{Binding EditSelectedHostCommand}">
<TextBlock Classes="mono" Text="{Binding SelectedHostPortLabel}" FontSize="12" />
</Button>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
VerticalAlignment="Center">
<TextBlock Text="port" Foreground="{StaticResource TextDim}" />
<TextBlock Text="inherited" FontSize="11" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding SelectedHostPortIsInherited}"
ToolTip.Tip="This host states no port of its own and takes its group's." />
</StackPanel>
</Grid>
<!-- <Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,4" />
Connecting, and the box a host that wants a typed password needs. A sentence in the box's place
when it does not, because "nothing needs typing" and "something needs typing and the box has not
appeared" look identical and only one of them is fine.
It is here rather than in a bar across the top of the screen, which is where it used to be: the <TextBlock Classes="sectionhead" Text="Credentials" Margin="0,0,0,2" />
password belongs to the host, and a box at the top of a grid of forty machines is one whose
subject you have to work out. That move is also what this arrangement is for — the bar had one
row and had to fit the box, the tick, the note and CONNECT along it; a column has room to put
the tick under the box it qualifies, which is where it reads as a property of the password
rather than as a fourth control in a row.
REMEMBER travels with the box and hides with it. It is the two-step chore the box's tooltip used <Grid ColumnDefinitions="*,Auto">
to describe — add a password under Keychain, then bind the host to it — done from the one place <Button Grid.Column="0" Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
that already has the password, and it takes effect only once the remote has accepted it. <Grid ColumnDefinitions="Auto,*">
--> <TextBlock Grid.Column="0" Classes="fieldglyph" Text="@" />
<TextBox Text="{Binding ConnectPassword}" PlaceholderText="password" <TextBlock Grid.Column="1" Text="{Binding SelectedHostUsernameLabel}"
PasswordChar="•" HorizontalAlignment="Stretch" TextTrimming="CharacterEllipsis" />
IsVisible="{Binding SelectedHostAsksForAPassword}" </Grid>
ToolTip.Tip="Typed each time unless REMEMBER is ticked, in which case it is saved to your keychain and bound to this host once the connection succeeds." /> </Button>
<CheckBox IsChecked="{Binding RemembersConnectPassword}" <TextBlock Grid.Column="1" Text="inherited" FontSize="11" Margin="6,0,0,0"
IsVisible="{Binding SelectedHostAsksForAPassword}" VerticalAlignment="Center" Foreground="{StaticResource TextFaint}"
ToolTip.Tip="Saves this password to your keychain, bound to this host, so it is not asked for again. It syncs to your other machines, and only happens if the connection works."> IsVisible="{Binding SelectedHostUsernameIsInherited}"
<TextBlock Text="Remember this password" Classes="hint" FontSize="12" /> ToolTip.Tip="This host states no account of its own and takes its group's." />
</CheckBox> </Grid>
<!--
The box a host that wants a typed password needs, and it is a real one — the only input in
this pane, because what it holds is not part of the host and is never saved with it.
It is here rather than in a bar across the top of the screen, which is where it used to be:
the password belongs to the host, and a box at the top of a grid of forty machines is one
whose subject you have to work out. A column also has room to put REMEMBER under the box it
qualifies, which is where it reads as a property of the password rather than as a fourth
control in a row.
REMEMBER travels with the box and hides with it. It is the two-step chore the box's tooltip
used to describe — add a password under Keychain, then bind the host to it — done from the
one place that already has the password, and it takes effect only once the remote has
accepted it.
-->
<TextBox Text="{Binding ConnectPassword}" PlaceholderText="password"
PasswordChar="•" HorizontalAlignment="Stretch"
IsVisible="{Binding SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time unless REMEMBER is ticked, in which case it is saved to your keychain and bound to this host once the connection succeeds." />
<CheckBox IsChecked="{Binding RemembersConnectPassword}"
IsVisible="{Binding SelectedHostAsksForAPassword}"
ToolTip.Tip="Saves this password to your keychain, bound to this host, so it is not asked for again. It syncs to your other machines, and only happens if the connection works.">
<TextBlock Text="Remember this password" Classes="hint" FontSize="12" />
</CheckBox>
<!--
What it authenticates with instead, named. The row is the item's own label and the sentence
under it is which of the three ways this is and where the binding came from — the note
rather than the one word the card shows, because a host that inherits its group's key is
the case where the word alone is misleading and there is room for the sentence here.
-->
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}"
IsVisible="{Binding !SelectedHostAsksForAPassword}">
<Grid ColumnDefinitions="Auto,*">
<!--
◆ rather than the ⚿ the nav rail marks the keychain with, and that is a rendering
decision rather than a semantic one: this application substitutes the design's fonts
for Inter and the system monospace stack — see MonoFont in Palette.axaml — and U+26BF
is outside what either supplies, so it lands on whatever the platform's fallback has.
Every other glyph in this pane is from Geometric Shapes, which both faces carry.
-->
<TextBlock Grid.Column="0" Classes="fieldglyph" Text="◆" />
<TextBlock Grid.Column="1" Text="{Binding SelectedHostBindingLabel}"
TextTrimming="CharacterEllipsis" />
</Grid>
</Button>
<TextBlock Classes="hint" FontSize="11" Text="{Binding SelectedHostAuthenticationNote}"
IsVisible="{Binding SelectedHostAuthenticationNote,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</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." />
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding ShowsHostActions}">
<Button Classes="accent" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteHostCommand}" />
</StackPanel> </StackPanel>
<!-- ============ THE HOST EDITOR ============ -->
<!-- <!--
Swapped for the buttons rather than stacked under them, as it always was, so DELETE cannot be The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
pressed again while its own question is on screen. See VaultViewModel.ShowsHostActions. above, with the boxes real — which is what makes pressing a row up there lead somewhere that
looks like where it was pressed.
--> -->
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionhead" Text="Address" />
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBox Grid.Column="1" Margin="10,0,0,0" Text="{Binding EditorHostname}"
PlaceholderText="hostname or address" />
</Grid>
</StackPanel>
</Border>
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Which group this host is filed under. Inside the encrypted payload like everything else
here, so the server learns nothing about how the estate is organised — and a group the
vault no longer has keeps a placeholder entry, so that editing the port cannot quietly
unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
The tags this host wears. Chips that toggle rather than a multi-select list, because a chip
is what a tag looks like on the card in the grid — a list of names to tick would make the
user match an entry to a chip they can already see.
The box under them creates one and puts it on straight away. That is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. Unlike every other field
here it writes to the keychain immediately, because a host can only name a tag that has an
id — so cancelling this editor leaves the tag behind, which is honest rather than hidden.
Renaming and deleting are on the keychain screen, where every other item kind is managed.
-->
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}"
Margin="0,2,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<!--
Worn is filled, unworn is outlined. One control per tag with two states rather than a
checkbox beside a label: the state and the name occupy the same object, so a row of
them reads as the host's tags rather than as a form about them.
-->
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,4,4"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="10.5" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD"
Command="{Binding AddEditorTagCommand}" />
</Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" 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.
-->
<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>
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its
credentials. Part of the host rather than of the connection, so it follows the host to
every machine; a host bound to something since deleted keeps a placeholder entry here, so
that editing the port cannot quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both"
impossible to express rather than merely invalid. The qualifier beside each label is not
decoration: a key called "deploy" and the deploy account's password are the ordinary case,
and bare labels would offer two identical-looking entries that authenticate completely
differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
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.
-->
<TextBlock Classes="hint" FontSize="11"
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host
key is refused outright with no way to continue past it — so a legitimately rebuilt server
needs somewhere deliberate to be re-approved from, and that somewhere must not be the
warning itself. It takes effect when clicked rather than on Save, and the status line says
so; it is not a field of the host.
-->
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
Margin="0,4,0,0"
Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
</Border>
</StackPanel>
<!-- ============ THE GROUP EDITOR ============ -->
<!--
Here rather than on the Keychain screen, because a group is not a secret — it is how this
screen's grid is arranged, and the arranging belongs beside the thing arranged. Filing a host
into one is done in the host's own editor above, for the same reason its key and its password
are.
One form for both adding and renaming; the header says which of the two is about to happen, and
so does the footer's button. The four fields under the name are what the hosts inside inherit
when they say nothing themselves; every one of them may be left empty, and empty means "lend
nothing" rather than "unset". The parent picker leaves out this group and everything beneath it,
so a cycle cannot be made here — which is a courtesy rather than the guarantee, because one
assembled offline on two machines was never offered this list. See HostInheritance.
-->
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditingGroup}">
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="Group" Margin="0,0,0,2" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A heading for the grid, and the defaults every host under it inherits. Which group a host is in is part of the host, and stays encrypted." />
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="What its hosts inherit" Margin="0,0,0,2" />
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding GroupEditorDefaultPort}" Minimum="1"
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
PlaceholderText="default port" />
<TextBox Grid.Column="2" Text="{Binding GroupEditorDefaultUsername}"
PlaceholderText="default username" />
</Grid>
<ComboBox ItemsSource="{Binding GroupEditorAuthenticationChoices}"
SelectedItem="{Binding GroupEditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</Panel>
</ScrollViewer>
<!-- ============ THE FOOTER ============ -->
<!--
One row, and exactly one of its four contents is showing — the same by-construction exclusivity the
panels above have, from the same flags. It is what each panel is for: connecting, saving a host,
saving a group, or answering the question about deleting one.
◆ THE QUESTION TAKES CONNECT'S PLACE rather than stacking under it, as it always did with the row of
buttons this footer replaced, so that DELETE cannot be pressed again while its own question is on
screen. See VaultViewModel.ShowsHostPaneActions.
-->
<Border Grid.Row="2" Padding="12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
<Panel>
<Button Classes="accent" Content="Connect" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" Padding="10,7" FontSize="12.5"
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding ShowsHostPaneActions}" />
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6" <Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingHostDeletion}"> IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard /> <views:ConfirmDeleteCard />
</Border> </Border>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
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." />
</StackPanel>
<!-- ============ THE HOST EDITOR ============ -->
<!--
The editor doubles as the "add" form; there is no separate dialog.
-->
<StackPanel Margin="16" Spacing="6" IsVisible="{Binding IsEditing}">
<TextBlock Classes="label" Text="HOST" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<!--
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 below 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>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" />
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
Part of the host rather than of the connection, so it follows the host to every machine; a host
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both" impossible to
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
identical-looking entries that authenticate completely differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Which group this host is filed under. Inside the encrypted payload like everything else here, so
the server learns nothing about how the estate is organised — and a group the vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
The tags this host wears. Chips that toggle rather than a multi-select list, because a chip is
what a tag looks like on the card in the grid — a list of names to tick would make the user
match an entry to a chip they can already see.
The box under them creates one and puts it on straight away. That is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. Unlike every other field here it
writes to the keychain immediately, because a host can only name a tag that has an id — so
cancelling this editor leaves the tag behind, which is honest rather than hidden. Renaming and
deleting are on the keychain screen, where every other item kind is managed.
-->
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}"
Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<!--
Worn is filled, unworn is outlined. One control per tag with two states rather than a
checkbox beside a label: the state and the name occupy the same object, so a row of them
reads as the host's tags rather than as a form about them.
-->
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,4,4"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="10.5" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD" Command="{Binding AddEditorTagCommand}" />
</Grid>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
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.
-->
<TextBlock Classes="hint" FontSize="11"
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" /> <Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" /> <Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
</StackPanel> </StackPanel>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
-->
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetHostKeyCommand}"
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
<!-- ============ THE GROUP EDITOR ============ --> <StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
<!--
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
grid is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is
done in the host's own editor above, for the same reason its key and its password are.
One form for both adding and renaming; GroupSaveLabel is what says which of the two is about to
happen. The four fields under the name are what the hosts inside inherit when they say nothing
themselves; every one of them may be left empty, and empty means "lend nothing" rather than
"unset". The parent picker leaves out this group and everything beneath it, so a cycle cannot be
made here — which is a courtesy rather than the guarantee, because one assembled offline on two
machines was never offered this list. See HostInheritance.
-->
<StackPanel Margin="16" Spacing="6" IsVisible="{Binding IsEditingGroup}">
<TextBlock Classes="label" Text="GROUP" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A heading for the grid, and the defaults every host under it inherits. Which group a host is in is part of the host, and stays encrypted." />
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding GroupEditorDefaultPort}" Minimum="1"
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
PlaceholderText="default port" />
<TextBox Grid.Column="2" Text="{Binding GroupEditorDefaultUsername}"
PlaceholderText="default username" />
</Grid>
<ComboBox ItemsSource="{Binding GroupEditorAuthenticationChoices}"
SelectedItem="{Binding GroupEditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="{Binding GroupSaveLabel}" <Button Classes="accent" Content="{Binding GroupSaveLabel}"
Command="{Binding SaveGroupCommand}" /> Command="{Binding SaveGroupCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGroupEditCommand}" /> <Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGroupEditCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </Panel>
</Border>
</Panel> </Grid>
</ScrollViewer>
</Border> </Border>
</UserControl> </UserControl>
+144 -98
View File
@@ -16,7 +16,12 @@
Cards split the two jobs. The grid is about all of them — how many groups there are, which machines are Cards split the two jobs. The grid is about all of them — how many groups there are, which machines are
in one, which have a shell open — and it gets the full width to say so. The drawer is about one, and it in one, which have a shell open — and it gets the full width to say so. The drawer is about one, and it
is only there when there is one. See HostDrawer and VaultViewModel.IsDrawerOpen. is only there when it has been asked for. See HostDrawer and VaultViewModel.IsDrawerOpen.
◆ ASKED FOR, AND IT USED TO ARRIVE WITH THE SELECTION. Touching any card opened the drawer, so choosing
a host cost 304 pixels of grid every time — including for every card passed over on the way to the one
somebody wanted. The pencil on a card is what opens it now; selecting is free again. See the item
template below and VaultViewModel.IsHostPaneOpen.
── WHAT IS DELIBERATELY NOT HERE ──────────────────────────────────────────────────────────────────── ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────────
The design this was drawn from puts a view-mode switch, a tag filter, a calendar and a share control in The design this was drawn from puts a view-mode switch, a tag filter, a calendar and a share control in
@@ -315,9 +320,17 @@
and cancels the menu outright over the space around the cards, where there is no host to act and cancels the menu outright over the space around the cards, where there is no host to act
on. on.
--> -->
<!--
Details is here as well as on the pencil, and that is not a duplicate for its own sake: the
pencil appears on hover, so it is unreachable without a pointer, and this menu is what a
keyboard and the context key can get to. It passes no parameter — the code-behind has
already selected whatever was right-clicked, which is exactly what OpenHostPane falls back
to.
-->
<ListBox.ContextMenu> <ListBox.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" /> <MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Details…" Command="{Binding OpenHostPaneCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" /> <MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator /> <Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" /> <MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
@@ -327,116 +340,148 @@
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel"> <DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="tile"> <!--
<StackPanel Spacing="6"> The address is on the tooltip and nowhere else on the card. It used to be the subtitle;
<Grid ColumnDefinitions="Auto,*,Auto"> see the note on the summary line below for what took its place and why it is not lost.
-->
<Border Classes="tile" ToolTip.Tip="{Binding Address}">
<!--
A Panel so the pencil below can be drawn *over* the card rather than in a column of
its own. A column would have cost the name 30 pixels of a 232-pixel tile — permanently,
for a control that is only there while the pointer is — and the name is the thing the
grid exists to be scanned by.
-->
<Panel>
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}"> <Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold" <TextBlock Classes="mono" Text="&gt;_" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}" Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" /> HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" <StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center"
Spacing="2"> Spacing="2">
<!-- <!--
◆ A GRID AND NOT A HORIZONTAL StackPanel, on both of these rows, and the two ◆ A GRID AND NOT A HORIZONTAL StackPanel, on both of these rows, and the two
look interchangeable until the text is too long for the card. look interchangeable until the text is too long for the card.
A horizontal StackPanel measures every child with infinite width, so a A horizontal StackPanel measures every child with infinite width, so a
TextBlock inside one never learns it is short of room and TextTrimming never TextBlock inside one never learns it is short of room and TextTrimming never
fires — the text simply runs on past the card's border and over the card beside fires — the text simply runs on past the card's border and over the card beside
it. The card is a fixed 248 and a host name is not, so that is the ordinary it. The card is a fixed 248 and a host name is not, so that is the ordinary
case rather than the edge one. case rather than the edge one.
The star column is the part that gives way and the Auto column is the part that The star column is the part that gives way and the Auto column is the part that
must not: a badge saying "not synced" and the word that says how a host must not: a badge saying "not synced" and the word that says how a host
authenticates are both short and both meaningless trimmed, where a hostname authenticates are both short and both meaningless trimmed, where a hostname
with its tail cut is still the machine you were looking for. with its tail cut is still the machine you were looking for.
--> -->
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium" <TextBlock Grid.Column="0" Text="{Binding Label}" FontWeight="Medium"
Foreground="{StaticResource Text}" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<!--
◆ ONE COMMA-SEPARATED LINE: the transport, the account, and every tag this host
wears. It replaces two things — the address-and-authentication row that was here,
and the row of tag chips that used to sit under the whole card.
What went is the address, and it went to the tooltip above rather than nowhere.
A card is read while scanning forty machines, and at that moment the name and
what kind of machine it is are what is being looked for; an address is what you
read once you have found it, which is what the drawer is for. The word saying
how it authenticates went with it, for a smaller reason: it was there to warn
that a password box would be needed, and the box is in the pane beside that
warning now.
See HostRowViewModel.Summary, and the remark there on why "ssh" is printed at
all when every host in this product is one.
-->
<TextBlock Text="{Binding Summary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0" <!--
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> Which vault this host is in, and only when there is more than one to be in. It
<TextBlock Text="{Binding Badge}" FontSize="9.5" /> decides who else can see the host and where an edit goes back to, so on a grid
</Border> spanning several vaults it is not decoration.
</Grid> -->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!-- <!--
The address and how it authenticates, on one line. Which of the three ways is Connected, and nothing more. Green means a terminal is open on this host right
here because only one of them wants a password typed, and an empty password box now; grey means there is not one. It is deliberately not reachability — nothing
on a key-authenticated host is otherwise indistinguishable from one somebody here pings anything, and a dot that meant "up" would be a claim this application
forgot to fill in. never checks.
--> -->
<Grid ColumnDefinitions="*,Auto"> <Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}"
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" VerticalAlignment="Top" Margin="6,3,0,0" />
FontSize="10.5" Foreground="{StaticResource TextFaint}" </Grid>
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Authentication}"
FontSize="10.5" Margin="6,0,0,0"
Foreground="{StaticResource TextFaint}" />
</Grid>
<!--
Which vault this host is in, and only when there is more than one to be in. It
decides who else can see the host and where an edit goes back to, so on a grid
spanning several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!-- <!--
Connected, and nothing more. Green means a terminal is open on this host right The group this host is filed under, and only when it is filed under one — which is
now; grey means there is not one. It is deliberately not reachability — nothing the same rule the group cards above follow, and the reason a keychain nobody has
here pings anything, and a dot that meant "up" would be a claim this application filed anything in looks exactly as it did before groups existed.
never checks.
This is what the fold-away heading over a run of cards used to say. A chip on the
card says it per host instead, which is the shape the rest of this card already is
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
needs no row of its own, and it is what changes when a card is dropped onto a group.
Accent rather than plain, so the one chip that is a *place* is not read as another
tag. A group whose name the vault cannot resolve leaves this empty rather than
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel.
--> -->
<Ellipse Grid.Column="2" Classes="dot" Classes.live="{Binding IsConnected}" <Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
VerticalAlignment="Top" Margin="6,3,0,0" /> IsVisible="{Binding HasGroup}">
</Grid> <TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
TextTrimming="CharacterEllipsis" />
</Border>
<!-- <!--
The group this host is filed under, and only when it is filed under one — which is A wrapped row of tag chips was here. The tags are in the summary line above now,
the same rule the group cards above follow, and the reason a keychain nobody has after the account, which is what took the card from three rows and a wrap down to
filed anything in looks exactly as it did before groups existed. two: a chip and a comma-separated name say the same thing, and only one of them
needs a row of its own. Chips are still what a tag looks like where it is *chosen* —
the editor's picker in the drawer, and the phone's own list. See
HostRowViewModel.Summary.
-->
</StackPanel>
This is what the fold-away heading over a run of cards used to say. A chip on the <!--
card says it per host instead, which is the shape the rest of this card already is ◆ THE PENCIL, AND THE ONLY WAY THE DRAWER OPENS FROM THIS GRID.
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
needs no row of its own, and it is what changes when a card is dropped onto a group.
Accent rather than plain, so the one chip that is a *place* is not read as another Selecting a card no longer opens it. That used to be automatic, which meant the grid
tag. A group whose name the vault cannot resolve leaves this empty rather than gave up 304 pixels the moment anybody touched a card — including every card arrowed
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel. past on the way to the one they wanted. Opening the pane is a thing you ask for now,
--> and this is where the asking is. See VaultViewModel.IsHostPaneOpen.
<Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
IsVisible="{Binding HasGroup}">
<TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
TextTrimming="CharacterEllipsis" />
</Border>
<!-- Bottom right, under the status dot rather than beside it, and over the card rather
The tags this host wears. Under the summary rather than beside it, because a host than in a column: the two marks a card can carry on that edge are "a shell is open
can wear several and the line above is three fixed-width facts where these are not. here" and "open this one's pane", and stacking them is what keeps both out of the
name's width. It appears on hover and on the selected card; see Button.rowedit in
App.axaml for why that is IsVisible rather than an opacity.
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See The row is the command's parameter rather than being left to the selection. A button
HostRowViewModel.TagLabels. inside a ListBoxItem takes the press itself, and whether the item is also selected by
--> it is the theme's business — so a command reading SelectedHost would open the pane on
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}"> whichever card happened to be lit, which on the first click of a session is none of
<ItemsControl.ItemsPanel> them.
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate> -->
</ItemsControl.ItemsPanel> <Button Classes="flat rowedit" Content="✎"
<ItemsControl.ItemTemplate> HorizontalAlignment="Right" VerticalAlignment="Bottom"
<DataTemplate x:DataType="x:String"> Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).OpenHostPaneCommand}"
<Border Classes="chip" Padding="5,0" Margin="0,0,4,2"> CommandParameter="{Binding}"
<TextBlock Text="{Binding}" FontSize="9.5" /> ToolTip.Tip="Opens this host's pane beside the grid" />
</Border> </Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -461,8 +506,9 @@
</Grid> </Grid>
<!-- <!--
The drawer, and it takes its 304 pixels only while there is something in it — which is what gives the The drawer, and it takes its 304 pixels only while it has been opened — which is what gives the grid
grid the full width for most of the time anybody is looking at it. the full width for most of the time anybody is looking at it, now including all the time somebody is
choosing between forty machines.
Wrapped so that its own IsVisible is the shell's binding and its data context is the vault; see Wrapped so that its own IsVisible is the shell's binding and its data context is the vault; see
MainWindow.axaml's note on why the two cannot be put on one element. MainWindow.axaml's note on why the two cannot be put on one element.
@@ -304,6 +304,47 @@ internal sealed partial class HostRowViewModel(
private string DisplayUsername => private string DisplayUsername =>
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value; string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
/// <summary>
/// The one line under the name on a card: the transport, the account, and every tag, comma-separated.
/// </summary>
/// <remarks>
/// <para>
/// <b>The address is deliberately not in it, and it used to be the whole line.</b> A card carrying
/// <c>root@10.0.4.12:22</c> and a second row of tag chips is three facts and a wrap in a 232-pixel tile,
/// and the two that a person scanning forty machines actually reads are the name and what kind of
/// machine it is. The address is on the card's tooltip and in the drawer, which is where somebody
/// checking an address is looking anyway. See <c>HostsScreen.axaml</c>.
/// </para>
/// <para>
/// <b>"ssh" is a constant today and is printed anyway</b>, which is the one thing on this row worth
/// arguing about — this codebase omits constants dressed up as readings, and by that rule the word
/// should not be here. It is here because it is the first item of a list whose other items vary, and a
/// list that begins with the account on one card and with a tag on the next has no shape to scan. It
/// becomes a real fact the day a second transport exists; until then it is a label, not a reading.
/// </para>
/// <para>
/// The account is the <em>resolved</em> one, so a host taking its group's user says that user rather
/// than nothing, and a host nobody has given one to is one item shorter rather than saying "—". Tags
/// come last because there can be any number of them and the two before them are at most one each.
/// </para>
/// </remarks>
internal string Summary => string.Join(", ", SummaryParts());
private IEnumerable<string> SummaryParts()
{
yield return "ssh";
if (!string.IsNullOrEmpty(resolved.Username.Value))
{
yield return resolved.Username.Value;
}
foreach (var tag in tagLabels)
{
yield return tag;
}
}
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges; internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
internal bool IsBlocked => host.IsBlocked; internal bool IsBlocked => host.IsBlocked;
@@ -1515,6 +1556,9 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
private bool isEditing; private bool isEditing;
/// <summary> /// <summary>
@@ -1544,6 +1588,9 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
private bool isEditingGroup; private bool isEditingGroup;
/// <summary> /// <summary>
@@ -1567,10 +1614,83 @@ internal sealed partial class VaultViewModel(
/// somebody moved the grid. /// somebody moved the grid.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null; internal bool IsDrawerOpen =>
IsEditing || IsEditingGroup || (IsHostPaneOpen && SelectedHost is not null);
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary> /// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null; internal bool IsShowingHostDetail =>
!IsEditing && !IsEditingGroup && IsHostPaneOpen && SelectedHost is not null;
/// <summary>
/// Whether the pane about one host has been asked for.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A selection no longer opens the drawer, and this flag is the difference.</b> It used to:
/// <see cref="IsDrawerOpen"/> read <c>SelectedHost is not null</c>, so touching any card took 304 pixels
/// off the grid — which is the cost of choosing, paid every time somebody arrows through a list to find
/// the machine they want. Selecting is now free, and the pane is opened by the pencil on the card, by
/// the context menu, or by either editor being raised.
/// </para>
/// <para>
/// It <em>follows</em> the selection once it is open rather than pinning the host it was opened on. A
/// pane that kept showing the previous machine while a different card was lit would be two answers to
/// "which host is this about" on one screen; the rule is that opening is deliberate and tracking is not.
/// </para>
/// <para>
/// Cleared when the selection goes, in <see cref="OnSelectedHostChanged"/>. Without that a filter that
/// matched nothing would leave this true, and the pane would spring open again on the next card
/// somebody merely selected — which is the behaviour this exists to remove.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isHostPaneOpen;
/// <summary>
/// Whether the detail pane's own actions are showing: CONNECT, and the menu holding EDIT and DELETE.
/// </summary>
/// <remarks>
/// The detail pane and nothing else. With an editor open the menu would offer to open the editor, and
/// while the deletion question is up it would offer to ask it again — which is the rule
/// <see cref="ShowsHostActions"/> has always carried for the row of buttons these two replaced. The
/// question takes CONNECT's place in the footer for the same reason it took DELETE's.
/// </remarks>
internal bool ShowsHostPaneActions => IsShowingHostDetail && !IsConfirmingHostDeletion;
/// <summary>
/// What the drawer's header says it is about.
/// </summary>
/// <remarks>
/// On the view model rather than as three exclusive headings in the markup, because the header is one
/// row that outlives the panel under it: it carries the close button and the overflow menu, and three
/// copies of that row would be three places to fix the day one of them moves.
/// </remarks>
internal string DrawerTitle => (IsEditing, IsEditingGroup) switch
{
(true, _) => editingEntityId is null ? "New host" : "Host details",
(_, true) => EditingGroupId is null ? "New group" : "Group details",
_ => "Host details",
};
/// <summary>
/// The line under it: which keychain this is filed in, or what a group is for.
/// </summary>
/// <remarks>
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
/// under different keys — so a control offering the move would be offering something no layer below
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
/// <see cref="TargetVaults"/>.
/// </remarks>
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
{
(_, true) => "A heading, and what its hosts inherit",
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
_ => SelectedHost?.VaultName ?? string.Empty,
};
/// <summary> /// <summary>
/// Whether the add sheet is showing over the host list. /// Whether the add sheet is showing over the host list.
@@ -2223,6 +2343,63 @@ internal sealed partial class VaultViewModel(
_ => string.Empty, _ => string.Empty,
}; };
/// <summary>
/// The port the drawer prints, which is the one this host would dial.
/// </summary>
/// <remarks>
/// Resolved rather than stored, like everything else the pane draws: a host that states no port of its
/// own and sits under a group on 2222 shows 2222 here, because the question the pane answers is what
/// happens when CONNECT is pressed. The editor shows the same number as a <em>placeholder</em> behind an
/// empty box, which is the same fact said the other way round — see <see cref="EditorPortPlaceholder"/>.
/// </remarks>
internal string SelectedHostPortLabel =>
SelectedHost?.Resolved.Port.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
/// <summary>Whether that port came from a group rather than from the host.</summary>
/// <remarks>
/// Drawn as a word beside the value rather than folded into it. "2222" and "2222, inherited" are the
/// same connection and different edits: clearing the group's default moves the first host and the
/// second, and only somebody who knows which is which can predict that.
/// </remarks>
internal bool SelectedHostPortIsInherited => SelectedHost?.Resolved.Port.IsInherited ?? false;
/// <inheritdoc cref="SelectedHostPortLabel" />
/// <remarks>
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
/// value that had not loaded.
/// </remarks>
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
? user
: "no account set";
/// <inheritdoc cref="SelectedHostPortIsInherited" />
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
/// <summary>
/// What the pane names in the credentials row: the key or password this host authenticates with.
/// </summary>
/// <remarks>
/// <para>
/// The item's own label, resolved here rather than carried on the row, because the answer changes when
/// somebody renames a key on the keychain screen and the host row is not rebuilt for that.
/// </para>
/// <para>
/// A binding whose target the vault no longer holds says so instead of printing an id — the same rule
/// <see cref="AuthenticationChoice.Missing"/> follows in the editor's picker, and for the same reason:
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
/// </para>
/// </remarks>
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
{
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
?? "(a password that is no longer here)",
_ => string.Empty,
};
/// <remarks> /// <remarks>
/// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to /// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
/// search. A group that has since been deleted leaves the binding dangling, which the connect path /// search. A group that has since been deleted leaves the binding dangling, which the connect path
@@ -3469,6 +3646,69 @@ internal sealed partial class VaultViewModel(
[RelayCommand] [RelayCommand]
private void CloseAddSheet() => IsAddSheetOpen = false; private void CloseAddSheet() => IsAddSheetOpen = false;
/// <summary>
/// Opens the pane about one host, on the card the pencil was pressed on.
/// </summary>
/// <param name="row">
/// The card, or null to open on whatever is already selected — which is what the context menu passes,
/// since the code-behind has already selected the card the pointer was over.
/// </param>
/// <remarks>
/// <para>
/// The pencil takes the row as a parameter rather than relying on the click having selected the card
/// first. A button inside a <c>ListBoxItem</c> handles the press itself, and whether the item is also
/// selected by it is the theme's business rather than this application's — so a command reading
/// <see cref="SelectedHost"/> would be opening the pane on whichever card happened to be lit, which on
/// the first click of a session is none of them.
/// </para>
/// <para>
/// It selects as well as opening, because the two have to agree: the pane is about one host and the grid
/// marks one host, and a pane opened on a card the grid has not lit is the disagreement
/// <see cref="IsHostPaneOpen"/> exists to prevent in the other direction.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenHostPane(HostRowViewModel? row)
{
if (row is not null)
{
SelectedHost = row;
}
if (SelectedHost is null)
{
return;
}
IsHostPaneOpen = true;
}
/// <summary>
/// Puts the drawer away, whichever of the three panels is in it.
/// </summary>
/// <remarks>
/// One button for all three, because what it means is "give the grid its 304 pixels back" rather than
/// "cancel". An open editor is abandoned by it — the same thing its own CANCEL does, and the same thing
/// the arrow has to mean, since a header button that refused while a form was open would be a control
/// that is sometimes furniture and sometimes a decision. The selection survives: the card stays lit and
/// the pencil on it opens the pane again.
/// </remarks>
[RelayCommand]
private void CloseDrawer()
{
if (IsEditing)
{
CancelEditCommand.Execute(null);
}
if (IsEditingGroup)
{
CancelGroupEditCommand.Execute(null);
}
IsHostPaneOpen = false;
}
/// <summary>Starts a new host.</summary> /// <summary>Starts a new host.</summary>
[RelayCommand] [RelayCommand]
private void NewHost() private void NewHost()
@@ -3551,6 +3791,12 @@ internal sealed partial class VaultViewModel(
grouped: row.Host.GroupId is not null); grouped: row.Host.GroupId is not null);
IsEditing = true; IsEditing = true;
// So that saving lands on this host's own pane rather than closing the drawer. The editor is reached
// from that pane most of the time and the flag is already true; it is not when EDIT was chosen from
// the grid's context menu, and coming back to a collapsed column after a save reads as the edit
// having been thrown away. See IsHostPaneOpen.
IsHostPaneOpen = true;
Status = $"Editing {row.Label}."; Status = $"Editing {row.Label}.";
} }
@@ -6507,9 +6753,27 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAuthenticationNote)); OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
OnPropertyChanged(nameof(ShowsConnectBar)); OnPropertyChanged(nameof(ShowsConnectBar));
// The drawer opens on a selection and closes when there is none, so both of these move with it. // Every field the drawer's detail pane draws. They are properties of the vault rather than of the
// row because two of them need the group chain read and one needs the keychain searched, and none of
// that can be done from inside an item template.
OnPropertyChanged(nameof(SelectedHostPortLabel));
OnPropertyChanged(nameof(SelectedHostPortIsInherited));
OnPropertyChanged(nameof(SelectedHostUsernameLabel));
OnPropertyChanged(nameof(SelectedHostUsernameIsInherited));
OnPropertyChanged(nameof(SelectedHostBindingLabel));
OnPropertyChanged(nameof(DrawerSubtitle));
// A selection no longer opens the drawer, but losing one still closes it — and takes the flag with
// it, so that the pane does not spring back open on the next card somebody merely selects. See
// IsHostPaneOpen.
if (value is null)
{
IsHostPaneOpen = false;
}
OnPropertyChanged(nameof(IsDrawerOpen)); OnPropertyChanged(nameof(IsDrawerOpen));
OnPropertyChanged(nameof(IsShowingHostDetail)); OnPropertyChanged(nameof(IsShowingHostDetail));
OnPropertyChanged(nameof(ShowsHostPaneActions));
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to // Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each // one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
@@ -6576,11 +6840,18 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsConfirmingHostDeletion)); OnPropertyChanged(nameof(IsConfirmingHostDeletion));
OnPropertyChanged(nameof(IsConfirmingGroupDeletion)); OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
OnPropertyChanged(nameof(ShowsHostActions)); OnPropertyChanged(nameof(ShowsHostActions));
OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(ShowsGroupActions)); OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(ShowsItemActions)); OnPropertyChanged(nameof(ShowsItemActions));
} }
partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel)); partial void OnEditingGroupIdChanged(Guid? value)
{
OnPropertyChanged(nameof(GroupSaveLabel));
// Which of the two things the group editor is doing, which its header says as well as its button.
OnPropertyChanged(nameof(DrawerTitle));
}
/// <summary> /// <summary>
/// Takes the question away when the selection it was asked about has moved on. /// Takes the question away when the selection it was asked about has moved on.
@@ -236,6 +236,70 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off"); vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
} }
/// <summary>
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
/// </summary>
/// <remarks>
/// <para>
/// The two halves are one rule and are asserted together, because either alone would pass on a broken
/// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
/// second. What is being held is that opening is <em>deliberate</em>.
/// </para>
/// <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.
/// </para>
/// </remarks>
[Fact]
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
{
await OnTheGridAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
vault.SelectedHost = host;
vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");
// The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
// point: what a headless run can reach is the control and the command behind it.
var pencil = CardFor(screen, host)
.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("rowedit"));
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
pencil.Command.Execute(pencil.CommandParameter);
vault.IsDrawerOpen.ShouldBeTrue();
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
});
}
/// <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.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheGridAsync((_, _) =>
{
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
vault.IsDrawerOpen.ShouldBeTrue();
vault.SelectedHost = null;
vault.IsDrawerOpen.ShouldBeFalse();
vault.SelectedHost = Row(vault, "stage-web");
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
});
}
// ---- Helpers ---- // ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary> /// <summary>The same in-process format the screen's own drag carries.</summary>
@@ -143,10 +143,17 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on // and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
// the right. // the right.
/// <remarks>
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
/// when the pencil arrived: a selected host no longer puts the pane up, so a test that only selected one
/// would measure a drawer with all three panels collapsed and pass on an empty column. See
/// <c>VaultViewModel.IsHostPaneOpen</c>.
/// </remarks>
[Fact] [Fact]
public async Task TheHostDrawerFitsShowingAHost() public async Task TheHostDrawerFitsShowingAHost()
{ {
vault.SelectedHost = vault.Hosts[0]; vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsShowingHostDetail.ShouldBeTrue("there is nothing to measure otherwise");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
} }
@@ -206,8 +213,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact] [Fact]
public async Task TheHostDrawerFitsWithADeletionInQuestion() public async Task TheHostDrawerFitsWithADeletionInQuestion()
{ {
vault.SelectedHost = vault.Hosts[0]; vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.SelectedHost.IsConnected = true; vault.SelectedHost.ShouldNotBeNull().IsConnected = true;
vault.DeleteHostCommand.Execute(null); vault.DeleteHostCommand.Execute(null);
vault.IsConfirmingDeletion.ShouldBeTrue(); vault.IsConfirmingDeletion.ShouldBeTrue();
@@ -373,7 +380,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact] [Fact]
public async Task TheHostsScreenFitsWithTheDrawerOpen() public async Task TheHostsScreenFitsWithTheDrawerOpen()
{ {
vault.SelectedHost = vault.Hosts[0]; vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue(); vault.IsDrawerOpen.ShouldBeTrue();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out")); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
@@ -399,7 +406,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact] [Fact]
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen() public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
{ {
vault.SelectedHost = vault.Hosts[0]; vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away"); vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await OnTheHostsScreenAsync((screen, window) => await OnTheHostsScreenAsync((screen, window) =>