Restyle the drawer, pin folders on a host, and say when it was last connected

This commit is contained in:
2026-08-07 18:12:31 +02:00
parent c3ef4bd8b4
commit 2ba7c14e35
8 changed files with 1392 additions and 202 deletions
+211 -57
View File
@@ -31,16 +31,23 @@
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
for", so no two of these can be true at once. The whole control collapses when none of them is — see
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
300-pixel column beside a grid is 300 pixels the grid could have had.
320-pixel column beside a grid is 320 pixels the grid could have had.
◆ AND IT IS ASKED FOR NOW, rather than arriving with a selection. Touching a card used to open this,
which charged the width of the pane for the act of choosing; the pencil on a card is the asking. See
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
── THE THREE PANELS ARE THE SAME THREE CARDS ────────────────────────────────────────────────────────
Address, General, Connection first as rows stating what the host is, then as boxes for changing it.
The detail pane's rows are buttons that open the editor, so a box that looks editable turns out to be,
one step along; see Button.fieldrow in App.axaml for why they are not inputs that save as you type.
── THE TWO PANELS ARE THE SAME CARDS ────────────────────────────────────────────────────────────────
Address, General, Connection, and — since v5 — Quick access: first as rows stating what the host is,
then as boxes for changing it. QUICK ACCESS is the one card the detail pane draws conditionally rather
than always: a host with no pinned paths has nothing to state, where an empty Address or General would
be a fact about the host rather than an absent one. The detail pane's rows are buttons that open the
editor, so a box that looks editable turns out to be, one step along; see Button.fieldrow in App.axaml
for why they are not inputs that save as you type.
The editor's four cards carry v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — the
mock's tracked-out capitals rather than TextBlock.sectionhead's sentence case; see TextBlock.sectionlabel
in App.axaml and the remark on the editor's StackPanel below for why the detail pane did not move too.
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
screen hands it over.
@@ -62,7 +69,7 @@
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
-->
<Border Width="304" Background="{StaticResource Sidebar}"
<Border Width="320" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<Grid RowDefinitions="Auto,*,Auto">
@@ -79,14 +86,19 @@
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
moment a host is created is a different question, and it is in the editor beside the name.
-->
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
<Border Grid.Row="0" Padding="22,16" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
<TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
<!--
Title 20 bold and subtitle 12.5, the mock's own header sizes. What the subtitle says is
unchanged — see DrawerSubtitle's own remarks below the header for why it is the vault's name
rather than the mock's "Saving to X vault" sentence with a picker's chevron inside it.
-->
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{Binding DrawerTitle}" FontSize="20" FontWeight="Bold"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="12.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding DrawerSubtitle,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
@@ -138,7 +150,7 @@
<Panel>
<!-- ============ WHAT THIS HOST IS ============ -->
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
<!--
============ ADDRESS ============
@@ -155,7 +167,12 @@
Foreground="{StaticResource AccentText}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Button Grid.Column="1" Classes="fieldrow" Margin="10,0,0,0"
<!--
Classes="address" is the v5 restyle: 42 tall and bordered in accent rather than the
ordinary hairline, matched by TextBox.address in App.axaml, which the editor's own
hostname box below wears too — the two are the same field, one step apart.
-->
<Button Grid.Column="1" Classes="fieldrow address" Margin="10,0,0,0"
Command="{Binding EditSelectedHostCommand}"
ToolTip.Tip="Opens this host's editor.">
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
@@ -338,6 +355,36 @@
</StackPanel>
</Border>
<!--
============ QUICK ACCESS (read-only) ============
The folders pinned on this host, in the pane's own fieldrow idiom rather than the editor's rows
with a close box on each one: one Button opens the editor on a press, same as every other row
here, and what is inside it is a list rather than a single fact — the same shape the tags row
above takes for the same reason. Hidden entirely rather than shown empty, for the reason Notes
is: a card titled QUICK ACCESS with nothing under it would answer a question nobody asked here.
-->
<Border Classes="section" IsVisible="{Binding SelectedHost.HasPins}">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="Quick access" Margin="0,0,0,2" />
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
Margin="0,3,0,0" Text="&#xE2C7;" />
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PinnedPaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="mono" Text="{Binding}" FontSize="12"
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Button>
</StackPanel>
</Border>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
@@ -345,22 +392,35 @@
<!-- ============ THE HOST EDITOR ============ -->
<!--
The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
The editor doubles as the "add" form; there is no separate dialog. Same four cards as the pane
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
looks like where it was pressed.
looks like where it was pressed. QUICK ACCESS is the one card that always draws here even when
the pane above hid it for having nothing to show: an empty editor section is where a first pin
gets added, so it cannot be conditional on already having one.
-->
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsEditing}">
<!--
v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — replace sentence-case
headings on this one panel, the editor, because it is the panel the mock actually draws; the
detail pane above keeps TextBlock.sectionhead, its own established idiom, since the mock has no
read-only view to restyle it against. See TextBlock.sectionlabel in App.axaml.
-->
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionhead" Text="Address" />
<TextBlock Classes="sectionlabel" Text="ADDRESS" />
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
<TextBlock Classes="mono" Text="&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}"
<!--
No drawn caret: this TextBox has a real one, and the mock's blinking bar is what a real
caret looks like in a screenshot that cannot show motion. Classes="address" is the 42-tall,
accent-bordered restyle shared with the detail pane's own Hostname row above.
-->
<TextBox Grid.Column="1" Classes="address" Margin="10,0,0,0" Text="{Binding EditorHostname}"
PlaceholderText="hostname or address" />
</Grid>
</StackPanel>
@@ -368,9 +428,9 @@
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
<TextBlock Classes="sectionlabel" Text="GENERAL" Margin="0,0,0,2" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" Height="40" />
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
@@ -413,7 +473,7 @@
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
@@ -453,37 +513,48 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Add button is 40x40 and a glyph rather than the word ADD, matched to the field beside it —
the v5 mock draws every add affordance in this drawer as a square icon button; QUICK ACCESS's
own add row below is the same idiom at 36x36, one size down for its shorter field.
-->
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
Height="40">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD"
Command="{Binding AddEditorTagCommand}" />
<Button Grid.Column="2" Classes="ghost" Content="+" Width="40" Height="40"
FontSize="16" HorizontalContentAlignment="Center"
Command="{Binding AddEditorTagCommand}" ToolTip.Tip="Adds this tag" />
</Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" />
Height="58" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="section">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="Connection" Margin="0,0,0,2" />
<TextBlock Classes="sectionlabel" Text="CONNECTION" Margin="0,0,0,2" />
<!--
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset".
The watermark is what the host will actually use if it is left that way, which is why it is
bound rather than literal: it changes when the group picker above moves.
Username first and port 78 wide second, the mock's own order — reversed from the field's
earlier "port, username" layout, which put the number that is usually left blank ahead of
the one that usually is not. Both boxes are still allowed to be empty, and empty still means
"take the group's" rather than "unset"; the watermark is what the host will actually use if
it is left that way, which is why it is bound rather than literal — it changes when the
group picker above moves.
-->
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False"
PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
<Grid ColumnDefinitions="*,8,78">
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
Height="40" />
</Grid>
<!--
@@ -500,7 +571,7 @@
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
@@ -513,26 +584,42 @@
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay (not wired up yet)" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to
resolve the target, or it becomes an authenticated open proxy into the operator's network.
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay is
built and this client has no path to it: VaultViewModel dials the address directly whether
this is ticked or not. So the box as it stood spent the one deliberate privacy concession
in the design — the address in plaintext columns — and delivered a connection that failed
exactly as it had before, with nothing saying why.
Left tickable rather than disabled, because a host that already carries the flag has to be
able to lose it, and a disabled control would trap the concession on. See
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
removable.
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
and still the reason this box says what it says.
-->
<TextBlock Classes="hint" FontSize="11"
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
</CheckBox>
<!--
Stated at the moment the decision is made, which is the only place it means anything.
With relay off the server stores no address at all; with it on the server must be able
to resolve the target, or it becomes an authenticated open proxy into the operator's
network.
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
is built and this client has no path to it: VaultViewModel dials the address directly
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
concession in the design — the address in plaintext columns — and delivered a
connection that failed exactly as it had before, with nothing saying why.
Left tickable rather than disabled, because a host that already carries the flag has to
be able to lose it, and a disabled control would trap the concession on. See
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
removable.
-->
<TextBlock Classes="hint" FontSize="11.5"
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
</StackPanel>
</Border>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host
@@ -550,6 +637,66 @@
</StackPanel>
</Border>
<!--
============ QUICK ACCESS ============
The folders pinned on this host. Staged on VaultViewModel.EditorPinnedPaths the way the tag
picker stages editorTagIds — see that field's own remarks — populated when the editor opens,
read back by BuildHost on Save, and left alone by CANCEL for the same reason every other field
here is: the whole editor is abandoned together.
-->
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionlabel" Text="QUICK ACCESS" />
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="&#xE2C7;" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding}" FontSize="12.5"
Margin="8,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<!--
$parent[ItemsControl] rather than #Board: this ItemsControl's own DataContext is
already the VaultViewModel — HostDrawer's root x:DataType is that type directly —
so one level up is enough, the same single-hop case ToggleEditorTagCommand above
already uses.
-->
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
Text="{Binding EditorNewPin}"
PlaceholderText="/path/to/folder" Height="36">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorPinCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
HorizontalContentAlignment="Center"
Command="{Binding AddEditorPinCommand}" ToolTip.Tip="Pins this path" />
</Grid>
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="Pinned folders appear above the terminal for this host." />
</StackPanel>
</Border>
</StackPanel>
<!-- ============ THE GROUP EDITOR ============ -->
@@ -727,10 +874,17 @@
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
</StackPanel>
<!--
Save host flex-grows and Cancel is a fixed 92, the mock's own footer proportions — a Grid rather
than the StackPanel every other footer row uses, because a StackPanel measures each child at its
own size and has nothing that means "the rest of the row".
-->
<Grid ColumnDefinitions="*,8,92" IsVisible="{Binding IsEditing}">
<Button Grid.Column="0" Classes="accent" Content="Save host" Height="44"
HorizontalContentAlignment="Center" Command="{Binding SaveHostCommand}" />
<Button Grid.Column="2" Classes="ghost" Content="Cancel" Height="44"
HorizontalContentAlignment="Center" Command="{Binding CancelEditCommand}" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
<Button Classes="accent" Content="{Binding GroupSaveLabel}"