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}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</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
|
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
|
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
|
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||||
rather than narrower.
|
rather than narrower.
|
||||||
|
|
||||||
◆ 232 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
◆ 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 304, which is 522. The
|
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 —
|
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
|
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||||
Width no more than 235.
|
Width no more than 227.
|
||||||
|
|
||||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
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
|
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
|
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">
|
<Style Selector="Border.tile">
|
||||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||||
@@ -659,7 +685,7 @@
|
|||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
<Setter Property="Padding" Value="12,10" />
|
<Setter Property="Padding" Value="12,10" />
|
||||||
<Setter Property="Width" Value="232" />
|
<Setter Property="Width" Value="224" />
|
||||||
<Setter Property="Margin" Value="0,0,10,10" />
|
<Setter Property="Margin" Value="0,0,10,10" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
<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 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
|
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
|
what a 320-pixel column needs to stay readable: three cards is three things to find, where fourteen
|
||||||
stacked controls is 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
|
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
|
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
|
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
|
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.
|
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">
|
<Style Selector="TextBlock.sectionhead">
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
@@ -778,6 +809,24 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
</Style>
|
</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.
|
◆ 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}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||||
</Style>
|
</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 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
|
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
|
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
|
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||||
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
||||||
300-pixel column beside a grid is 300 pixels the grid could have had.
|
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,
|
◆ 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
|
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.
|
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
|
||||||
|
|
||||||
── THE THREE PANELS ARE THE SAME THREE CARDS ────────────────────────────────────────────────────────
|
── THE TWO PANELS ARE THE SAME CARDS ────────────────────────────────────────────────────────────────
|
||||||
Address, General, Connection — first as rows stating what the host is, then as boxes for changing it.
|
Address, General, Connection, and — since v5 — Quick access: first as rows stating what the host is,
|
||||||
The detail pane's rows are buttons that open the editor, so a box that looks editable turns out to be,
|
then as boxes for changing it. QUICK ACCESS is the one card the detail pane draws conditionally rather
|
||||||
one step along; see Button.fieldrow in App.axaml for why they are not inputs that save as you type.
|
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
|
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
|
||||||
screen hands it over.
|
screen hands it over.
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
|
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">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<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
|
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.
|
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">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<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" />
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
|
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="12.5"
|
||||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||||
IsVisible="{Binding DrawerSubtitle,
|
IsVisible="{Binding DrawerSubtitle,
|
||||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
@@ -138,7 +150,7 @@
|
|||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ WHAT THIS HOST IS ============ -->
|
<!-- ============ WHAT THIS HOST IS ============ -->
|
||||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============ ADDRESS ============
|
============ ADDRESS ============
|
||||||
@@ -155,7 +167,12 @@
|
|||||||
Foreground="{StaticResource AccentText}"
|
Foreground="{StaticResource AccentText}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</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}"
|
Command="{Binding EditSelectedHostCommand}"
|
||||||
ToolTip.Tip="Opens this host's editor.">
|
ToolTip.Tip="Opens this host's editor.">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
||||||
@@ -338,6 +355,36 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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"
|
<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." />
|
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 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
|
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">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Classes="sectionhead" Text="Address" />
|
<TextBlock Classes="sectionlabel" Text="ADDRESS" />
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
||||||
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
||||||
Foreground="{StaticResource AccentText}"
|
Foreground="{StaticResource AccentText}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</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" />
|
PlaceholderText="hostname or address" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -368,9 +428,9 @@
|
|||||||
|
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="6">
|
<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
|
◆ 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}"
|
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedGroup}"
|
SelectedItem="{Binding EditorSelectedGroup}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:GroupChoice">
|
<DataTemplate x:DataType="vm:GroupChoice">
|
||||||
<TextBlock Text="{Binding Label}" />
|
<TextBlock Text="{Binding Label}" />
|
||||||
@@ -453,37 +513,48 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</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">
|
<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>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="2" Classes="ghost" Content="ADD"
|
<Button Grid.Column="2" Classes="ghost" Content="+" Width="40" Height="40"
|
||||||
Command="{Binding AddEditorTagCommand}" />
|
FontSize="16" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding AddEditorTagCommand}" ToolTip.Tip="Adds this tag" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||||
Height="56" TextWrapping="Wrap" />
|
Height="58" TextWrapping="Wrap" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="6">
|
<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".
|
Username first and port 78 wide second, the mock's own order — reversed from the field's
|
||||||
The watermark is what the host will actually use if it is left that way, which is why it is
|
earlier "port, username" layout, which put the number that is usually left blank ahead of
|
||||||
bound rather than literal: it changes when the group picker above moves.
|
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,*">
|
<Grid ColumnDefinitions="*,8,78">
|
||||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||||
FormatString="0" ShowButtonSpinner="False"
|
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||||
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
|
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||||
|
Height="40" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -500,7 +571,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
@@ -513,26 +584,42 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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
|
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||||
relay off the server stores no address at all; with it on the server must be able to
|
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||||
resolve the target, or it becomes an authenticated open proxy into the operator's network.
|
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.
|
||||||
|
-->
|
||||||
|
<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
|
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||||
built and this client has no path to it: VaultViewModel dials the address directly whether
|
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||||
this is ticked or not. So the box as it stood spent the one deliberate privacy concession
|
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||||
in the design — the address in plaintext columns — and delivered a connection that failed
|
concession in the design — the address in plaintext columns — and delivered a
|
||||||
exactly as it had before, with nothing saying why.
|
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
|
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||||
able to lose it, and a disabled control would trap the concession on. See
|
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
|
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||||
removable.
|
removable.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
<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." />
|
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
|
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||||
@@ -550,6 +637,66 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ============ THE GROUP EDITOR ============ -->
|
<!-- ============ THE GROUP EDITOR ============ -->
|
||||||
@@ -727,10 +874,17 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
|
<!--
|
||||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
Save host flex-grows and Cancel is a fixed 92, the mock's own footer proportions — a Grid rather
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
than the StackPanel every other footer row uses, because a StackPanel measures each child at its
|
||||||
</StackPanel>
|
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}">
|
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||||
|
|||||||
@@ -101,7 +101,53 @@
|
|||||||
-->
|
-->
|
||||||
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
<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 ============ -->
|
<!-- ============ THE PAGES ============ -->
|
||||||
<Panel IsVisible="{Binding IsShowingPages}">
|
<Panel IsVisible="{Binding IsShowingPages}">
|
||||||
@@ -234,6 +280,7 @@
|
|||||||
</Panel>
|
</Panel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
||||||
|
|||||||
@@ -308,6 +308,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
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;
|
private IVaultServer? connection;
|
||||||
|
|
||||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
/// <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.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
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
|
// 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
|
// 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.
|
// 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();
|
RefreshConnectedHosts();
|
||||||
|
Vault?.RestringLastConnected();
|
||||||
|
}
|
||||||
|
|
||||||
private void RaiseSyncState()
|
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.
|
/// Marks a tab dead when its shell ends on its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
@@ -3165,31 +3210,112 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
RefreshConnectedHosts();
|
RefreshConnectedHosts();
|
||||||
|
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
/// 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
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private void RefreshConnectedHosts()
|
private void RefreshConnectedHosts()
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault)
|
if (Vault is not { } vault)
|
||||||
{
|
{
|
||||||
|
ActiveTabPinnedPaths.Clear();
|
||||||
|
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||||
|
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HostRowViewModel? connectedActiveHost = null;
|
||||||
|
|
||||||
foreach (var host in vault.Hosts)
|
foreach (var host in vault.Hosts)
|
||||||
{
|
{
|
||||||
host.IsConnected = Tabs.Any(
|
host.IsConnected = Tabs.Any(
|
||||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
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)
|
partial void OnLiveSessionCountChanged(int value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasLiveSessions));
|
OnPropertyChanged(nameof(HasLiveSessions));
|
||||||
@@ -3253,6 +3379,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
_ = vaults.LoadAsync(CancellationToken.None);
|
_ = vaults.LoadAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateLastConnectedVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3324,7 +3451,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// so handing it the vault's object would select nothing.
|
/// so handing it the vault's object would select nothing.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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);
|
ShowFiles(RemoteKind.Host);
|
||||||
|
|
||||||
@@ -3336,7 +3490,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
Transfers.SelectedHost =
|
Transfers.SelectedHost =
|
||||||
Transfers.Hosts.FirstOrDefault(row => row.EntityId == e.Host.EntityId);
|
Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);
|
||||||
|
|
||||||
if (Transfers.SelectedHost is null)
|
if (Transfers.SelectedHost is null)
|
||||||
{
|
{
|
||||||
@@ -3346,11 +3500,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (Transfers.SelectedHostAsksForAPassword)
|
if (Transfers.SelectedHostAsksForAPassword)
|
||||||
{
|
{
|
||||||
Transfers.BeginChoosingRemoteCommand.Execute(null);
|
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;
|
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" />
|
/// <inheritdoc cref="OnScreenChanged" />
|
||||||
@@ -3369,6 +3528,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
RaiseSurfaceState();
|
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>
|
/// <remarks>
|
||||||
@@ -3378,6 +3633,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RaiseSurfaceState()
|
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(IsHostsScreen));
|
||||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||||
OnPropertyChanged(nameof(IsKeychainScreen));
|
OnPropertyChanged(nameof(IsKeychainScreen));
|
||||||
@@ -3423,6 +3684,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
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
|
// 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
|
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||||
// on screen; see TerminalTabViewModel.IsShowing.
|
// 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.
|
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Not computed here.</b> A later wave fills this from the synced connection log on the hosts
|
/// <b>Not computed here.</b> <see cref="VaultViewModel.RefreshLastConnectedAsync"/> fills this from the
|
||||||
/// screen's activation and on <c>SessionEnded</c>, and restrings it on a tick while the screen is
|
/// connection log on the hosts screen's activation and on <c>SessionEnded</c>, and
|
||||||
/// visible. This row carries only the string, the way <see cref="IsConnected"/> carries a fact the vault
|
/// <see cref="VaultViewModel.RestringLastConnected"/> re-strings it on a tick while the screen is visible
|
||||||
/// does not own either.
|
/// 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>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string lastConnectedText = string.Empty;
|
private string lastConnectedText = string.Empty;
|
||||||
@@ -1485,6 +1486,135 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
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>
|
/// <summary>
|
||||||
/// What the hosts grid says when it has nothing in it.
|
/// What the hosts grid says when it has nothing in it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3195,6 +3325,113 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(HasTagChoices));
|
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>
|
/// <summary>The item being edited, or null when creating.</summary>
|
||||||
private Guid? editingEntityId;
|
private Guid? editingEntityId;
|
||||||
|
|
||||||
@@ -6969,7 +7206,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// Puts the drawer away, whichever of the three panels is in it.
|
/// Puts the drawer away, whichever of the three panels is in it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <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
|
/// "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
|
/// 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
|
/// 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;
|
editorTagIds = TagSet.Empty;
|
||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
EditorPinnedPaths.Clear();
|
||||||
|
EditorNewPin = string.Empty;
|
||||||
|
|
||||||
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
@@ -7109,6 +7348,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
|
||||||
|
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||||
|
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
BuildGroupChoices(row.Host.GroupId);
|
BuildGroupChoices(row.Host.GroupId);
|
||||||
|
|
||||||
@@ -11432,6 +11673,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
// something else.
|
// something else.
|
||||||
TagIds = editorTagIds,
|
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
|
// 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
|
// 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
|
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ internal static class LayoutHarness
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
|
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
|
||||||
/// drawer states its own width instead — it is the only thing in its column and the column is
|
/// drawer states its own width instead — it is the only thing in its column and the column is
|
||||||
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it.
|
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it. It was 304 through
|
||||||
|
/// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and <c>App.axaml</c>'s
|
||||||
|
/// <c>Border.tile</c> narrowed to keep two columns fitting the grid beside it at the window's minimum.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double HostDrawerWidth = 304;
|
internal const double HostDrawerWidth = 320;
|
||||||
|
|
||||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||||
internal const double NavRailWidth = 190;
|
internal const double NavRailWidth = 190;
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
//
|
//
|
||||||
// This was the host sidebar's section. The control kept the half of that column that is about one host
|
// This was the host sidebar's section. The control kept the half of that column that is about one host
|
||||||
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
|
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
|
||||||
// the right.
|
// the right. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
|
||||||
|
// LayoutHarness.HostDrawerWidth.
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
|
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
|
||||||
@@ -161,7 +162,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
|
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
|
||||||
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
|
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
|
||||||
/// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
|
/// wrapped row of tag chips, a checkbox, a relay card, QUICK ACCESS's own rows and its add row, a
|
||||||
|
/// paragraph of hint text and three buttons.
|
||||||
|
///
|
||||||
|
/// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
|
||||||
|
/// harness skips the scrolled content's height (see the remark above
|
||||||
|
/// <see cref="TheHostDrawerFitsWithADeletionInQuestion"/>) but not its width, and a row's own mono path
|
||||||
|
/// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
|
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
|
||||||
@@ -177,6 +184,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
.First(choice => choice.Kind is AuthenticationKind.Credential);
|
.First(choice => choice.Kind is AuthenticationKind.Credential);
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +236,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
|
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
|
||||||
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
|
/// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
|
||||||
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
|
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
|
||||||
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
|
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using DodoSSH.Client.Auth;
|
using DodoSSH.Client.Auth;
|
||||||
using DodoSSH.Client.Domain;
|
using DodoSSH.Client.Domain;
|
||||||
using DodoSSH.Client.Import;
|
using DodoSSH.Client.Import;
|
||||||
@@ -996,6 +997,126 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
IsReadOnly: false),
|
IsReadOnly: false),
|
||||||
isLive: false);
|
isLive: false);
|
||||||
|
|
||||||
|
// ---- Last connected ----
|
||||||
|
//
|
||||||
|
// The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
|
||||||
|
// is the log read, and the two triggers below — the hosts screen coming back on screen and a session
|
||||||
|
// ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
|
||||||
|
/// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
|
||||||
|
/// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "just now")]
|
||||||
|
[InlineData(59, "just now")]
|
||||||
|
[InlineData(60, "1 min ago")]
|
||||||
|
[InlineData(150, "2 min ago")]
|
||||||
|
[InlineData(3599, "59 min ago")]
|
||||||
|
[InlineData(3600, "1 hr ago")]
|
||||||
|
[InlineData(7200, "2 hr ago")]
|
||||||
|
[InlineData(86399, "23 hr ago")]
|
||||||
|
[InlineData(86400, "1 day ago")]
|
||||||
|
[InlineData(172800, "2 days ago")]
|
||||||
|
public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
|
||||||
|
VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
|
||||||
|
/// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
|
||||||
|
/// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
|
||||||
|
/// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
|
||||||
|
/// level, which fired already.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
var host = vault.Hosts[0];
|
||||||
|
|
||||||
|
await vault.Session.ConnectionLog.CreateAsync(
|
||||||
|
vault.Session.ActiveVaultId,
|
||||||
|
new ConnectionLogSecret
|
||||||
|
{
|
||||||
|
HostLabel = host.Label,
|
||||||
|
Address = host.Address,
|
||||||
|
HostId = host.EntityId,
|
||||||
|
StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
|
||||||
|
DeviceName = "a workstation",
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => host.LastConnectedText.Length > 0,
|
||||||
|
"activating the hosts screen should have read the log");
|
||||||
|
|
||||||
|
host.LastConnectedText.ShouldBe("3 days ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A host the log has never named. Empty rather than a dash or the word "never" — see
|
||||||
|
/// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
|
||||||
|
/// whose log simply has not been read yet look identical from this row, and neither is a claim it can
|
||||||
|
/// make on its own.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
var host = vault.Hosts[0];
|
||||||
|
|
||||||
|
await vault.RefreshLastConnectedAsync(Token);
|
||||||
|
|
||||||
|
host.LastConnectedText.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Decision 4's second half: a host with a session open right now shows the green dot instead of an
|
||||||
|
/// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
|
||||||
|
/// and the older answer is the one likeliest to be misread as current.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
var host = vault.Hosts[0];
|
||||||
|
|
||||||
|
await vault.Session.ConnectionLog.CreateAsync(
|
||||||
|
vault.Session.ActiveVaultId,
|
||||||
|
new ConnectionLogSecret
|
||||||
|
{
|
||||||
|
HostLabel = host.Label,
|
||||||
|
Address = host.Address,
|
||||||
|
HostId = host.EntityId,
|
||||||
|
StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
|
||||||
|
DeviceName = "a workstation",
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
host.IsConnected.ShouldBeTrue();
|
||||||
|
|
||||||
|
await vault.RefreshLastConnectedAsync(Token);
|
||||||
|
|
||||||
|
host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other trigger — a session ending on its own — has no test here. It runs inside
|
||||||
|
// MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
|
||||||
|
// RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
|
||||||
|
// window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
|
||||||
|
// queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
|
||||||
|
// nothing about the one line this wave added, since the untestable half is wiring this wave did not
|
||||||
|
// write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
|
||||||
|
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
||||||
|
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||||
@@ -2774,8 +2895,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
await AddGroupAsync(vault, "platform");
|
await AddGroupAsync(vault, "platform");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
|
||||||
vault.EditGroupCommand.Execute(null);
|
|
||||||
vault.GroupEditorDefaultPort = 2222;
|
vault.GroupEditorDefaultPort = 2222;
|
||||||
|
|
||||||
await AddKeyAsync(vault, "deploy");
|
await AddKeyAsync(vault, "deploy");
|
||||||
@@ -4267,17 +4387,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
|
/// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
|
||||||
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
|
/// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
|
||||||
/// machines through the editor is thirty rounds of open, pick, save.
|
/// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
|
||||||
|
/// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
|
||||||
|
/// nothing sets it through a command — see that property's own remarks.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
|
/// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
|
||||||
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
|
/// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||||
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
|
/// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
|
||||||
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
|
|
||||||
/// read that property, and none of them should be aimed at a card that has left the screen. See
|
|
||||||
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -4290,13 +4409,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
|
|
||||||
var group = vault.Groups.Single().EntityId;
|
var group = vault.Groups.Single().EntityId;
|
||||||
var host = vault.Hosts.Single();
|
|
||||||
|
|
||||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
||||||
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
||||||
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
|
|
||||||
|
|
||||||
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
||||||
// tree flattened, so the host is still in it.
|
// tree flattened, so the host is still in it.
|
||||||
@@ -4310,22 +4427,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
||||||
|
|
||||||
// Opening the group is where it went, and the way to it.
|
// Opening the group is where it went, and the way to it.
|
||||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
vault.GroupFilter = vault.Groups.Single();
|
||||||
|
|
||||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||||
|
|
||||||
// And back out again, which is what the host's own editor is for now that the drop has one target.
|
// And back out again.
|
||||||
vault.OpenGroupCommand.Execute(null);
|
vault.GroupFilter = null;
|
||||||
|
|
||||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
|
vault.SelectedHost = vault.Hosts.Single();
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||||
|
choice => choice.EntityId is null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
||||||
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
||||||
|
|
||||||
// Coming out of a group is the direction that lands the host back on this level, so here the
|
|
||||||
// selection does survive the move.
|
|
||||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||||
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -4355,11 +4472,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
||||||
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
||||||
|
|
||||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
vault.GroupFilter = vault.Groups.Single();
|
||||||
|
|
||||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
||||||
|
|
||||||
vault.OpenGroupCommand.Execute(null);
|
vault.GroupFilter = null;
|
||||||
|
|
||||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
||||||
}
|
}
|
||||||
@@ -4392,8 +4509,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
||||||
|
|
||||||
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
||||||
vault.OpenGroupCommand.Execute(
|
vault.GroupFilter =
|
||||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||||
|
|
||||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||||
|
|
||||||
@@ -4427,8 +4544,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
||||||
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
||||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
|
/// papercut that comes free with a grid holding one level — so the editor opens on the group
|
||||||
/// is standing in, and the picker shows it before anything is saved.
|
/// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
|
||||||
|
/// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
|
||||||
|
/// that property's own remarks.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
||||||
@@ -4438,7 +4557,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
|
|
||||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
vault.GroupFilter = vault.Groups.Single();
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
|
|
||||||
@@ -4457,31 +4576,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
|
|
||||||
/// one is open would be a save nobody asked for, and one they could then not cancel.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
|
|
||||||
{
|
|
||||||
await UnlockedAsync();
|
|
||||||
var vault = shell.Vault!;
|
|
||||||
|
|
||||||
await AddHostAsync(vault, "prod-db");
|
|
||||||
await AddGroupAsync(vault, "production");
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts.Single();
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
vault.EditorLabel = "half-typed";
|
|
||||||
|
|
||||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
|
||||||
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
|
|
||||||
|
|
||||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
|
|
||||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
|
||||||
vault.Status.ShouldContain("editing");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
||||||
@@ -4505,8 +4599,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
await FileAsync(vault, "prod-db", "production");
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single();
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
var question = vault.PendingDeletion.ShouldNotBeNull();
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -4549,8 +4642,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
await FileAsync(vault, "prod-db", "production");
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single();
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
||||||
vault.DeletionTakesTheHostsToo = true;
|
vault.DeletionTakesTheHostsToo = true;
|
||||||
@@ -4580,17 +4672,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "staging");
|
await AddGroupAsync(vault, "staging");
|
||||||
await FileAsync(vault, "prod-db", "production");
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
|
||||||
|
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
|
||||||
vault.DeletionTakesTheHostsToo = true;
|
vault.DeletionTakesTheHostsToo = true;
|
||||||
vault.CancelDeleteCommand.Execute(null);
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
||||||
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
|
row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
|
||||||
|
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
||||||
}
|
}
|
||||||
@@ -4615,8 +4703,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.EditSelectedHostCommand.Execute(null);
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
vault.EditorLabel = "half-typed";
|
vault.EditorLabel = "half-typed";
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single();
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
||||||
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
||||||
@@ -4676,8 +4763,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
await FileAsync(vault, "prod-db", "production");
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single();
|
vault.EditGroupCommand.Execute(vault.Groups.Single());
|
||||||
vault.EditGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
|
||||||
|
|
||||||
@@ -4879,10 +4965,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
await SetGroupParentAsync(vault, "production", "estate");
|
await SetGroupParentAsync(vault, "production", "estate");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||||
|
|
||||||
vault.EditGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.GroupEditorParentChoices
|
vault.GroupEditorParentChoices
|
||||||
.Select(choice => choice.Label)
|
.Select(choice => choice.Label)
|
||||||
@@ -5355,24 +5439,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The desktop's drag, once more than one card is ticked.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Dragging one host onto a group card has always been <c>MoveHostToGroup</c>; a set dragged onto one has
|
/// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
|
||||||
/// to file all of it, because moving whichever card the pointer happened to be holding and leaving the
|
/// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
|
||||||
/// other five where they are is a gesture that quietly does a fraction of what it looks like it does. It
|
/// covers what a set files to now that the group picker is the only route. What survives here is the
|
||||||
/// is the picker's write with the picker skipped — see <c>ConfirmRegroupChosenHosts</c>, which shares it.
|
/// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
|
||||||
/// </para>
|
/// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
|
||||||
/// <para>
|
/// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
|
||||||
/// The refusal is the one <c>RefusesTheDrop</c> makes for a single card, made once for the set: a drop is
|
/// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
|
||||||
/// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
|
/// way a card once was.
|
||||||
/// and could not then cancel.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
|
public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
@@ -5385,25 +5465,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||||
|
|
||||||
var card = vault.Groups.Single(
|
|
||||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
|
|
||||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
vault.RegroupChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
|
||||||
vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
|
|
||||||
|
|
||||||
vault.CancelEditCommand.Execute(null);
|
vault.CancelEditCommand.Execute(null);
|
||||||
|
|
||||||
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
|
vault.RegroupChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
|
vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
|
||||||
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
|
|
||||||
|
var group = vault.Groups.Single(
|
||||||
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
|
||||||
|
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
|
||||||
|
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
|
||||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||||
|
|
||||||
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -5555,12 +5639,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.Transfers.Status.ShouldContain("password");
|
shell.Transfers.Status.ShouldContain("password");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
// ---- The terminal pin strip ----
|
||||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
||||||
|
/// the connection already open and the tab it opened already selected.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
|
||||||
{
|
{
|
||||||
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
|
var vault = await ReadyToConnectAsync();
|
||||||
// selection bounces back to the host on purpose. The command has to work off the heading it was
|
|
||||||
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = path;
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
return vault;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
shell.ShowsPinStrip.ShouldBeTrue();
|
||||||
|
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
|
||||||
|
// that happens to be selected on the hosts screen.
|
||||||
|
shell.ShowsPinStrip.ShouldBeFalse();
|
||||||
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
|
||||||
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
shell.ShowsPinStrip.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||||
|
|
||||||
|
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
|
||||||
|
|
||||||
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||||
|
|
||||||
|
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
|
||||||
|
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
||||||
|
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
||||||
|
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
||||||
|
/// entry independent of Requests.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClickingAPinChip_OpensFilesAtThatPath()
|
||||||
|
{
|
||||||
|
var vault = await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||||
|
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||||
|
shell.Transfers.RemotePath.ShouldBe("/var/www/app");
|
||||||
|
|
||||||
|
ssh.SftpRequests.ShouldHaveSingleItem();
|
||||||
|
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
||||||
|
{
|
||||||
|
// Both heads' only route into a group editor since v5: neither draws a group card to select one
|
||||||
|
// from any more, so the command has to work off the heading it was pressed on. Two groups exist here
|
||||||
|
// so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
@@ -5569,23 +5760,19 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await AddGroupAsync(vault, "production");
|
await AddGroupAsync(vault, "production");
|
||||||
await FileAsync(vault, "prod-db", "production");
|
await FileAsync(vault, "prod-db", "production");
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
|
||||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
|
||||||
|
|
||||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||||
|
|
||||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||||
|
|
||||||
vault.IsEditingGroup.ShouldBeTrue();
|
vault.IsEditingGroup.ShouldBeTrue();
|
||||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
|
/// The heading hands its group straight to the editor rather than selecting a card first, and this is
|
||||||
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
|
/// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
|
||||||
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
|
/// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
|
||||||
/// nothing on screen to say where it had gone, or how to get it back.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
||||||
@@ -5920,6 +6107,226 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
|
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Pinned paths (QUICK ACCESS) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
|
||||||
|
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
|
||||||
|
{
|
||||||
|
// Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
|
||||||
|
// to preserve it as faithfully as the domain type it is about to become.
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
|
||||||
|
{
|
||||||
|
vault.EditorNewPin = path;
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Host(vault, "prod-db").Host.PinnedPaths
|
||||||
|
.ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/etc/nginx";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.RemoveEditorPinCommand.Execute("/var/www/app");
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellingAHostEdit_DropsThePinningEntirely()
|
||||||
|
{
|
||||||
|
// Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
|
||||||
|
// than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
|
||||||
|
// Tag holds a tag's own name to.
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.CancelEditCommand.Execute(null);
|
||||||
|
|
||||||
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ANewHostsEditor_OpensWithNoPinsStaged()
|
||||||
|
{
|
||||||
|
// NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
|
||||||
|
// reason every other editor field is reset there.
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABlankPin_IsRefusedAtTheAddBox()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin = " ";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||||
|
vault.Status.ShouldContain("blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldHaveSingleItem();
|
||||||
|
vault.Status.ShouldContain("already pinned");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
|
||||||
|
{
|
||||||
|
// The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
|
||||||
|
// that waits for SAVE throws away every other field typed on the form since, where one at the box
|
||||||
|
// that caused it costs nothing else.
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
||||||
|
vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
vault.SelectedHost = Host(vault, "prod-db");
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
|
||||||
|
{
|
||||||
|
vault.EditorNewPin = $"/pin/{i}";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
|
||||||
|
|
||||||
|
vault.EditorNewPin = "/one/too/many";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorPinnedPaths.Count.ShouldBe(
|
||||||
|
HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
|
||||||
|
vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
|
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
|
||||||
{
|
{
|
||||||
@@ -6014,10 +6421,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
string? username = null,
|
string? username = null,
|
||||||
string? key = null)
|
string? key = null)
|
||||||
{
|
{
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||||
|
|
||||||
vault.EditGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.GroupEditorDefaultPort = port;
|
vault.GroupEditorDefaultPort = port;
|
||||||
vault.GroupEditorDefaultUsername = username ?? string.Empty;
|
vault.GroupEditorDefaultUsername = username ?? string.Empty;
|
||||||
@@ -6033,10 +6438,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
|
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
|
||||||
{
|
{
|
||||||
vault.SelectedGroup = vault.Groups.Single(
|
vault.EditGroupCommand.Execute(vault.Groups.Single(
|
||||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
||||||
|
|
||||||
vault.EditGroupCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||||
|
|||||||
Reference in New Issue
Block a user