Public Access
Restyle the drawer, pin folders on a host, and say when it was last connected
This commit is contained in:
@@ -585,6 +585,30 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE ADDRESS FIELD, the one input the host drawer draws with an accent border rather than the
|
||||
ordinary grey hairline — the mock's own choice, kept, because it is the one field a session cannot be
|
||||
opened without. Taller than the drawer's other fields too, at 42 rather than the TextBox default.
|
||||
|
||||
No drawn caret: the mock blinks one inside the field's own text, but this is a real TextBox with a real
|
||||
one already, so painting a second would either fight the true caret for the same pixel or lie about
|
||||
where typing lands. See HostDrawer.axaml's ADDRESS section, on both the pane and the editor.
|
||||
|
||||
Declared after the base TextBox rules — including :pointerover and :focus — for the reason this file
|
||||
states three times already for Button.tab, Border.rowmark and the tiles list: Avalonia has no
|
||||
specificity, and a rule declared before an exception it overrides does nothing at all. Placed here
|
||||
rather than beside them so both still read as one un-interrupted block of "what every TextBox looks
|
||||
like" with the one exception after it, not threaded through the middle.
|
||||
-->
|
||||
<Style Selector="TextBox.address">
|
||||
<Setter Property="Height" Value="42" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.address /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -642,16 +666,18 @@
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 232 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 304, which is 522. The
|
||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 490. A WrapPanel fits floor(490 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 235.
|
||||
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 227.
|
||||
|
||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
||||
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
|
||||
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
|
||||
the size this application guarantees, which is the shape the cards exist to avoid.
|
||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
@@ -659,7 +685,7 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="232" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -749,8 +775,9 @@
|
||||
|
||||
The hosts drawer is a column of grouped fields rather than a run of labelled controls — address here,
|
||||
the general facts here, the connection and its credentials here — which is what the design draws and
|
||||
what a 304-pixel column needs to stay readable: three cards is three things to find, where fourteen
|
||||
stacked controls is fourteen.
|
||||
what a 320-pixel column needs to stay readable: three cards is three things to find, where fourteen
|
||||
stacked controls is fourteen. v5 widened the column from 304 to 320 for the ADDRESS field's own
|
||||
breathing room, and moved every affected number in this file and its neighbours with it.
|
||||
|
||||
Raised on Sidebar, which is the one pairing that works in this column. Panel and Chrome are the same
|
||||
value as each other and near enough Sidebar to vanish against it. Field no longer reads as a well
|
||||
@@ -769,8 +796,12 @@
|
||||
<!--
|
||||
A card's heading, and deliberately not TextBlock.label. That style is the small tracked-out capitals
|
||||
this design uses for a *section of a screen* — GROUPS, HOSTS, the vault's categories — and three of
|
||||
them inside a 304-pixel column would claim the same weight as the screen headings beside it. These are
|
||||
them inside a 320-pixel column would claim the same weight as the screen headings beside it. These are
|
||||
captions on a card, so they are sentence case at the body size.
|
||||
|
||||
Still what the group editor's two cards use — GROUP, and what its hosts inherit — because that editor
|
||||
is not the mock this file's ADDRESS/GENERAL/CONNECTION/QUICK ACCESS labels were drawn from and nothing
|
||||
asked it to match a screen it is not on.
|
||||
-->
|
||||
<Style Selector="TextBlock.sectionhead">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
@@ -778,6 +809,24 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE OTHER CARD HEADING, v5's own, and the two are not interchangeable.
|
||||
|
||||
TextBlock.sectionhead above is sentence case at the body size — a caption on a card, the same weight
|
||||
the design's own prose is drawn at. This is the v5 mock's own idiom for the same job, on the one screen
|
||||
it was drawn for: ADDRESS, GENERAL, CONNECTION and QUICK ACCESS in the host drawer, tracked-out capitals
|
||||
at a third the size. Both exist because the group editor's cards were not restyled to the v5 mock — see
|
||||
the remark above — so the drawer now carries a heading in each of the two idioms depending on which
|
||||
panel is showing, and a single class could not have been both.
|
||||
-->
|
||||
<Style Selector="TextBlock.sectionlabel">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ A ROW IN THE DETAIL PANE, AND IT IS A BUTTON THAT LOOKS LIKE A FIELD.
|
||||
|
||||
@@ -817,6 +866,19 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The detail pane's own ADDRESS row, matched to TextBox.address above: taller, and bordered in accent
|
||||
rather than the ordinary hairline even under the pointer — declared after the :pointerover rule for the
|
||||
reason that class's own remark gives, so it wins in both states rather than only the resting one.
|
||||
-->
|
||||
<Style Selector="Button.fieldrow.address">
|
||||
<Setter Property="MinHeight" Value="42" />
|
||||
</Style>
|
||||
<Style Selector="Button.fieldrow.address /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The glyph at the head of such a row. A fixed width so that four rows in a card line their text up, and
|
||||
the faintest step in the ramp because it is a mark to skip rather than a fact to read: what the row
|
||||
|
||||
@@ -31,16 +31,23 @@
|
||||
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
|
||||
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
||||
300-pixel column beside a grid is 300 pixels the grid could have had.
|
||||
320-pixel column beside a grid is 320 pixels the grid could have had.
|
||||
|
||||
◆ AND IT IS ASKED FOR NOW, rather than arriving with a selection. Touching a card used to open this,
|
||||
which charged the width of the pane for the act of choosing; the pencil on a card is the asking. See
|
||||
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
|
||||
|
||||
── THE THREE PANELS ARE THE SAME THREE CARDS ────────────────────────────────────────────────────────
|
||||
Address, General, Connection — first as rows stating what the host is, then as boxes for changing it.
|
||||
The detail pane's rows are buttons that open the editor, so a box that looks editable turns out to be,
|
||||
one step along; see Button.fieldrow in App.axaml for why they are not inputs that save as you type.
|
||||
── THE TWO PANELS ARE THE SAME CARDS ────────────────────────────────────────────────────────────────
|
||||
Address, General, Connection, and — since v5 — Quick access: first as rows stating what the host is,
|
||||
then as boxes for changing it. QUICK ACCESS is the one card the detail pane draws conditionally rather
|
||||
than always: a host with no pinned paths has nothing to state, where an empty Address or General would
|
||||
be a fact about the host rather than an absent one. The detail pane's rows are buttons that open the
|
||||
editor, so a box that looks editable turns out to be, one step along; see Button.fieldrow in App.axaml
|
||||
for why they are not inputs that save as you type.
|
||||
|
||||
The editor's four cards carry v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — the
|
||||
mock's tracked-out capitals rather than TextBlock.sectionhead's sentence case; see TextBlock.sectionlabel
|
||||
in App.axaml and the remark on the editor's StackPanel below for why the detail pane did not move too.
|
||||
|
||||
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
|
||||
screen hands it over.
|
||||
@@ -62,7 +69,7 @@
|
||||
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
|
||||
-->
|
||||
|
||||
<Border Width="304" Background="{StaticResource Sidebar}"
|
||||
<Border Width="320" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
@@ -79,14 +86,19 @@
|
||||
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
|
||||
moment a host is created is a different question, and it is in the editor beside the name.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
|
||||
<Border Grid.Row="0" Padding="22,16" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
|
||||
<!--
|
||||
Title 20 bold and subtitle 12.5, the mock's own header sizes. What the subtitle says is
|
||||
unchanged — see DrawerSubtitle's own remarks below the header for why it is the vault's name
|
||||
rather than the mock's "Saving to X vault" sentence with a picker's chevron inside it.
|
||||
-->
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="20" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
|
||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding DrawerSubtitle,
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
@@ -138,7 +150,7 @@
|
||||
<Panel>
|
||||
|
||||
<!-- ============ WHAT THIS HOST IS ============ -->
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||
|
||||
<!--
|
||||
============ ADDRESS ============
|
||||
@@ -155,7 +167,12 @@
|
||||
Foreground="{StaticResource AccentText}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Button Grid.Column="1" Classes="fieldrow" Margin="10,0,0,0"
|
||||
<!--
|
||||
Classes="address" is the v5 restyle: 42 tall and bordered in accent rather than the
|
||||
ordinary hairline, matched by TextBox.address in App.axaml, which the editor's own
|
||||
hostname box below wears too — the two are the same field, one step apart.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="fieldrow address" Margin="10,0,0,0"
|
||||
Command="{Binding EditSelectedHostCommand}"
|
||||
ToolTip.Tip="Opens this host's editor.">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
||||
@@ -338,6 +355,36 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ QUICK ACCESS (read-only) ============
|
||||
The folders pinned on this host, in the pane's own fieldrow idiom rather than the editor's rows
|
||||
with a close box on each one: one Button opens the editor on a press, same as every other row
|
||||
here, and what is inside it is a list rather than a single fact — the same shape the tags row
|
||||
above takes for the same reason. Hidden entirely rather than shown empty, for the reason Notes
|
||||
is: a card titled QUICK ACCESS with nothing under it would answer a question nobody asked here.
|
||||
-->
|
||||
<Border Classes="section" IsVisible="{Binding SelectedHost.HasPins}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="Quick access" Margin="0,0,0,2" />
|
||||
|
||||
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
|
||||
Margin="0,3,0,0" Text="" />
|
||||
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="mono" Text="{Binding}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
|
||||
|
||||
@@ -345,22 +392,35 @@
|
||||
|
||||
<!-- ============ THE HOST EDITOR ============ -->
|
||||
<!--
|
||||
The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
|
||||
The editor doubles as the "add" form; there is no separate dialog. Same four cards as the pane
|
||||
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
|
||||
looks like where it was pressed.
|
||||
looks like where it was pressed. QUICK ACCESS is the one card that always draws here even when
|
||||
the pane above hid it for having nothing to show: an empty editor section is where a first pin
|
||||
gets added, so it cannot be conditional on already having one.
|
||||
-->
|
||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||
|
||||
<!--
|
||||
v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — replace sentence-case
|
||||
headings on this one panel, the editor, because it is the panel the mock actually draws; the
|
||||
detail pane above keeps TextBlock.sectionhead, its own established idiom, since the mock has no
|
||||
read-only view to restyle it against. See TextBlock.sectionlabel in App.axaml.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="sectionhead" Text="Address" />
|
||||
<TextBlock Classes="sectionlabel" Text="ADDRESS" />
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
||||
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="1" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
||||
<!--
|
||||
No drawn caret: this TextBox has a real one, and the mock's blinking bar is what a real
|
||||
caret looks like in a screenshot that cannot show motion. Classes="address" is the 42-tall,
|
||||
accent-bordered restyle shared with the detail pane's own Hostname row above.
|
||||
-->
|
||||
<TextBox Grid.Column="1" Classes="address" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
||||
PlaceholderText="hostname or address" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -368,9 +428,9 @@
|
||||
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
|
||||
<TextBlock Classes="sectionlabel" Text="GENERAL" Margin="0,0,0,2" />
|
||||
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" Height="40" />
|
||||
|
||||
<!--
|
||||
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
|
||||
@@ -413,7 +473,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch">
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
@@ -453,37 +513,48 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Add button is 40x40 and a glyph rather than the word ADD, matched to the field beside it —
|
||||
the v5 mock draws every add affordance in this drawer as a square icon button; QUICK ACCESS's
|
||||
own add row below is the same idiom at 36x36, one size down for its shorter field.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
|
||||
Height="40">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="ADD"
|
||||
Command="{Binding AddEditorTagCommand}" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="+" Width="40" Height="40"
|
||||
FontSize="16" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorTagCommand}" ToolTip.Tip="Adds this tag" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="56" TextWrapping="Wrap" />
|
||||
Height="58" TextWrapping="Wrap" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="sectionhead" Text="Connection" Margin="0,0,0,2" />
|
||||
<TextBlock Classes="sectionlabel" Text="CONNECTION" Margin="0,0,0,2" />
|
||||
|
||||
<!--
|
||||
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset".
|
||||
The watermark is what the host will actually use if it is left that way, which is why it is
|
||||
bound rather than literal: it changes when the group picker above moves.
|
||||
Username first and port 78 wide second, the mock's own order — reversed from the field's
|
||||
earlier "port, username" layout, which put the number that is usually left blank ahead of
|
||||
the one that usually is not. Both boxes are still allowed to be empty, and empty still means
|
||||
"take the group's" rather than "unset"; the watermark is what the host will actually use if
|
||||
it is left that way, which is why it is bound rather than literal — it changes when the
|
||||
group picker above moves.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" ShowButtonSpinner="False"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
<Grid ColumnDefinitions="*,8,78">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||
Height="40" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -500,7 +571,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch">
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -513,26 +584,42 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay (not wired up yet)" />
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
||||
relay off the server stores no address at all; with it on the server must be able to
|
||||
resolve the target, or it becomes an authenticated open proxy into the operator's network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay is
|
||||
built and this client has no path to it: VaultViewModel dials the address directly whether
|
||||
this is ticked or not. So the box as it stood spent the one deliberate privacy concession
|
||||
in the design — the address in plaintext columns — and delivered a connection that failed
|
||||
exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to be
|
||||
able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
|
||||
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
|
||||
and still the reason this box says what it says.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything.
|
||||
With relay off the server stores no address at all; with it on the server must be able
|
||||
to resolve the target, or it becomes an authenticated open proxy into the operator's
|
||||
network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||
concession in the design — the address in plaintext columns — and delivered a
|
||||
connection that failed exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||
be able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||
@@ -550,6 +637,66 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
============ QUICK ACCESS ============
|
||||
The folders pinned on this host. Staged on VaultViewModel.EditorPinnedPaths the way the tag
|
||||
picker stages editorTagIds — see that field's own remarks — populated when the editor opens,
|
||||
read back by BuildHost on Save, and left alone by CANCEL for the same reason every other field
|
||||
here is: the whole editor is abandoned together.
|
||||
-->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="sectionlabel" Text="QUICK ACCESS" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding}" FontSize="12.5"
|
||||
Margin="8,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
$parent[ItemsControl] rather than #Board: this ItemsControl's own DataContext is
|
||||
already the VaultViewModel — HostDrawer's root x:DataType is that type directly —
|
||||
so one level up is enough, the same single-hop case ToggleEditorTagCommand above
|
||||
already uses.
|
||||
-->
|
||||
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
|
||||
FontSize="11"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
|
||||
Text="{Binding EditorNewPin}"
|
||||
PlaceholderText="/path/to/folder" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorPinCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorPinCommand}" ToolTip.Tip="Pins this path" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Pinned folders appear above the terminal for this host." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ THE GROUP EDITOR ============ -->
|
||||
@@ -727,10 +874,17 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Save host flex-grows and Cancel is a fixed 92, the mock's own footer proportions — a Grid rather
|
||||
than the StackPanel every other footer row uses, because a StackPanel measures each child at its
|
||||
own size and has nothing that means "the rest of the row".
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,92" IsVisible="{Binding IsEditing}">
|
||||
<Button Grid.Column="0" Classes="accent" Content="Save host" Height="44"
|
||||
HorizontalContentAlignment="Center" Command="{Binding SaveHostCommand}" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="Cancel" Height="44"
|
||||
HorizontalContentAlignment="Center" Command="{Binding CancelEditCommand}" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||
|
||||
@@ -101,7 +101,53 @@
|
||||
-->
|
||||
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!--
|
||||
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
|
||||
|
||||
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
|
||||
connecting card are all layered on top of one another there, which is right for three things
|
||||
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
|
||||
and IsVisible="False" collapse to nothing when there is nothing to show, so a page screen or an
|
||||
empty terminal loses no height to a row it never draws — see ShowsPinStrip, which is false on
|
||||
every page and every tab whose host has no pins.
|
||||
|
||||
ActiveTabPinnedPaths is MainWindowViewModel's, not the vault's: which tab is selected and
|
||||
whether its session is live are the shell's business, and RefreshConnectedHosts is where the
|
||||
two lists — Tabs and Vault.Hosts — are already walked together to paint the status dots. This
|
||||
rides along on the same walk rather than opening a subscription of its own.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding ShowsPinStrip, FallbackValue=False}">
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] is enough to reach back to the shell: this
|
||||
ItemsControl's own DataContext is MainWindowViewModel, the window's root, so one hop up
|
||||
from the chip's string DataContext lands on it directly — no #Board-style named-element
|
||||
indirection needed, because there is no second level of template nesting here.
|
||||
-->
|
||||
<Button Classes="ghost" Padding="8,4" ToolTip.Tip="{Binding}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="12" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" Text="{Binding}" FontSize="11.5"
|
||||
MaxWidth="220" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
@@ -232,6 +278,7 @@
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -308,6 +308,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
||||
|
||||
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
|
||||
/// <remarks>
|
||||
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
|
||||
/// ever names, so a tick any faster would restring a fact that has not changed in any word it prints.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hosts screen was showing the last time <see cref="UpdateLastConnectedVisibility"/> ran.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The transition is what matters, not the level — see that method. Without this, every unrelated
|
||||
/// screen or surface change would re-read the connection log for no reason, which is exactly the
|
||||
/// background re-read <c>LogsViewModel.cs</c> argues a connection log must never be put on.
|
||||
/// </remarks>
|
||||
private bool wasHostsScreenShowing;
|
||||
|
||||
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
|
||||
private CancellationTokenSource? lastConnectedTick;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -2633,6 +2653,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
|
||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
||||
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
|
||||
// object would otherwise leak.
|
||||
StopLastConnectedTick();
|
||||
|
||||
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
|
||||
// vault because the restart path disposes this whole object and then applies the update — so a
|
||||
// check still running would be writing into a view model the process is about to replace.
|
||||
@@ -2892,8 +2917,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
/// <remarks>
|
||||
/// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts
|
||||
/// with neither, and this is the one place both know a rebuild just happened. No log read here — see
|
||||
/// <see cref="VaultViewModel.RestringLastConnected"/> — only the timestamps already on hand, written
|
||||
/// onto whichever row objects exist now.
|
||||
/// </remarks>
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
RefreshConnectedHosts();
|
||||
Vault?.RestringLastConnected();
|
||||
}
|
||||
|
||||
private void RaiseSyncState()
|
||||
{
|
||||
@@ -3152,9 +3186,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
|
||||
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
|
||||
/// the scrollback, and the renderer has already written the reason into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Also where a session ending re-reads the connection log</b>, per decision 4: the host just closed
|
||||
/// is the one whose ago-text is about to stop being suppressed by <see cref="HostRowViewModel.IsConnected"/>,
|
||||
/// and it deserves "just now" rather than whatever it last said. <see cref="ConnectionRecorder.Closed"/>
|
||||
/// runs before this event does, but its own write is queued onto a background task rather than made
|
||||
/// inline — see its remarks — so a read landing before that write drains shows the previous entry
|
||||
/// instead. It self-heals on the next activation or the next session end, which is judged an acceptable
|
||||
/// gap rather than worth blocking this handler on the recorder's queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
@@ -3165,29 +3210,110 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list.
|
||||
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
||||
/// wrong than a dot that goes dark when the vault is reopened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
|
||||
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
|
||||
/// sites for the list of moments that counts as.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshConnectedHosts()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
return;
|
||||
}
|
||||
|
||||
HostRowViewModel? connectedActiveHost = null;
|
||||
|
||||
foreach (var host in vault.Hosts)
|
||||
{
|
||||
host.IsConnected = Tabs.Any(
|
||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||
|
||||
// The active tab's host, and only when it is actually connected: the strip is for a session that
|
||||
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
{
|
||||
connectedActiveHost = host;
|
||||
}
|
||||
}
|
||||
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
|
||||
if (connectedActiveHost is not null)
|
||||
{
|
||||
foreach (var path in connectedActiveHost.Host.PinnedPaths)
|
||||
{
|
||||
ActiveTabPinnedPaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
|
||||
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
|
||||
|
||||
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
|
||||
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pin strip draws at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
|
||||
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
|
||||
/// the rare frame between selecting a terminal tab and navigating away from it.
|
||||
/// </remarks>
|
||||
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnLiveSessionCountChanged(int value)
|
||||
@@ -3253,6 +3379,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ = vaults.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
UpdateLastConnectedVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3324,7 +3451,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// so handing it the vault's object would select nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e)
|
||||
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
|
||||
_ = GoToHostFilesAsync(e.Host, null);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The shared body behind <see cref="OnVaultFilesRequested"/> and <see cref="OpenPinnedPathAsync"/>: which
|
||||
/// machine and where to land once it is open are the only two things that differ between "Browse files"
|
||||
/// and a click on the pin strip, and both are parameters here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host asking for a typed password still only opens the picker.</b> <paramref name="path"/> is not
|
||||
/// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files
|
||||
/// already had before there was a path to carry, and closing it would mean holding a pending navigation
|
||||
/// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin
|
||||
/// that has to be clicked again once the session is open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It reconnects rather than checking whether this host is already open.</b> The existing "Browse
|
||||
/// files" flow — <see cref="TransfersViewModel.ConnectCommand"/> — never asked, and reusing it here
|
||||
/// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files
|
||||
/// does not have. See <c>hosts-v5-design-spec.md</c>: SFTP is a second authenticated connection, every
|
||||
/// time it is opened, by design.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task GoToHostFilesAsync(HostRowViewModel host, string? path)
|
||||
{
|
||||
ShowFiles(RemoteKind.Host);
|
||||
|
||||
@@ -3336,7 +3490,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
Transfers.SelectedHost =
|
||||
Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
|
||||
Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);
|
||||
|
||||
if (Transfers.SelectedHost is null)
|
||||
{
|
||||
@@ -3346,11 +3500,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (Transfers.SelectedHostAsksForAPassword)
|
||||
{
|
||||
Transfers.BeginChoosingRemoteCommand.Execute(null);
|
||||
Transfers.Status = $"{e.Host.Label} asks for a password. Type it here, then CONNECT.";
|
||||
Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT.";
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
|
||||
if (path is not null && Transfers.IsConnected)
|
||||
{
|
||||
await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
@@ -3369,6 +3528,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
RaiseSurfaceState();
|
||||
UpdateLastConnectedVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called from both <see cref="OnScreenChanged"/> and <see cref="OnSurfaceChanged"/>, because either one
|
||||
/// alone can be what makes <see cref="IsHostsShowing"/> flip: <see cref="ShowScreenCommand"/> moves both
|
||||
/// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface,
|
||||
/// and switching groups on the hosts screen itself moves neither. <see cref="wasHostsScreenShowing"/> is
|
||||
/// what turns two call sites into one decision rather than two chances to double the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The read this starts is the one <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already argues
|
||||
/// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the
|
||||
/// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait
|
||||
/// on a decrypt pass over its log.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void UpdateLastConnectedVisibility()
|
||||
{
|
||||
var showing = IsHostsShowing;
|
||||
|
||||
if (showing == wasHostsScreenShowing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wasHostsScreenShowing = showing;
|
||||
|
||||
if (showing)
|
||||
{
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
StartLastConnectedTick();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopLastConnectedTick();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No log read here or in <see cref="RunLastConnectedTickAsync"/>.</b> The tick only turns the
|
||||
/// timestamps <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already read into new words, through
|
||||
/// <see cref="VaultViewModel.RestringLastConnected"/> — reading the connection log itself on a timer is
|
||||
/// exactly what <c>LogsViewModel.cs</c> argues that log must never be put on.
|
||||
/// </remarks>
|
||||
private void StartLastConnectedTick()
|
||||
{
|
||||
if (lastConnectedTick is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
lastConnectedTick = cts;
|
||||
_ = RunLastConnectedTickAsync(cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>Stops the loop <see cref="StartLastConnectedTick"/> began, if one is running.</summary>
|
||||
/// <remarks>
|
||||
/// Cancelled rather than merely forgotten, so the loop's own <c>PeriodicTimer</c> wait unblocks and the
|
||||
/// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
|
||||
/// </remarks>
|
||||
private void StopLastConnectedTick()
|
||||
{
|
||||
if (lastConnectedTick is not { } cts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastConnectedTick = null;
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Vault?.RestringLastConnected();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The hosts screen navigated away, or the shell is closing.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -3378,6 +3633,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
|
||||
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
|
||||
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
|
||||
// would answer correctly the next time anything else asked it.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsKeychainScreen));
|
||||
@@ -3423,6 +3684,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
|
||||
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
|
||||
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
|
||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||
// on screen; see TerminalTabViewModel.IsShowing.
|
||||
|
||||
@@ -617,10 +617,11 @@ internal sealed partial class HostRowViewModel(
|
||||
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not computed here.</b> A later wave fills this from the synced connection log on the hosts
|
||||
/// screen's activation and on <c>SessionEnded</c>, and restrings it on a tick while the screen is
|
||||
/// visible. This row carries only the string, the way <see cref="IsConnected"/> carries a fact the vault
|
||||
/// does not own either.
|
||||
/// <b>Not computed here.</b> <see cref="VaultViewModel.RefreshLastConnectedAsync"/> fills this from the
|
||||
/// connection log on the hosts screen's activation and on <c>SessionEnded</c>, and
|
||||
/// <see cref="VaultViewModel.RestringLastConnected"/> re-strings it on a tick while the screen is visible
|
||||
/// and after every rebuild of <see cref="VaultViewModel.Hosts"/>. This row carries only the string, the
|
||||
/// way <see cref="IsConnected"/> carries a fact the vault does not own either.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string lastConnectedText = string.Empty;
|
||||
@@ -1485,6 +1486,135 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent <c>StartedAt</c> the connection log carries for each host, by <c>HostId</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read once per <see cref="RefreshLastConnectedAsync"/> and kept here rather than only on the rows,
|
||||
/// because <see cref="Hosts"/> is rebuilt wholesale on every synchronisation pass — see the remark on
|
||||
/// <see cref="HostRowViewModel.IsConnected"/> — and a rebuilt row has to get its text back from
|
||||
/// somewhere that survived the rebuild. <see cref="RestringLastConnected"/> is what writes it back on;
|
||||
/// <c>MainWindowViewModel.OnVaultHostsChanged</c> is what calls that after every rebuild.
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, DateTimeOffset> lastConnectedAt = [];
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the connection log and refreshes every host row's <see cref="HostRowViewModel.LastConnectedText"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// On demand, not on the sync loop — the same rule <c>LogsViewModel</c> states for the log itself:
|
||||
/// nobody is waiting for their own connection from an hour ago to update on a screen they are not
|
||||
/// looking at, and a decrypt pass over the whole log is not free. The caller decides when "on demand"
|
||||
/// is: the hosts screen's own activation and a session ending, both in <c>MainWindowViewModel</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Scoped to <see cref="VaultSession.ActiveVaultId"/> alone</b>, the same limitation
|
||||
/// <c>LogsViewModel</c> and <c>MainWindowViewModel.RecentConnections</c> already carry, unlike
|
||||
/// <see cref="Hosts"/> itself, which reads every readable vault. A host filed in a second shown vault
|
||||
/// gets no ago-text until this reads that vault's log too — worth fixing the day something on this
|
||||
/// screen already reads more than one vault's connection history; nothing does yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Failures are swallowed, like every other advisory read on this screen: a missing ago-text is not
|
||||
/// worth a status line under a screen whose job is letting somebody connect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task RefreshLastConnectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
lastConnectedAt.Clear();
|
||||
|
||||
foreach (var entry in connections.Items)
|
||||
{
|
||||
if (entry.Secret.HostId is not { } hostId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastConnectedAt.TryGetValue(hostId, out var current)
|
||||
|| entry.Secret.StartedAt > current)
|
||||
{
|
||||
lastConnectedAt[hostId] = entry.Secret.StartedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RestringLastConnected();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the timestamps <see cref="RefreshLastConnectedAsync"/> already read into the words each card
|
||||
/// shows, without reading the log again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the hosts screen's one-minute tick calls, and what <c>MainWindowViewModel.OnVaultHostsChanged</c>
|
||||
/// calls after every rebuild of <see cref="Hosts"/> — neither of those is a reason to ask the vault
|
||||
/// anything, only to say the same fact in fresher words or on a new set of row objects.
|
||||
/// <para>
|
||||
/// <b>A connected host shows nothing here, per the design's own decision.</b> The green dot already
|
||||
/// says "open right now"; printing "3 min ago" beside it would be answering a question about a
|
||||
/// connection that is not the one still running, on the one card where that answer is most likely to
|
||||
/// be misread as current.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void RestringLastConnected()
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow();
|
||||
|
||||
foreach (var row in Hosts)
|
||||
{
|
||||
row.LastConnectedText = !row.IsConnected && lastConnectedAt.TryGetValue(row.EntityId, out var at)
|
||||
? DescribeElapsed(now - at)
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The word a card prints for how long ago a connection started, given how long ago that was.</summary>
|
||||
/// <remarks>
|
||||
/// A pure function of the gap rather than of a clock, which is what lets <see cref="RestringLastConnected"/>
|
||||
/// call it once a tick and once after every log read alike, and what lets a test hand it a gap directly
|
||||
/// instead of freezing time to manufacture one.
|
||||
/// <para>
|
||||
/// Each unit's own top end is the next unit's first tick: fifty-nine seconds still reads "just now" and
|
||||
/// sixty is the first "1 min ago"; fifty-nine minutes stays minutes and sixty crosses into "1 hr ago";
|
||||
/// twenty-three hours stays hours and twenty-four crosses into "1 day ago". A negative gap — a clock that
|
||||
/// skipped backwards — reads as "just now" rather than as a claim nothing here can back up.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static string DescribeElapsed(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return "just now";
|
||||
}
|
||||
|
||||
if (elapsed < TimeSpan.FromHours(1))
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalMinutes} min ago");
|
||||
}
|
||||
|
||||
if (elapsed < TimeSpan.FromDays(1))
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalHours} hr ago");
|
||||
}
|
||||
|
||||
var days = (int)elapsed.TotalDays;
|
||||
|
||||
return days == 1
|
||||
? "1 day ago"
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{days} days ago");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
@@ -3195,6 +3325,113 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(HasTagChoices));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The authority while an editor is open, on the same footing <see cref="editorTagIds"/> holds for tags:
|
||||
/// populated from <see cref="HostSecret.PinnedPaths"/> when the editor opens, mutated by
|
||||
/// <see cref="AddEditorPin"/> and <see cref="RemoveEditorPin"/> while it stays open, and read back by
|
||||
/// <see cref="BuildHost"/> on Save. Unlike the tag set there is nothing else to project it onto — a pin
|
||||
/// has no id and no vault-wide list of every pin that exists, so this collection is both the staging area
|
||||
/// and the thing QUICK ACCESS binds to directly, with no <c>BuildTagChoices</c>-style rebuild step.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An <see cref="ObservableCollection{T}"/> rather than a field replaced wholesale, because a row in the
|
||||
/// editor is a button that removes one path — a rebuilt collection would have to re-diff itself against
|
||||
/// the one the list was bound to, where an in-place <c>Add</c>/<c>Remove</c> is what the binding already
|
||||
/// knows how to redraw incrementally.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<string> EditorPinnedPaths { get; } = [];
|
||||
|
||||
/// <summary>The box QUICK ACCESS's add row holds before ADD is pressed.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorNewPin = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Pins a path on the host being edited, from the editor's own box.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Refuses everything <see cref="HostSecret.TryValidate"/> would, and for the same reason
|
||||
/// <see cref="AddEditorTagAsync"/> validates a tag's label before writing it anywhere: a refusal that
|
||||
/// waits for SAVE is a refusal that throws away five other fields typed since, where one at the box that
|
||||
/// caused it costs nothing else on the form. <c>HostSecret.MaxPinnedPaths</c> and
|
||||
/// <c>MaxPinnedPathLength</c> are read from the domain type rather than restated here, so a bound that
|
||||
/// moves there cannot go stale at this, its only other reader.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A path already pinned is refused rather than duplicated. <see cref="PinnedPathList.Create"/> would
|
||||
/// silently drop the second one at Save regardless, but refusing here is the honest version — a user who
|
||||
/// typed the same path twice is told why nothing changed, rather than watching it vanish once the drawer
|
||||
/// closes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void AddEditorPin()
|
||||
{
|
||||
var path = EditorNewPin.Trim();
|
||||
|
||||
if (path.Length == 0)
|
||||
{
|
||||
Status = "A pinned path cannot be blank.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (EditorPinnedPaths.Count >= HostSecret.MaxPinnedPaths)
|
||||
{
|
||||
Status = $"A host cannot pin more than {HostSecret.MaxPinnedPaths} paths.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Length > HostSecret.MaxPinnedPathLength)
|
||||
{
|
||||
Status = $"A pinned path cannot be longer than {HostSecret.MaxPinnedPathLength} characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Any(char.IsControl))
|
||||
{
|
||||
Status = "A pinned path cannot contain a control character.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (EditorPinnedPaths.Contains(path, StringComparer.Ordinal))
|
||||
{
|
||||
Status = $"'{path}' is already pinned.";
|
||||
return;
|
||||
}
|
||||
|
||||
EditorPinnedPaths.Add(path);
|
||||
EditorNewPin = string.Empty;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Unpins a path from the host being edited.</summary>
|
||||
[RelayCommand]
|
||||
private void RemoveEditorPin(string? path)
|
||||
{
|
||||
if (path is not null)
|
||||
{
|
||||
EditorPinnedPaths.Remove(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stages an existing host's pins for editing. See <see cref="EditorPinnedPaths"/>.</summary>
|
||||
private void LoadEditorPinnedPaths(PinnedPathList pinned)
|
||||
{
|
||||
EditorPinnedPaths.Clear();
|
||||
|
||||
foreach (var path in pinned)
|
||||
{
|
||||
EditorPinnedPaths.Add(path);
|
||||
}
|
||||
|
||||
EditorNewPin = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>The item being edited, or null when creating.</summary>
|
||||
private Guid? editingEntityId;
|
||||
|
||||
@@ -6969,7 +7206,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// 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
|
||||
/// One button for all three, because what it means is "give the grid its 320 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
|
||||
@@ -7021,6 +7258,8 @@ internal sealed partial class VaultViewModel(
|
||||
editorTagIds = TagSet.Empty;
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
EditorPinnedPaths.Clear();
|
||||
EditorNewPin = string.Empty;
|
||||
|
||||
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||||
BuildEditorVaultChoices(editingHostVaultId);
|
||||
@@ -7109,6 +7348,8 @@ internal sealed partial class VaultViewModel(
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
|
||||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||
|
||||
BuildEditorVaultChoices(editingHostVaultId);
|
||||
BuildGroupChoices(row.Host.GroupId);
|
||||
|
||||
@@ -11432,6 +11673,12 @@ internal sealed partial class VaultViewModel(
|
||||
// something else.
|
||||
TagIds = editorTagIds,
|
||||
|
||||
// The editor's own staged list — see EditorPinnedPaths — canonicalised on the way out the same
|
||||
// way TagIds is: PinnedPathList.Create dedupes and keeps order, so a path added twice by some
|
||||
// path this box's own guard in AddEditorPin did not catch still lands on the host once rather
|
||||
// than twice.
|
||||
PinnedPaths = PinnedPathList.Create(EditorPinnedPaths),
|
||||
|
||||
// Both read off the one picker, including the id of something that has gone missing. Reading them
|
||||
// from the picker rather than carrying the originals through is what lets a binding be removed at
|
||||
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
|
||||
|
||||
Reference in New Issue
Block a user