Public Access
Merge branch 'claude/host-detail-pane-design-e98621'
This commit is contained in:
@@ -117,6 +117,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. |
|
||||
> | 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. |
|
||||
>
|
||||
> ## 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
|
||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||
|
||||
@@ -625,6 +625,155 @@
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</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.
|
||||
|
||||
|
||||
@@ -18,102 +18,272 @@
|
||||
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.
|
||||
|
||||
── 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 ───────────────────────────────────────────────────────
|
||||
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",
|
||||
so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
|
||||
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
||||
300-pixel column beside a grid is 300 pixels the grid could have had.
|
||||
|
||||
◆ 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
|
||||
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
|
||||
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}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ THE HEADER ============ -->
|
||||
<!--
|
||||
◆ IT SCROLLS AS A WHOLE, and the host editor no longer carries a MaxHeight of its own.
|
||||
One row for all three panels, which is why what it says is on the view model rather than repeated
|
||||
three times here. See VaultViewModel.DrawerTitle.
|
||||
|
||||
The old column gave the editor 300 pixels and let the list above have the rest, so the editor had to
|
||||
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 that overflows is the one that scrolls.
|
||||
|
||||
The cost is the one the old note recorded and it has not changed: the layout harness skips anything
|
||||
with a ScrollViewer in its ancestry — see LayoutHarness.IsScrollable — so from here on it certifies
|
||||
that this pane fits the column rather than that every field inside it does. That is the true claim
|
||||
about a pane that scrolls, and the tag picker is why it has to scroll: its height is a chip per tag
|
||||
in the keychain, wrapped, so no fixed height holds it for somebody with fifteen.
|
||||
The subtitle is the keychain this host is filed in, and the design's chevron beside it is not drawn:
|
||||
an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
|
||||
is a delete and a retype — and a picker offering the move would be offering something no layer below
|
||||
this can do.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding DrawerSubtitle,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
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="16" Spacing="12" IsVisible="{Binding IsShowingHostDetail}">
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding SelectedHost.IsConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding SelectedHost.Label}" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
<!--
|
||||
============ 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=">_" 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>
|
||||
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedHost.Address}" FontSize="12"
|
||||
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Which of the three ways this host authenticates, and where it came from. The note rather than
|
||||
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.
|
||||
============ 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.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="12" Text="{Binding SelectedHostAuthenticationNote}" />
|
||||
<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 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 — the card wraps them under a two-line summary and
|
||||
this is a column 304 wide — and a shared template would have to be told which.
|
||||
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.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding SelectedHost.TagLabels}"
|
||||
<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,4">
|
||||
<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>
|
||||
|
||||
<SelectableTextBlock Text="{Binding SelectedHost.Host.Notes}" FontSize="12"
|
||||
Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}"
|
||||
IsVisible="{Binding SelectedHost.Host.Notes,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
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" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,2" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
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.
|
||||
============ CONNECTION ============
|
||||
What this host dials and what it authenticates with — the two things CONNECT in the footer is
|
||||
about, which is why they share a card with the box that some hosts need filled in.
|
||||
|
||||
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. 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.
|
||||
Every value here is the *resolved* one, so a host that states nothing of its own shows what it
|
||||
would actually use rather than a blank. Where that came from a group the row says "inherited"
|
||||
beside it: the number is the same either way and the edit is not, because clearing a group's
|
||||
default moves every host that never overrode it.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
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.
|
||||
<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" />
|
||||
|
||||
<TextBlock Classes="sectionhead" Text="Credentials" Margin="0,0,0,2" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Button Grid.Column="0" Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="fieldglyph" Text="@" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding SelectedHostUsernameLabel}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Text="inherited" FontSize="11" Margin="6,0,0,0"
|
||||
VerticalAlignment="Center" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding SelectedHostUsernameIsInherited}"
|
||||
ToolTip.Tip="This host states no account of its own and takes its group's." />
|
||||
</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"
|
||||
@@ -125,20 +295,33 @@
|
||||
<TextBlock Text="Remember this password" Classes="hint" FontSize="12" />
|
||||
</CheckBox>
|
||||
|
||||
<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>
|
||||
|
||||
<!--
|
||||
Swapped for the buttons rather than stacked under them, as it always was, so DELETE cannot be
|
||||
pressed again while its own question is on screen. See VaultViewModel.ShowsHostActions.
|
||||
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.
|
||||
-->
|
||||
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
|
||||
IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
<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"
|
||||
@@ -148,57 +331,38 @@
|
||||
|
||||
<!-- ============ THE HOST EDITOR ============ -->
|
||||
<!--
|
||||
The editor doubles as the "add" form; there is no separate dialog.
|
||||
The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
|
||||
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
|
||||
looks like where it was pressed.
|
||||
-->
|
||||
<StackPanel Margin="16" Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||
|
||||
<TextBlock Classes="label" Text="HOST" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
|
||||
<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=">_" 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" />
|
||||
<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.
|
||||
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}"
|
||||
@@ -209,19 +373,20 @@
|
||||
</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 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.
|
||||
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">
|
||||
Margin="0,2,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
@@ -229,8 +394,8 @@
|
||||
<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.
|
||||
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}"
|
||||
@@ -247,51 +412,107 @@
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="ADD" Command="{Binding AddEditorTagCommand}" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="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.
|
||||
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="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
||||
</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.
|
||||
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.
|
||||
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.
|
||||
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="16" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditingGroup}">
|
||||
|
||||
<TextBlock Classes="label" Text="GROUP" Foreground="{StaticResource TextDim}" Margin="0,0,0,4" />
|
||||
<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." />
|
||||
@@ -307,6 +528,12 @@
|
||||
</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"
|
||||
@@ -330,17 +557,53 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||
Command="{Binding SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGroupEditCommand}" />
|
||||
</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"
|
||||
IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||
Command="{Binding SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGroupEditCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
|
||||
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
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
The design this was drawn from puts a view-mode switch, a tag filter, a calendar and a share control in
|
||||
@@ -357,9 +362,17 @@
|
||||
and cancels the menu outright over the space around the cards, where there is no host to act
|
||||
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>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
|
||||
<MenuItem Header="Details…" Command="{Binding OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
|
||||
@@ -369,7 +382,18 @@
|
||||
<ListBox.ItemTemplate>
|
||||
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Border Classes="tile">
|
||||
<!--
|
||||
The address is on the tooltip and nowhere else on the card. It used to be the subtitle;
|
||||
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">
|
||||
|
||||
@@ -406,19 +430,24 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
<!--
|
||||
The address and how it authenticates, on one line. Which of the three ways is
|
||||
here because only one of them wants a password typed, and an empty password box
|
||||
on a key-authenticated host is otherwise indistinguishable from one somebody
|
||||
forgot to fill in.
|
||||
◆ 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.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}"
|
||||
FontSize="10.5" Foreground="{StaticResource TextFaint}"
|
||||
<TextBlock Text="{Binding Summary}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
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
|
||||
@@ -460,25 +489,41 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The tags this host wears. Under the summary rather than beside it, because a host
|
||||
can wear several and the line above is three fixed-width facts where these are not.
|
||||
|
||||
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See
|
||||
HostRowViewModel.TagLabels.
|
||||
A wrapped row of tag chips was here. The tags are in the summary line above now,
|
||||
after the account, which is what took the card from three rows and a wrap down to
|
||||
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.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border Classes="chip" Padding="5,0" Margin="0,0,4,2">
|
||||
<TextBlock Text="{Binding}" FontSize="9.5" />
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ THE PENCIL, AND THE ONLY WAY THE DRAWER OPENS FROM THIS GRID.
|
||||
|
||||
Selecting a card no longer opens it. That used to be automatic, which meant the grid
|
||||
gave up 304 pixels the moment anybody touched a card — including every card arrowed
|
||||
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.
|
||||
|
||||
Bottom right, under the status dot rather than beside it, and over the card rather
|
||||
than in a column: the two marks a card can carry on that edge are "a shell is open
|
||||
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.
|
||||
|
||||
The row is the command's parameter rather than being left to the selection. A button
|
||||
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
|
||||
whichever card happened to be lit, which on the first click of a session is none of
|
||||
them.
|
||||
-->
|
||||
<Button Classes="flat rowedit" Content="✎"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).OpenHostPaneCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Opens this host's pane beside the grid" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -503,8 +548,9 @@
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
The drawer, and it takes its 304 pixels only while there is something in it — which is what gives the
|
||||
grid the full width for most of the time anybody is looking at it.
|
||||
The drawer, and it takes its 304 pixels only while it has been opened — which is what gives the grid
|
||||
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
|
||||
MainWindow.axaml's note on why the two cannot be put on one element.
|
||||
|
||||
@@ -315,6 +315,47 @@ internal sealed partial class HostRowViewModel(
|
||||
private string DisplayUsername =>
|
||||
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 IsBlocked => host.IsBlocked;
|
||||
@@ -1587,6 +1628,9 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
@@ -1616,6 +1660,9 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
@@ -1639,10 +1686,83 @@ internal sealed partial class VaultViewModel(
|
||||
/// somebody moved the grid.
|
||||
/// </para>
|
||||
/// </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>
|
||||
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>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
@@ -2313,6 +2433,63 @@ internal sealed partial class VaultViewModel(
|
||||
_ => 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>
|
||||
/// 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
|
||||
@@ -3680,6 +3857,69 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
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>
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
@@ -3762,6 +4002,12 @@ internal sealed partial class VaultViewModel(
|
||||
grouped: row.Host.GroupId is not null);
|
||||
|
||||
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}.";
|
||||
}
|
||||
|
||||
@@ -6723,9 +6969,27 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
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(IsShowingHostDetail));
|
||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||
|
||||
// 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
|
||||
@@ -6805,11 +7069,18 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
||||
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
||||
OnPropertyChanged(nameof(ShowsHostActions));
|
||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
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>
|
||||
/// Takes the question away when the selection it was asked about has moved on.
|
||||
|
||||
@@ -346,6 +346,70 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
|
||||
/// </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 ----
|
||||
|
||||
/// <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
|
||||
// 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]
|
||||
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());
|
||||
}
|
||||
@@ -206,8 +213,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task TheHostDrawerFitsWithADeletionInQuestion()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.SelectedHost.IsConnected = true;
|
||||
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
||||
vault.SelectedHost.ShouldNotBeNull().IsConnected = true;
|
||||
|
||||
vault.DeleteHostCommand.Execute(null);
|
||||
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||
@@ -373,7 +380,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheDrawerOpen()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
||||
vault.IsDrawerOpen.ShouldBeTrue();
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
|
||||
@@ -399,7 +406,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
[Fact]
|
||||
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");
|
||||
|
||||
await OnTheHostsScreenAsync((screen, window) =>
|
||||
|
||||
Reference in New Issue
Block a user