Public Access
Give the application a settings area built from what really exists
This commit is contained in:
@@ -1261,6 +1261,57 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5c ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Settings mode's own furniture: a page title, a tracked section label, a card group and the rows inside
|
||||
it. Named apart from Border.section and TextBlock.sectionlabel above rather than reusing them — those
|
||||
are the hosts drawer's 320-pixel column, at that column's own padding and heading size, and the
|
||||
settings pages are a 1100-pixel one at the numbers Settings-General.dc.html and its three siblings all
|
||||
state identically: 12-radius cards on Raised with a 1px Border inset, 20/24 row padding, a 1px
|
||||
BorderSubtle divider between rows and none under the last one.
|
||||
-->
|
||||
<Style Selector="TextBlock.settingstitle">
|
||||
<Setter Property="FontSize" Value="33" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="-0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingssection">
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="Margin" Value="0,32,0,12" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingscard">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingsrow">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="24,20" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingsrow.last">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingsrowtitle">
|
||||
<Setter Property="FontSize" Value="14.5" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="-0.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingsrowcaption">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="LineHeight" Value="19.4" />
|
||||
<Setter Property="MaxWidth" Value="640" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
||||
says what each entry means; importing is a separate press on a set somebody has looked at.
|
||||
|
||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
||||
something almost nobody is looking at.
|
||||
v5c-3: restyled into Import.dc.html's own table over SettingsView's content column — SettingsNav stays
|
||||
lit on Preferences while this is up, and the titlebar says "Back to preferences"; see
|
||||
MainWindowViewModel.IsImportOpen. No longer reachable from the nav rail, exactly as before: it is a task
|
||||
done once or once a year, reached from the Preferences page's own "OPEN IMPORTER" row.
|
||||
|
||||
── ◆ THE ONE TICK THAT READS PRIVATE KEYS ─────────────────────────────────────────────────────────────
|
||||
Below the list, off, and drawn only where the scan actually found an IdentityFile. It is the only control
|
||||
@@ -26,144 +27,212 @@
|
||||
What comes back afterwards is the report under the list: one line per key file, saying which were stored,
|
||||
which are protected by a passphrase this cannot know, and which were not there at all. That is reported
|
||||
rather than previewed for the same reason — previewing would mean reading them.
|
||||
|
||||
── ◆ WHAT THIS MEANS ──────────────────────────────────────────────────────────────────────────────────
|
||||
One chip per row rather than the old separate AUTHENTICATION/STATE columns, mapped off the two facts a
|
||||
row actually carries: ImportRowViewModel.AlreadyPresent and HasWarnings. A skipped Host pattern (a
|
||||
wildcard block) never becomes a row at all — see SshConfigImport.SkippedPatterns — so there is no third,
|
||||
"skipped" state to draw here; a warned row is the amber case instead, and it wins over "already here"
|
||||
because the warning is the more actionable of the two facts. See ImportRowViewModel.Meaning.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
<UserControl.Styles>
|
||||
<!-- The row's own hover fill, matching the design's per-row style-hover — Track, the same as every other table this application draws. -->
|
||||
<Style Selector="Border.importrow:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new">
|
||||
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists">
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn">
|
||||
<Setter Property="Background" Value="{StaticResource WarnWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="14,12,14,0"
|
||||
<!-- ============ HEADER ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="10">
|
||||
<TextBlock Classes="settingstitle" Text="Import SSH config" />
|
||||
<TextBlock Classes="mono" Text="{Binding HeaderStatus}" FontSize="12"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="40" VerticalAlignment="Top" Content="SCAN AGAIN"
|
||||
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file again and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,10,0,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
||||
comparing the two needs to be told before they conclude the parser lost something.
|
||||
What could not be honoured, at document level. Every one of these is a way the import is quieter than
|
||||
the file — an ignored Match block, a dropped ProxyCommand — and a person comparing the two needs to be
|
||||
told before they conclude the parser lost something.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
|
||||
<Border Grid.Row="2" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11"
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
|
||||
<!-- ============ THE TABLE ============ -->
|
||||
<Border Grid.Row="3" Margin="0,18,0,0" CornerRadius="12" Background="{StaticResource Pane}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
||||
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*">
|
||||
<!--
|
||||
The header tick-all box. A plain Button rather than a CheckBox — ImportRowViewModel's own
|
||||
ToggleAllCommand is "tick everything, or untick everything" in one press, which is a command
|
||||
rather than a two-way bound bool, and a CheckBox has no Command of its own to hang that on.
|
||||
-->
|
||||
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
|
||||
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
|
||||
ToolTip.Tip="Tick or untick every row">
|
||||
<Panel Width="18" Height="18">
|
||||
<Border CornerRadius="5" Background="{StaticResource Accent}" IsVisible="{Binding AllTicked}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
|
||||
IsVisible="{Binding !AllTicked}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="ALIAS" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="14,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="HOSTNAME" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<Border Classes="importrow" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*" Margin="12" MinHeight="20">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding Authentication}" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Hostname}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding User}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Port}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Classes.new="{Binding IsMeaningNew}" Classes.exists="{Binding IsMeaningExisting}"
|
||||
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ FOOTER ============ -->
|
||||
<StackPanel Grid.Row="4" Margin="0,16,0,0" Spacing="16" IsVisible="{Binding HasRows}">
|
||||
|
||||
<!--
|
||||
Said before the button, not after. Whether the key material comes with the host is the difference
|
||||
between a bookmark that connects and one that asks for a password, and somebody who is not told
|
||||
will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap" IsVisible="{Binding !ImportsKeys}"
|
||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
|
||||
|
||||
<!--
|
||||
◆ THE OPT-IN CARD. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read
|
||||
~/.ssh on a screen where it would read nothing is a control that teaches people to ignore it.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Raised}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" Padding="18,16" IsVisible="{Binding HasKeyFiles}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<CheckBox VerticalAlignment="Top" Margin="0,3,0,0" IsChecked="{Binding ImportsKeys}" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Also store the private keys these entries point at" FontSize="13.5"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="{Binding KeyMaterialIntro}" />
|
||||
<!--
|
||||
The warning sentence appears only when the tick is on, and it is the one place this application
|
||||
says out loud that it is about to open private keys.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding ImportsKeys}"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ What became of each key file, after the fact. Capped with a scroll viewer because a config with
|
||||
thirty keyed hosts would otherwise push IMPORT off the window — the one control this screen must
|
||||
never lose.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasKeyReport}" Padding="12,10" CornerRadius="10"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1">
|
||||
<ScrollViewer MaxHeight="120">
|
||||
<ItemsControl ItemsSource="{Binding KeyReport}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- "N of M entries selected · saving to {vault}", Cancel, Import N hosts. -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
|
||||
Text="{Binding SelectionSummary}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences. Nothing is stored." />
|
||||
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<StackPanel Margin="14,0">
|
||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="10.5" Margin="34,0,0,8"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
IsVisible="{Binding HasWarnings}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<StackPanel Spacing="8">
|
||||
<!--
|
||||
Said before the button, not after. Whether the key material comes with the host is the difference
|
||||
between a bookmark that connects and one that asks for a password, and somebody who is not told
|
||||
will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !ImportsKeys}"
|
||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
|
||||
|
||||
<!--
|
||||
◆ THE TICK. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read ~/.ssh
|
||||
on a screen where it would read nothing is a control that teaches people to ignore it.
|
||||
|
||||
The warning sentence appears only when it is on, and it is the one place this application says out
|
||||
loud that it is about to open private keys. It names the directory rather than saying "your keys",
|
||||
because what somebody is agreeing to is a read of that directory.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasKeyFiles}">
|
||||
<CheckBox IsChecked="{Binding ImportsKeys}">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Also import the private keys these hosts point at" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding ImportsKeys}" Foreground="{StaticResource WarnText}"
|
||||
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ What became of each key file, after the fact. Below the button because it is the answer rather
|
||||
than the offer, and capped with a scroll viewer because a config with thirty keyed hosts would
|
||||
otherwise push IMPORT off the window — the one control this screen must never lose.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasKeyReport}" Padding="10,8" CornerRadius="4"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1">
|
||||
<ScrollViewer MaxHeight="120">
|
||||
<ItemsControl ItemsSource="{Binding KeyReport}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
@@ -10,7 +9,11 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
|
||||
/// A task rather than a destination, which is why it is reached from Preferences rather than from the nav
|
||||
/// rail. v5c-3 moved it inside settings mode as an overlay over the Preferences page — it no longer has a
|
||||
/// keyboard target of its own to hand back: <c>MainWindow.axaml.cs</c>'s <c>KeyboardHome</c> falls back to
|
||||
/// the window for settings mode as a whole, the same way it already does for the account and logs screens,
|
||||
/// so a <c>KeyboardTarget</c> property here would be dead code nothing reads.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportScreen : UserControl
|
||||
{
|
||||
@@ -24,9 +27,6 @@ internal sealed partial class ImportScreen : UserControl
|
||||
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
||||
}
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
internal IInputElement KeyboardTarget => this;
|
||||
|
||||
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ImportViewModel import)
|
||||
|
||||
@@ -91,12 +91,13 @@
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<views:TitleBar Grid.Row="0" />
|
||||
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- The unlocked application. -->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}"
|
||||
IsEnabled="{Binding !IsSettingsMode}">
|
||||
|
||||
<!--
|
||||
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
|
||||
@@ -176,13 +177,19 @@
|
||||
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
|
||||
calls it now; see NavRail.axaml.
|
||||
|
||||
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
|
||||
Deliberately not given the full session shell above: a bucket is not a host, has no terminal tab
|
||||
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
|
||||
could show. Wave B's own scope is the terminal and SFTP surfaces; S3 stays the plain screen it
|
||||
already was, and this is the recorded reason rather than an oversight.
|
||||
could show — so no tab row, no host header, no status bar and no sidebar. Wave B's own scope
|
||||
was the terminal and SFTP surfaces and left this plain; v5c-3 gives it the session shell's own
|
||||
LOOK without its machinery — a 26px padded column and the same bordered, radius-12 container —
|
||||
since design-notes/v5c-fidelity-notes.md calls for the fidelity pass here too, and there is
|
||||
nothing this screen does that needs the parts still withheld.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsBucketsScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
ClipToBounds="True">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
@@ -217,30 +224,21 @@
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULTS ============ -->
|
||||
<!--
|
||||
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the vaults view model, and both on one element would resolve
|
||||
IsVaultsScreen against a type that does not have it.
|
||||
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
|
||||
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
|
||||
redirects it into EnterSettings before Screen is ever set — so IsVaultsScreen being true also
|
||||
means IsSettingsMode is true, and this whole page-area Panel (bound to !IsSettingsMode, above)
|
||||
is already hidden behind SettingsView by then. A Panel here would never have been drawn.
|
||||
|
||||
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
||||
is one vault's contents and is what the keychain and hosts screens above draw.
|
||||
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
|
||||
importer inside the settings chrome — SettingsNav lit on Preferences, a "Back to preferences"
|
||||
titlebar rather than the ordinary rail and titlebar this Panel belongs to — so ImportScreen now
|
||||
lives in SettingsView.axaml behind MainWindowViewModel.IsImportOpen, beside the seven settings
|
||||
pages rather than among the screens here. See ShowScreen's own translation of
|
||||
ShellScreen.Import for what still reaches it: the Preferences page's own "OPEN IMPORTER" row,
|
||||
and anything else that names the same destination.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultsScreen}">
|
||||
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
@@ -319,6 +317,22 @@
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
v5c: the settings mode, which replaces the titlebar above and everything in this Panel below it with
|
||||
its own — see SettingsView.axaml and TitleBar's own IsVisible next to it in this file. Later in the
|
||||
Panel than the unlocked Grid, on the same reasoning the setup Border below gives for its own
|
||||
position: Avalonia z-order draws a later sibling over an earlier one, and SettingsView's own
|
||||
Background is opaque, so it covers the rail and the page area completely rather than needing them
|
||||
hidden out from under it. The unlocked Grid keeps IsEnabled bound to the negation above so a Tab
|
||||
press cannot walk keyboard focus into a control this mode has covered.
|
||||
|
||||
Earlier than the palette and the host-key decision, both further down this same Panel, so either can
|
||||
still interrupt settings mode exactly as it already interrupts every other screen — a host key that
|
||||
changes while the sync loop is running does not stop being worth answering because the window
|
||||
happens to be showing Settings.
|
||||
-->
|
||||
<views:SettingsView IsVisible="{Binding IsSettingsMode}" />
|
||||
|
||||
<!--
|
||||
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
||||
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
|
||||
@@ -476,11 +490,18 @@
|
||||
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
||||
announcing an update that does not exist.
|
||||
-->
|
||||
<views:UpdateBanner Grid.Row="2"
|
||||
DataContext="{Binding Updates}"
|
||||
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
|
||||
<!--
|
||||
v5c: wrapped rather than given a third condition of its own — UpdateBanner sets its own DataContext to
|
||||
UpdateViewModel on this same element, so a binding against IsSettingsMode has to live on an ancestor
|
||||
whose context is still the shell. The design's settings titlebar has no room for this strip; hiding it
|
||||
here rather than teaching the banner about a mode it otherwise has no reason to know about.
|
||||
-->
|
||||
<Panel Grid.Row="2" IsVisible="{Binding !IsSettingsMode}">
|
||||
<views:UpdateBanner DataContext="{Binding Updates}"
|
||||
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
|
||||
</Panel>
|
||||
|
||||
<views:StatusBar Grid.Row="3" />
|
||||
<views:StatusBar Grid.Row="3" IsVisible="{Binding !IsSettingsMode}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -111,11 +111,13 @@ internal sealed partial class MainWindow : Window
|
||||
/// </remarks>
|
||||
private IInputElement KeyboardHome => shell switch
|
||||
{
|
||||
// v5c: settings mode has no keyboard-focused control of its own yet — its pages are read-only prose
|
||||
// and buttons, the same shape the account and logs screens already fall back to the window for.
|
||||
{ IsSettingsMode: true } => this,
|
||||
{ IsTerminalShowing: true } => Terminal,
|
||||
{ Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||
_ => this,
|
||||
@@ -213,6 +215,25 @@ internal sealed partial class MainWindow : Window
|
||||
{
|
||||
Palette.HandleKey(e);
|
||||
}
|
||||
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
||||
// already follows one branch up. Checked after the palette rather than before it: the two states
|
||||
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
||||
// settings does not open the palette — but an Escape while both were somehow true should close the
|
||||
// thing drawn on top, which is the palette.
|
||||
//
|
||||
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
||||
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
||||
// "Back to application" while IsImportOpen is true.
|
||||
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
||||
{
|
||||
viewModel.CloseImportCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
||||
{
|
||||
viewModel.LeaveSettingsCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
@@ -249,12 +249,15 @@
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
Settings and Preferences both land on the one screen this application actually has — the
|
||||
mock's own Settings area is a family of screens that do not exist yet; see the decided
|
||||
deviation in design-notes/v5b-fidelity-notes.md. Two doors to one room rather than one door
|
||||
removed, so neither word on the mock goes unanswered.
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
|
||||
mode's own default landing page; Vaults and Preferences open directly on the page they name,
|
||||
which is also what anything that used to navigate to ShellScreen.Vaults or
|
||||
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
||||
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
||||
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource Text}" />
|
||||
@@ -282,8 +285,8 @@
|
||||
|
||||
<!--
|
||||
The existing sign-out flow, with its own confirm card — see
|
||||
MainWindowViewModel.SignOutFromPopover for why this goes through Preferences rather than
|
||||
calling SignOutCommand directly from wherever the popover happened to be opened.
|
||||
MainWindowViewModel.SignOutFromPopover for why this goes through the Account settings page
|
||||
rather than calling SignOutCommand directly from wherever the popover happened to be opened.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
|
||||
@@ -48,30 +48,42 @@ internal sealed partial class NavRail : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Settings and Preferences both land here — see the remark in the markup.</summary>
|
||||
/// <summary>Opens settings mode on its default landing page — see the remark in the markup.</summary>
|
||||
private void OnPopoverSettingsPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosePopover();
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.General);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens settings mode on its Preferences page.</summary>
|
||||
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosePopover();
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens settings mode on its Vaults page.</summary>
|
||||
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosePopover();
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
||||
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||
/// </summary>
|
||||
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Preferences.
|
||||
|
||||
The design's rail has six sections and its TERMINAL panel has six settings. One of those six is now
|
||||
real: text size. It needed all three of the things this comment used to record as missing — somewhere
|
||||
to keep a preference (settings.json beside the cache, outside it deliberately, so it can be read on a
|
||||
launch that never unlocks anything), a frame carrying a terminal option (TerminalServerOpcode.FontSize),
|
||||
and a way for the page's own chords to reach the host that owns the value
|
||||
(TerminalClientOpcode.FontSizeStep). The rule that kept it off this screen until then still stands and
|
||||
is why the storage came first: a stepper that reset on every launch is worse than no stepper.
|
||||
|
||||
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
|
||||
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
|
||||
The two commands behind it were already in the shell; they were merely homeless, wedged into the old
|
||||
account bar because there was nowhere else to put them.
|
||||
|
||||
Everything else is listed as absent rather than omitted, because a preferences screen that is silent
|
||||
about the settings it has not got reads as a product with six preferences.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
|
||||
|
||||
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanRegisterDevice}" />
|
||||
<!--
|
||||
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one: a
|
||||
machine with no TPM and a machine that is already registered are both "cannot register", and only
|
||||
the second has anything to take back.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="STOP UNLOCKING HERE"
|
||||
Command="{Binding ForgetDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanForgetDevice}"
|
||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||
</Grid>
|
||||
|
||||
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<!--
|
||||
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
|
||||
client can replace itself, and the moment that is true the question of where a replacement comes
|
||||
from stops being theoretical. The answer is the security content of this section rather than a
|
||||
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
|
||||
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
|
||||
</StackPanel>
|
||||
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
|
||||
Command="{Binding Updates.CheckNowCommand}"
|
||||
IsEnabled="{Binding Updates.CanCheckNow}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
|
||||
IsChecked="{Binding Updates.IsAutomatic}"
|
||||
IsEnabled="{Binding Updates.IsSupported}" />
|
||||
</Grid>
|
||||
|
||||
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
|
||||
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
|
||||
Value="{Binding Updates.DownloadPercent}"
|
||||
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
|
||||
IsVisible="{Binding Updates.IsDownloading}" />
|
||||
|
||||
<!--
|
||||
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
|
||||
this is where the warning can be as long as it needs to be — and it needs to be, because this
|
||||
application has spent a lot of words teaching that locking keeps shells running.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
|
||||
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
|
||||
Command="{Binding Updates.RestartNowCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
Text="{Binding Updates.Status}"
|
||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
|
||||
why rather than shown three controls that cannot do anything.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
Text="This copy of DodoSSH cannot replace itself, so none of the above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<!--
|
||||
Here as well as on the chord, and not because the chord is in doubt. Ctrl+plus can only be heard
|
||||
while a terminal has focus, since that is where the keyboard is being read — so somebody who has
|
||||
not opened one yet, or who has just made the text too small to find anything in, has nowhere else
|
||||
to look. This is that place, and it names the chord so the screen teaches it rather than replacing
|
||||
it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
|
||||
<Button Classes="ghost" Content="A−" Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}"
|
||||
ToolTip.Tip="Smaller · Ctrl+minus" />
|
||||
<TextBlock Classes="mono" FontSize="13" MinWidth="26" VerticalAlignment="Center"
|
||||
TextAlignment="Center" Foreground="{StaticResource Text}"
|
||||
Text="{Binding TerminalFontSize}" />
|
||||
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}"
|
||||
ToolTip.Tip="Larger · Ctrl+plus" />
|
||||
<Button Classes="ghost" Content="RESET" Command="{Binding ResetTerminalFontCommand}"
|
||||
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
|
||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="12" Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="13"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
button that actually does it and two sign-out buttons on one screen is one too many.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
|
||||
Command="{Binding SignOutCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding !IsConfirmingSignOut}" />
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
|
||||
IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="14" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="12" Margin="0,8,0,0"
|
||||
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
|
||||
|
||||
<ItemsControl Margin="0,12,0,0">
|
||||
<ItemsControl.Styles>
|
||||
<Style Selector="TextBlock.gap">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Margin" Value="0,0,0,7" />
|
||||
</Style>
|
||||
</ItemsControl.Styles>
|
||||
<TextBlock Classes="gap"
|
||||
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. The text size above is the one that is not, and it is the path the others would follow if they were worth a row here." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Changing channel from in here — there is a nightly as well as a release, but which one a copy follows is fixed when it is built, so moving between them means installing the other one." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,9 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Preferences: what this build can actually change, and a list of what it cannot.</summary>
|
||||
internal sealed partial class PreferencesScreen : UserControl
|
||||
{
|
||||
public PreferencesScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsAccountPage"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: Account, against Settings-Account.dc.html.
|
||||
|
||||
The profile card is read-only, unlike the design's own — there is no "Edit profile" row here because
|
||||
there is no endpoint to send an edit to. Name, email and avatar all come from MeResponse and nothing in
|
||||
this client can change any of the three.
|
||||
|
||||
The SIGN-IN card keeps one row rather than the design's two. The issuer sentence is real:
|
||||
MainWindowViewModel.Issuer is MeResponse.Issuer, cached the same turn AccountName and Email are and
|
||||
surfaced here for the first time — see the remark on that property. "Master passphrase" and its "Change
|
||||
passphrase" button are refused outright: there is no change-passphrase flow, only the one enrollment-time
|
||||
choice, and a button that opened nothing would be worse than no button.
|
||||
|
||||
DEVICES and "Sign out everywhere" are both refused. There is no list-devices endpoint and no
|
||||
sign-out-everywhere endpoint; the one honest device fact this client has — this machine's own Windows
|
||||
Hello registration — is drawn on the Security page, not duplicated here as a one-row list.
|
||||
|
||||
SignOutCard is the same control the old PreferencesScreen used, restyled into this page's card idiom
|
||||
rather than rewritten — see that control's own remark on why it is a bare StackPanel and not a card of
|
||||
its own, and why one control serves both this screen and the unlock screen's copy of it.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock Classes="settingstitle" Text="Account" />
|
||||
|
||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="24,22">
|
||||
<StackPanel Orientation="Horizontal" Spacing="18">
|
||||
<Border Width="64" Height="64" CornerRadius="60" Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="20" LetterSpacing="0.5"
|
||||
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding AccountName}" FontSize="20" FontWeight="Bold" LetterSpacing="-0.25"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Email}"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="SIGN-IN" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow last">
|
||||
<StackPanel Spacing="6" MaxWidth="640">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Single sign-on" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Signing in proves who you are — it never decrypts a vault." />
|
||||
<TextBlock Classes="mono" FontSize="12" Margin="0,4,0,0" Foreground="{StaticResource Live}"
|
||||
Text="{Binding Issuer, StringFormat='You sign in through {0}.'}"
|
||||
IsVisible="{Binding Issuer, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="THIS MACHINE" />
|
||||
|
||||
<Border Classes="settingscard" Margin="0,12,0,40">
|
||||
<Panel>
|
||||
<Border Classes="settingsrow last" IsVisible="{Binding !IsConfirmingSignOut}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Sign out of this machine" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="danger" Height="38" Content="SIGN OUT"
|
||||
Command="{Binding SignOutCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Padding="24,20" IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's Account page: the read-only profile, the sign-in fact, and signing out.</summary>
|
||||
internal sealed partial class SettingsAccountPage : UserControl
|
||||
{
|
||||
public SettingsAccountPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsGeneralPage"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: General, against Settings-General.dc.html.
|
||||
|
||||
The design's page has three cards — STARTUP, APPEARANCE, UPDATES — and this one has one. STARTUP
|
||||
(launch at login, reopen tabs) and APPEARANCE (theme, language) are refused outright: nothing tracks
|
||||
whether Windows starts this application, nothing remembers a tab list across a launch, only the one dark
|
||||
theme exists, and there is no i18n anywhere in the client. Building either card would be two rows of
|
||||
controls that toggle a field nothing reads. See design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
UPDATES is the whole page's real content, moved here from the old PreferencesScreen.axaml verbatim —
|
||||
same UpdateViewModel, same states, same warnings, restyled into this page's row idiom. Its own
|
||||
update-channel switcher is refused the same way STARTUP and APPEARANCE are: which channel a copy follows
|
||||
is fixed when it is built, so this screen cannot offer to switch it.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock Classes="settingstitle" Text="General" />
|
||||
|
||||
<TextBlock Classes="settingssection" Text="UPDATES" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Version" />
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" Text="{Binding Updates.CurrentVersion}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CHECK NOW"
|
||||
Command="{Binding Updates.CheckNowCommand}"
|
||||
IsEnabled="{Binding Updates.CanCheckNow}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
||||
IsChecked="{Binding Updates.IsAutomatic}"
|
||||
IsEnabled="{Binding Updates.IsSupported}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsDownloading}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Downloading…" />
|
||||
<ProgressBar Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Updates.DownloadPercent}"
|
||||
Foreground="{StaticResource Accent}" Background="{StaticResource Chip}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsReady}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Updates.ReadyHeadline}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="settingsrowcaption" Text="{Binding Updates.RestartWarning}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="RESTART NOW"
|
||||
Command="{Binding Updates.RestartNowCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow last">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Builds come from the project's own release page, and never from the server you sign in to. Whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
|
||||
<TextBlock Classes="settingsrowcaption" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Updates.Status}"
|
||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel Margin="24,20" Spacing="10">
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="These are on the design and have nothing behind them here. They are listed rather than left out, so what this page does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Update channel — there is a nightly as well as a release, but which one a copy follows is fixed when it is built, so moving between them means installing the other one." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Launch at login — nothing registers this application with Windows' own startup list." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Reopen tabs from last session — nothing remembers which tabs were open across a launch, and every connection would need to authenticate again regardless." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Theme and language — only the one dark theme exists, and there is no translation anywhere in this client." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's General page: the Updates card, and the refused items as an essay.</summary>
|
||||
internal sealed partial class SettingsGeneralPage : UserControl
|
||||
{
|
||||
public SettingsGeneralPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsGroupsPage"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
x:Name="Root">
|
||||
|
||||
<!--
|
||||
v5c-2: Groups, against Settings-Groups.dc.html — a real management page over VaultViewModel's own group
|
||||
commands (Groups, NewGroup, EditGroup, DeleteGroup), given its own row here rather than only living inside
|
||||
the hosts board's sidebar. DataContext.Vault (VaultViewModel) is nullable — locked, or the settings mode
|
||||
reached before an unlock — so every binding below is {Binding Vault.*}, and the empty-vault caption covers
|
||||
the null case the same way the vaults page's own does.
|
||||
|
||||
── THE INTRO SENTENCE IS NOT THE MOCK'S ────────────────────────────────────────────────────────────────
|
||||
The design says "the order here is the order there" and draws drag handles to back it up. Neither is true:
|
||||
HostGroupRowViewModel — see VaultViewModel.RebuildGroups — orders groups by the vault new items go into
|
||||
first, then by vault name, then by label; there is no manual order to drag into and nothing this page
|
||||
could do would make one real. The sentence below says what is actually true instead, and there are no
|
||||
drag handles.
|
||||
|
||||
── EDIT AND DELETE ──────────────────────────────────────────────────────────────────────────────────────
|
||||
EditGroupCommand and DeleteGroupCommand already take the row as their argument — see
|
||||
VaultViewModel.EditGroup and VaultViewModel.EditGroupFromHeading's own remark on why — so this card's
|
||||
buttons bind straight to them with CommandParameter="{Binding}"; no wrapper commands were needed the way
|
||||
the tags page needs one. Editing opens the same drawer form the hosts board's own GROUPS section does
|
||||
(HostDrawer.axaml's IsEditingGroup panel); it is not reproduced here as a second form, on the reasoning
|
||||
the vaults page gives for reusing VaultsViewModel's commands rather than rebuilding them: one set of
|
||||
fields, one place they can drift from what SaveGroupCommand actually writes.
|
||||
|
||||
── THE "NO GROUP" FOOTER ────────────────────────────────────────────────────────────────────────────────
|
||||
Real, all three facts: VaultViewModel.UngroupedHostCount counts hosts whose group has gone or was never
|
||||
set, "always listed first" is true of both FlattenIntoSections call sites (RebuildSidebarRows passes
|
||||
ungroupedFirst: false — it is drawn last there — but the desktop's own board, RebuildHostSections, passes
|
||||
true), and it genuinely cannot be renamed or deleted: there is no HostGroupSecret behind it for either
|
||||
command to act on.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<TextBlock Classes="settingstitle" Text="Groups" />
|
||||
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Vault.Groups.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW GROUP"
|
||||
Command="{Binding Vault.NewGroupCommand}"
|
||||
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
|
||||
Text="Groups order by which vault new items go into, then by vault, then by label — there is no manual order to set." />
|
||||
|
||||
<!-- The group editor, in place: HostDrawer's own IsEditingGroup form, restyled into this page's card
|
||||
idiom rather than duplicated. Opened by + NEW GROUP above or an EDIT icon below. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vault.IsEditingGroup}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Vault.DrawerTitle}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="A heading for the hosts board, and the defaults every host under it inherits when it says nothing itself." />
|
||||
<TextBox PlaceholderText="group name" Text="{Binding Vault.GroupEditorLabel}" />
|
||||
<StackPanel Spacing="4" IsVisible="{Binding Vault.ShowsGroupEditorVaultChoice}">
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorVaultChoices}"
|
||||
SelectedItem="{Binding Vault.GroupEditorSelectedVault}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorParentChoices}"
|
||||
SelectedItem="{Binding Vault.GroupEditorSelectedParent}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1"
|
||||
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
|
||||
PlaceholderText="default port" />
|
||||
<TextBox Grid.Column="2" Text="{Binding Vault.GroupEditorDefaultUsername}"
|
||||
PlaceholderText="default username" />
|
||||
</Grid>
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="{Binding Vault.GroupSaveLabel}"
|
||||
Command="{Binding Vault.SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vault.CancelGroupEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- The delete confirmation, in place. The same control the hosts board's GROUPS section and the
|
||||
vaults page both reuse. -->
|
||||
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Margin="0,24,0,40" Spacing="10">
|
||||
<ItemsControl ItemsSource="{Binding Vault.Groups}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="10" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
||||
<Border Classes="settingscard" Padding="20,16">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<Border Grid.Column="0" Width="38" Height="38" CornerRadius="10"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="14"
|
||||
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="5" Margin="14,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Description}" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="2" Classes="chip" Margin="0,0,14,0" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditGroupCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Rename this group or change what its hosts inherit.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteGroupCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Delete this group. The hosts filed under it are asked about separately.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- The "No group" footer row. Always drawn, even with zero groups, because it is true whether or
|
||||
not any group exists — it is just as often the only row on this page. -->
|
||||
<Border Classes="settingscard" Padding="20,16" Opacity="0.7"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="17" Text=""
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="No group" FontSize="14" FontWeight="SemiBold" LetterSpacing="-0.2"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding Vault.UngroupedHostCount, StringFormat='{}{0} hosts · always listed first · cannot be renamed or deleted'}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Unlock your keychain to see and manage your groups." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Groups: every group, and the hosts filed under each — see the remark at the top of the markup.</summary>
|
||||
internal sealed partial class SettingsGroupsPage : UserControl
|
||||
{
|
||||
public SettingsGroupsPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsNav"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: settings mode's own 340px rail — SettingsNav.dc.html. Two sections rather than the design's three:
|
||||
the design's ORGANISATION section and its one row (Organisation settings) are refused outright — no
|
||||
organisation entity exists anywhere in this product; a Team is the membership list behind a shared vault,
|
||||
and there is exactly one tenant per deployment. See design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
v5c-2: Groups and Tags joined CUSTOMIZE, after Preferences — the design's own order (Security,
|
||||
Preferences, Groups, Tags). Each opens a real management page over the existing group and tag commands;
|
||||
see SettingsGroupsPage.axaml and SettingsTagsPage.axaml.
|
||||
|
||||
Button.nav and its two TextBlock helpers are the exact rows NavRail.axaml already draws at 255px — height
|
||||
35, radius 8, a 19px glyph and a 10.5 semibold label, accent fill with white text when active. Reused
|
||||
rather than restyled: this design states the same three numbers for this rail's own rows, and a second
|
||||
copy of one style is how the two rails drift apart the day only one of them is touched again.
|
||||
-->
|
||||
|
||||
<Border Width="340" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<DockPanel LastChildFill="False" Margin="18,24">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="8">
|
||||
|
||||
<TextBlock Text="SETTINGS" Margin="11,0,0,6" FontSize="10" FontWeight="SemiBold"
|
||||
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGeneralPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.General}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="General" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsVaultsPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Vaults}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Vaults" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsAccountPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Account}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Account" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<TextBlock Text="CUSTOMIZE" Margin="11,14,0,6" FontSize="10" FontWeight="SemiBold"
|
||||
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsSecurityPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Security}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Security" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsPreferencesPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Preferences}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Preferences" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGroupsPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Groups}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Groups" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSettingsTagsPage}"
|
||||
Command="{Binding EnterSettingsCommand}"
|
||||
CommandParameter="{x:Static vm:SettingsPage.Tags}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Tags" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Wired to the same command the rail's own user popover Logout row calls — see
|
||||
MainWindowViewModel.SignOutFromPopover, which now lands on the Account page rather than on the old
|
||||
bare Preferences screen, so there is exactly one place the confirmation card is drawn.
|
||||
-->
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav"
|
||||
Command="{Binding SignOutFromPopoverCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" FontSize="17" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Logout" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's own 340px rail — General/Vaults/Account, Security/Preferences, and Logout.</summary>
|
||||
internal sealed partial class SettingsNav : UserControl
|
||||
{
|
||||
public SettingsNav() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsPreferencesPage"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: Preferences, against Settings-Preferences.dc.html — the old PreferencesScreen.axaml's real content,
|
||||
restyled into the settings page idiom and split across two pages rather than one.
|
||||
|
||||
THIS MACHINE moved to the Security page whole: Windows Hello's register/"stop unlocking here" pair and
|
||||
the no-TPM explanation are Security's UNLOCKING card now, one home rather than two — see the cross-
|
||||
reference row at the foot of this page. Device name is not one of the rows that moved; it never existed
|
||||
here at all, because there is no device-name setting to move. Logs record the machine name on their own.
|
||||
|
||||
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
|
||||
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
|
||||
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
|
||||
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock Classes="settingstitle" Text="Preferences" />
|
||||
|
||||
<TextBlock Classes="settingssection" Text="TERMINAL" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Text size" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Top">
|
||||
<Button Width="36" Height="36" Classes="ghost" Padding="0"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}"
|
||||
ToolTip.Tip="Smaller · Ctrl+minus">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||
</Button>
|
||||
<TextBlock Classes="mono" FontSize="13.5" MinWidth="46" VerticalAlignment="Center"
|
||||
TextAlignment="Center" Foreground="{StaticResource Text}"
|
||||
Text="{Binding TerminalFontSize, StringFormat={}{0} px}" />
|
||||
<Button Width="36" Height="36" Classes="ghost" Padding="0"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}"
|
||||
ToolTip.Tip="Larger · Ctrl+plus">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||
</Button>
|
||||
<Button Classes="ghost" Height="36" Content="RESET"
|
||||
Command="{Binding ResetTerminalFontCommand}"
|
||||
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="KEYCHAIN" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Lock the keychain" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="LOCK NOW"
|
||||
Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume." />
|
||||
<Button Classes="ghost" Height="38" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Reads this machine's ~/.ssh/config, shows what it found, and imports only what you approve. Nothing is stored during the scan, and no private key is read — where a key file is named, the path is recorded as a note." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN IMPORTER"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel Margin="24,20" Spacing="10">
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Copy on select and the terminal bell — neither is wired to anything the renderer does." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Clearing the clipboard after copying a secret, and confirming a snippet that runs on insert — neither exists; a copied secret stays on the clipboard until something else replaces it." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="A device name — there is no such setting. Logs record this machine's own name on their own." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's Preferences page: the Terminal and Keychain cards.</summary>
|
||||
internal sealed partial class SettingsPreferencesPage : UserControl
|
||||
{
|
||||
public SettingsPreferencesPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: Security, against Settings-Security.dc.html.
|
||||
|
||||
The end-to-end card's sentence is ADR 0001's own claim in the design's words: "The server stores
|
||||
ciphertext and never holds a key" and "the relay operator ... see ciphertext only" — see docs/adr/0001-e2ee-trust-model.md.
|
||||
|
||||
UNLOCKING is Windows Hello's register/"Stop unlocking here" pair, moved here whole from the old
|
||||
PreferencesScreen.axaml — same RegisterDeviceCommand, ForgetDeviceCommand and the no-TPM explanation
|
||||
(HasNoDeviceKeyOption). One home for this fact rather than two; see the cross-reference on the
|
||||
Preferences page. The design's own auto-lock link row is refused: there is nothing to link to, since
|
||||
auto-lock and per-vault unlock rules do not exist anywhere in this client.
|
||||
|
||||
CONNECTING keeps exactly one row, Approved host keys. Strict host-key checking and the allowed-algorithm
|
||||
chips are both refused — the app always asks on a changed key, and there is no algorithm allow-list
|
||||
anywhere in the SSH stack. The count and the "N that no host dials" clause both come straight off
|
||||
KnownHostsViewModel.Summary, the same sentence the known-hosts screen's own header prints, so this row
|
||||
can never say a different number than the screen it sends you to. "Open host keys" leaves settings mode
|
||||
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
||||
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
||||
|
||||
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
||||
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
||||
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
|
||||
its own: Logs already holds the whole story, and this row says so rather than half-repeating it.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock Classes="settingstitle" Text="Security" />
|
||||
|
||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||
Foreground="{StaticResource Live}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
|
||||
IsVisible="{Binding CanRegisterDevice}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
|
||||
IsVisible="{Binding CanForgetDevice}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanRegisterDevice}" />
|
||||
<Button Grid.Column="1" Classes="danger" Height="38" Content="STOP UNLOCKING HERE"
|
||||
Command="{Binding ForgetDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanForgetDevice}"
|
||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="CONNECTING" />
|
||||
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Approved host keys" />
|
||||
<TextBlock Classes="settingsrowcaption" Text="{Binding KnownHostsScreen.Summary}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN HOST KEYS"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
|
||||
|
||||
<Border Classes="settingscard" Margin="0,12,0,40">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow last">
|
||||
<Button Classes="flat" Padding="0" HorizontalAlignment="Left"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="See all activity in Logs" FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||
Foreground="{StaticResource AccentText}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary>
|
||||
internal sealed partial class SettingsSecurityPage : UserControl
|
||||
{
|
||||
public SettingsSecurityPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsTagsPage"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
x:Name="Root">
|
||||
|
||||
<!--
|
||||
v5c-2: Tags, against Settings-Tags.dc.html — a real management page over VaultViewModel's own tag
|
||||
commands (Tags, NewTag, EditTagRow, DeleteTagRow), which used to be reachable only from the keychain
|
||||
screen's TAGS category. Both doors stay open — the keychain screen's own panel is untouched — because
|
||||
they lead to the same commands and the same rows; nothing here is a second implementation.
|
||||
|
||||
── THE INTRO SENTENCE IS TRUE, AND IT IS THE DESIGN'S OWN ─────────────────────────────────────────────
|
||||
"Renaming here is one write and every host follows" — verified against SaveTagAsync and TagRowViewModel's
|
||||
own remark: a host names a tag's id in its TagSet, never its label, so renaming touches nothing but the
|
||||
tag item itself. Kept rather than rewritten.
|
||||
|
||||
── EDIT AND DELETE ARE WRAPPER COMMANDS, UNLIKE THE GROUPS PAGE'S ──────────────────────────────────────
|
||||
EditTagCommand and DeleteTagCommand read VaultViewModel.SelectedTag rather than taking a row argument —
|
||||
they were built for the keychain screen's own ListBox selection, which this page has no equivalent of.
|
||||
VaultViewModel.EditTagRow/DeleteTagRow (v5c-2, additive) select the row and then call the real command, so
|
||||
every guard and every sentence either one already has is still the one that runs.
|
||||
|
||||
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
|
||||
The LAST APPLIED column: no timestamp of when a tag was last put on a host exists anywhere in this
|
||||
client, and a column of "just now" / "2 min ago" would be inventing one.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<TextBlock Classes="settingstitle" Text="Tags" />
|
||||
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Vault.Tags.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW TAG"
|
||||
Command="{Binding Vault.NewTagCommand}"
|
||||
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
|
||||
Text="A tag is a name shared by every host that carries it. Renaming here is one write — every host, filter and snippet rule follows." />
|
||||
|
||||
<!-- The tag editor, in place — the same one-box form the keychain screen's own panel draws, restyled
|
||||
into this page's card idiom. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vault.IsEditingTag}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Tag" />
|
||||
<TextBox PlaceholderText="name" Text="{Binding Vault.TagEditorLabel}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveTagCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="SAVE" Command="{Binding Vault.SaveTagCommand}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL" Command="{Binding Vault.CancelTagEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- The delete confirmation, in place — the same shared control the groups and vaults pages reuse. -->
|
||||
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingscard" Margin="0,24,0,40" Padding="0"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text=" " />
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||
<Border Classes="settingsrow last" Padding="20,10">
|
||||
<Grid ColumnDefinitions="1.4*,*,*,Auto">
|
||||
|
||||
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding Description}" />
|
||||
|
||||
<!--
|
||||
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
|
||||
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
|
||||
so there is no second vault this column could honestly name.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" />
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteTagRowCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Delete this tag. Hosts wearing it simply stop showing the chip.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="20,14,20,18" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !Vault.HasTagItems}"
|
||||
Text="No tags yet. Add one from here, or from a host's own editor." />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Unlock your keychain to see and manage your tags." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Tags: every tag, and how many hosts wear each — see the remark at the top of the markup.</summary>
|
||||
internal sealed partial class SettingsTagsPage : UserControl
|
||||
{
|
||||
public SettingsTagsPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsTitleBar"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: settings mode's own titlebar — SettingsNav.dc.html and the four Settings-*.dc.html sources all draw
|
||||
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
|
||||
the same three window-control glyphs on the right TitleBar.axaml already draws.
|
||||
|
||||
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
|
||||
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
||||
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
||||
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
||||
bars needs its own window behaviour a shared base would have to be pulled apart first.
|
||||
|
||||
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
|
||||
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
|
||||
settings mode, unlike LeaveSettingsCommand.
|
||||
-->
|
||||
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !IsImportOpen}"
|
||||
Command="{Binding LeaveSettingsCommand}"
|
||||
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Back to application" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsImportOpen}"
|
||||
Command="{Binding CloseImportCommand}"
|
||||
ToolTip.Tip="Back to preferences, without leaving Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Back to preferences" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- The window controls, identical to TitleBar's own — see the remark above on why they are repeated. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2">
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||
ToolTip.Tip="Minimise">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
||||
ToolTip.Tip="Maximise">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
||||
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,47 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Settings mode's own titlebar — see the remark in the markup for why it is not TitleBar itself.</summary>
|
||||
internal sealed partial class SettingsTitleBar : UserControl
|
||||
{
|
||||
public SettingsTitleBar() => InitializeComponent();
|
||||
|
||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
||||
private void OnDrag(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Host?.BeginMoveDrag(e);
|
||||
}
|
||||
|
||||
private void OnMinimise(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Host is { } window)
|
||||
{
|
||||
window.WindowState = WindowState.Minimized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Host is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
window.WindowState = window.WindowState == WindowState.Maximized
|
||||
? WindowState.Normal
|
||||
: WindowState.Maximized;
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Host?.Close();
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:contracts="using:DodoSSH.Contracts"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsVaultsPage"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
x:Name="Root">
|
||||
|
||||
<!--
|
||||
v5c-2: Vaults, against Settings-Vaults.dc.html. Replaces the old full-bleed VaultsScreen, which this
|
||||
settings page now covers completely — every command it had is reachable here exactly once, and
|
||||
VaultsScreen.axaml is gone. Its data is DataContext.Vaults (VaultsViewModel), reached as {Binding Vaults.*}
|
||||
throughout because this page's own DataContext is the shell, the same choice every other settings page
|
||||
makes.
|
||||
|
||||
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
|
||||
"Manage devices" and "3 devices" in the sync line — no list-devices endpoint exists. The whole VAULT
|
||||
DEFAULTS card (auto-lock, require-password-on-unlock, relay toggle) and the whole RECOVERY card (kit,
|
||||
export) — none of the three exists; sync is always on. The magenta "Default" badge — the app does track a
|
||||
"new items go to" vault (VaultViewModel.TargetVaultId), but that lives on a different view model than the
|
||||
row being drawn here and cross-referencing the two per card would be more machinery than the badge is
|
||||
worth; dropped rather than faked. Per-vault UNLOCKED/LOCKED chips — this app locks the keychain as a
|
||||
whole, not one vault at a time, so there is no per-vault state for a chip to draw; the keychain-level fact
|
||||
lives in the sync card's dot instead. The "Unlock Rocateq" modal — same reason, there is nothing per-vault
|
||||
to unlock. Member avatar stacks on the card list — VaultRowViewModel carries a member *count*, not the
|
||||
members themselves; only the selected vault's Members collection is actually loaded, which is why avatars
|
||||
are drawn in the members panel below and nowhere else. The "Sorted by name ▾" control — decorative in the
|
||||
mock; Vaults.Vaults is already ordered personal-first-then-name and there is no second order to switch to.
|
||||
|
||||
── THE MEMBERS PANEL ────────────────────────────────────────────────────────────────────────────────────
|
||||
The design draws an 860px modal. This app's only overlay idiom below a whole-window mode is the scrim a
|
||||
Border with a translucent Background and a PointerPressed handler draws — see QuickConnect.axaml, the
|
||||
other place a click-away-to-close panel exists — so that is what this is, capped at MaxWidth 860 rather
|
||||
than fixed to it: the settings content column is narrower than 860 at the window's minimum, and a modal
|
||||
that insisted on the full width would arrange its own rows past the edge of what this page is given.
|
||||
|
||||
Inside it is the old screen's own right-hand column, restyled rather than reinvented: a selectable list of
|
||||
members, the SET ROLE buttons, the ADD row, REMOVE, HAND OVER, SHARE KEY/WITHDRAW KEY and the KEY HOLDERS
|
||||
list, on the same VaultsViewModel.SelectedMember selection the old screen used. Deleting a vault stays a
|
||||
card-level action (the danger icon below), not a members-panel one, so PendingAction is drawn in only one
|
||||
of the two places at a time — see the two IsVisible guards below, which key off IsMembersPanelOpen because
|
||||
HAND OVER can only ever be armed with the panel open (it needs a selected member) and DELETE only with it
|
||||
closed (the card's own icon is what arms it).
|
||||
|
||||
The sentence that must survive from the old screen: a vault cannot be deleted through the membership list
|
||||
behind it — there is no such call anywhere in the server, and archiving a team while its vault still
|
||||
exists is refused. That is not the same fact as "a vault cannot be deleted" — the DELETE icon below does
|
||||
delete the vault itself — and both sentences are kept, each where it is true.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<TextBlock Classes="settingstitle" Text="Vaults" />
|
||||
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30">
|
||||
<TextBlock Text="{Binding Vaults.Vaults.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<Button Classes="ghost" Height="40" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
<Button Classes="accent" Height="40" Content="+ NEW VAULT"
|
||||
Command="{Binding Vaults.NewVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
||||
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
||||
not over a form. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vaults.IsCreatingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="New vault" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding Vaults.NewVaultName}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="CREATE"
|
||||
Command="{Binding Vaults.CreateVaultCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelNewVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vaults.IsRenamingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Rename vault" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding Vaults.EditVaultName}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes — the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="SAVE"
|
||||
Command="{Binding Vaults.SaveVaultNameCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelRenameVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- A vault's own destructive confirmation. Only while the members panel is shut — see the remark
|
||||
above for why the two never compete for this one PendingAction. Both conditions on the same
|
||||
Border, not one nested inside the other: a Border visible with its content hidden would still
|
||||
draw an empty danger-tinted card while the members panel's own copy of this confirmation is
|
||||
showing. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}">
|
||||
<Border.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Vaults.IsConfirming" />
|
||||
<Binding Path="!Vaults.IsMembersPanelOpen" />
|
||||
</MultiBinding>
|
||||
</Border.IsVisible>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="14.5" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="12.5"
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Height="36" Content="CONFIRM"
|
||||
Command="{Binding Vaults.ConfirmActionCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Sync status: only what is real — no device count, no "ran N minutes ago" (this session tracks no
|
||||
such timestamp). The dot and the word are MainWindowViewModel.IsFullySynced/SyncLabel, the exact
|
||||
fact the titlebar's own dot already tells the truth with. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20,18">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="9" Height="9"
|
||||
Margin="0,7,0,0" VerticalAlignment="Top" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="13" FontWeight="Bold"
|
||||
LetterSpacing="0.5" Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="settingsrowcaption" Text="End-to-end encrypted." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="YOUR VAULTS" Margin="0,32,0,12" />
|
||||
|
||||
<StackPanel Spacing="10" Margin="0,0,0,40" IsVisible="{Binding Vaults.HasVaults}">
|
||||
<ItemsControl ItemsSource="{Binding Vaults.Vaults}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="10" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultRowViewModel">
|
||||
<Border Classes="settingscard" Padding="22,18">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Column="0" Width="52" Height="52" CornerRadius="12"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="20"
|
||||
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="7" Margin="16,0" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="Bold"
|
||||
LetterSpacing="-0.25" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding RoleLabel}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Detail}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="mono" FontSize="10.5" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding State}" IsVisible="{Binding HasState}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.OpenMembersPanelCommand}"
|
||||
CommandParameter="{Binding}" IsVisible="{Binding IsShared}"
|
||||
ToolTip.Tip="See and manage who is in this vault.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.RenameVaultRowCommand}"
|
||||
CommandParameter="{Binding}" IsVisible="{Binding CanAdminister}"
|
||||
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.DeleteVaultRowCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding !IsPersonal}"
|
||||
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !Vaults.HasVaults}"
|
||||
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
|
||||
|
||||
<TextBlock Margin="0,0,0,40" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding Vaults.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Text="{Binding Vaults.Status}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ THE MEMBERS PANEL ============ -->
|
||||
<Border x:Name="MembersBackdrop" Background="#9905050A"
|
||||
IsVisible="{Binding Vaults.IsMembersPanelOpen}" PointerPressed="OnBackdropPressed">
|
||||
<Border MaxWidth="860" MaxHeight="620" Margin="30"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
<Grid RowDefinitions="Auto,*" Margin="26,22">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Vaults.SelectedVault.Name, StringFormat='{}{0} · Members'}"
|
||||
FontSize="18" FontWeight="Bold" LetterSpacing="-0.25"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1" Classes="paneicon" Width="26" Height="26"
|
||||
Command="{Binding Vaults.CloseMembersPanelCommand}" ToolTip.Tip="Close">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vaults.HasSharedMembershipWarning}"
|
||||
Text="{Binding Vaults.SharedMembershipWarning}" />
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !Vaults.SelectedIsPersonal}">
|
||||
|
||||
<TextBlock Classes="settingssection" Text="MEMBERS" Margin="0" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults.Members}" SelectedItem="{Binding Vaults.SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="220">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,150,Auto" Margin="0,4">
|
||||
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding Initials}" FontWeight="Bold" FontSize="10"
|
||||
Foreground="White" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="10,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="10.5" Text="{Binding KeyState}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="10" Text="{Binding LastActive}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Role}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- The hand-over confirmation, only while this panel is the one that armed it. -->
|
||||
<Border Padding="14" CornerRadius="10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
|
||||
IsVisible="{Binding Vaults.IsConfirming}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="13.5" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="11.5"
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="CONFIRM" Command="{Binding Vaults.ConfirmActionCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding Vaults.CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="flat choice" Content="MEMBER" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="flat choice" Content="ADMIN" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Content="HAND OVER" Command="{Binding Vaults.HandOverCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" IsVisible="{Binding Vaults.OwnsSelected}"
|
||||
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
|
||||
<Button Classes="danger" Content="REMOVE" Command="{Binding Vaults.RemoveMemberCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
|
||||
<TextBlock Classes="settingssection" Text="INVITE" Margin="0" />
|
||||
<Grid ColumnDefinitions="*,140,44">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
|
||||
Text="{Binding Vaults.NewMemberEmail}" Margin="0,0,8,0" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="flat choice" Content="VIEWER"
|
||||
Classes.active="{Binding Vaults.AddsAsViewer}"
|
||||
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
|
||||
<Button Classes="flat choice" Content="MEMBER"
|
||||
Classes.active="{Binding Vaults.AddsAsMember}"
|
||||
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="accent" Width="44" Content="+"
|
||||
Command="{Binding Vaults.AddMemberCommand}" IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
Margin="8,0,0,0"
|
||||
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so." />
|
||||
</Grid>
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Adding somebody lets the server serve them this vault. It does not let them read it — that needs a vault key, which SHARE KEY below wraps for them." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.SelectedIsPersonal}">
|
||||
<TextBlock Classes="settingssection" Text="YOURS ALONE" Margin="0" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding Vaults.SelectedIsShared}">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding Vaults.ShareVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding Vaults.RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="KEY HOLDERS" Margin="0" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults.Grants}" Background="Transparent" BorderThickness="0"
|
||||
MaxHeight="120">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
|
||||
<Grid ColumnDefinitions="10,*" Margin="0,3">
|
||||
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Load-bearing, from the old screen: there is no DELETE here for a reason, and the reason is
|
||||
not "not built yet". The membership list behind a shared vault cannot be archived while the
|
||||
vault exists — that is a different fact from the vault itself, which the card's own DELETE
|
||||
icon does remove.
|
||||
-->
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="A vault's membership list cannot be deleted on its own — the server refuses to archive it while the vault it carries still exists. Deleting the vault itself is the card's own icon, outside this panel." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Vaults, restyled into the settings page idiom — see the remark at the top of the markup.</summary>
|
||||
internal sealed partial class SettingsVaultsPage : UserControl
|
||||
{
|
||||
public SettingsVaultsPage() => InitializeComponent();
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <remarks>
|
||||
/// Only a press on the wash itself, the same test <c>QuickConnect.OnBackdropPressed</c> makes: a press
|
||||
/// inside the card bubbles through here too, with its source the control that was actually hit rather
|
||||
/// than the backdrop, so closing on those would make the panel impossible to click into.
|
||||
/// </remarks>
|
||||
private void OnBackdropPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, MembersBackdrop) || Shell is not { } shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
shell.Vaults.CloseMembersPanelCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsView"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: the settings MODE — the full-window chrome swap the design calls Settings, built as one control so
|
||||
MainWindow.axaml can show or hide the whole thing behind MainWindowViewModel.IsSettingsMode with a single
|
||||
IsVisible, the same way it already does for the unlocked application and the setup half of the window.
|
||||
|
||||
Titlebar, then a row of the 340px SettingsNav beside whichever page ActiveSettingsPage names. Seven pages
|
||||
now, all of the design's — v5c-2 added Groups and Tags and retired the old VaultsScreen; see
|
||||
SettingsNav.axaml and design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
Every page including Vaults is wrapped in the 1100px centred column now. SettingsVaultsPage replaces the
|
||||
old VaultsScreen's own 268px-list-beside-a-full-bleed-pane layout with the design's card idiom — see that
|
||||
page's own remark for what changed and why.
|
||||
|
||||
v5c-3: ImportScreen joined the Panel, drawn over SettingsPreferencesPage rather than beside it — see
|
||||
MainWindowViewModel.IsImportOpen. SettingsNav stays lit on Preferences the whole time, per Import.dc.html,
|
||||
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
|
||||
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
|
||||
same split MainWindow.axaml drew before the importer moved in here.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||
|
||||
<views:SettingsTitleBar Grid.Row="0" />
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
|
||||
<views:SettingsNav Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<views:SettingsGeneralPage IsVisible="{Binding IsSettingsGeneralPage}" />
|
||||
|
||||
<views:SettingsVaultsPage IsVisible="{Binding IsSettingsVaultsPage}" />
|
||||
|
||||
<views:SettingsAccountPage IsVisible="{Binding IsSettingsAccountPage}" />
|
||||
|
||||
<views:SettingsSecurityPage IsVisible="{Binding IsSettingsSecurityPage}" />
|
||||
|
||||
<views:SettingsPreferencesPage IsVisible="{Binding IsSettingsPreferencesContentShowing}" />
|
||||
|
||||
<views:SettingsGroupsPage IsVisible="{Binding IsSettingsGroupsPage}" />
|
||||
|
||||
<views:SettingsTagsPage IsVisible="{Binding IsSettingsTagsPage}" />
|
||||
|
||||
<!--
|
||||
Wrapped, like MainWindow.axaml wraps every screen whose data context is its own rather than this
|
||||
control's: IsVisible has to resolve against the ambient MainWindowViewModel, and putting it on the
|
||||
same element as the DataContext override below would have it resolve against ImportViewModel
|
||||
instead, where IsImportOpen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportOpen}">
|
||||
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings mode's whole chrome: its own titlebar, its own 340px rail, and whichever of its five pages
|
||||
/// <see cref="DodoSSH.Client.Shell.ViewModels.MainWindowViewModel.ActiveSettingsPage"/> names.
|
||||
/// </summary>
|
||||
internal sealed partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView() => InitializeComponent();
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:contracts="using:DodoSSH.Contracts"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultsScreen"
|
||||
x:DataType="vm:VaultsViewModel">
|
||||
|
||||
<!--
|
||||
Vaults, and the people in each of them.
|
||||
|
||||
── THIS WAS THE TEAMS SCREEN, AND THE TEAM IS NOW BEHIND THE VAULT. ─────────────────────────────────
|
||||
The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
|
||||
vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
|
||||
So the rows are vaults now: naming one makes the membership list that carries it, and everything on
|
||||
the right — members, key holders — is that vault's. The server still authorises against
|
||||
a team, because that is what VaultAccessService resolves; what went is the requirement that a person
|
||||
know it exists. The one case where it is still visible is a membership list carrying several vaults,
|
||||
which this screen cannot make and will not hide: see SharedMembershipWarning.
|
||||
|
||||
── THE ONE FACT THE WHOLE SCREEN IS BUILT AROUND ────────────────────────────────────────────────────
|
||||
Adding somebody to a vault and giving them its key are two different acts, and only the first is
|
||||
something a server can do. The second needs a machine that holds the key, because this server never
|
||||
does. So the members list and the key-holders list are both here and are not the same list, an
|
||||
addition says out loud that it granted nothing readable yet, and SHARE KEY is its own button rather
|
||||
than a checkbox on the member row.
|
||||
|
||||
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
|
||||
this product) and avatars (no picture is stored anywhere). There is no INVITED list either, and that
|
||||
one is a decision rather than a gap — an address is not a way into a vault, so only an account that
|
||||
already exists can be added and there is nothing pending to draw. See the ADD box below, which says
|
||||
what to do about somebody who has not signed in here yet. Last-active is recorded at most once per
|
||||
account per hour, so it is drawn coarsely. Nor is there a way to delete a vault: the server has no
|
||||
such call, and the screen says so rather than offering a button that refuses.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<!-- ============ The vault list ============ -->
|
||||
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="VAULTS" FontSize="12" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW"
|
||||
Command="{Binding NewVaultCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Makes a vault you can share. Its key is generated on this machine, and nobody else has it until you hand it out." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<!--
|
||||
Read from this machine's own vault list rather than from the server, so the column is right
|
||||
with no connection. What is missing offline is who is in each one, which is why a row can
|
||||
say its membership is unknown rather than saying nothing at all.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding RoleLabel}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" />
|
||||
<!--
|
||||
Only when there is something to say. A vault waiting for a key and one owing a rekey
|
||||
are both temporary and both need somebody to act; a permanent "fine" beside them
|
||||
would teach people to stop reading the line.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding State}"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasState}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasVaults}"
|
||||
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
The name-a-vault form, in place rather than in a modal: this window has no idiom for one. It is
|
||||
in this column rather than beside the pane on the right for one reason — that pane is bound to
|
||||
HasSelection, so with no vaults at all it is not on screen, and "no vaults at all" is exactly
|
||||
the state somebody arrives in from the tab strip's New vault entry.
|
||||
|
||||
One field. The membership list behind it is made with it and named after it, and its slug is
|
||||
derived — see VaultsViewModel.CreateVaultAsync. Asking for a URL handle would be asking for one
|
||||
from somebody who has not been told they are making anything but a vault.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW VAULT" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ The selected vault: who is in it, and who can open it ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedVault.Name}" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The vault's own operations. RENAME is an admin's; handing it on is the owner's alone, and
|
||||
that is the line the server draws as well — an admin the owner promoted must not be able to
|
||||
take the vault from them.
|
||||
|
||||
DELETE is an admin's too, and it is last and red because it is the only one of the three that
|
||||
cannot be undone. It is absent on the personal vault rather than disabled: there is no version
|
||||
of this window in which that vault can go, so a greyed button would be an offer that never
|
||||
becomes real. Its question is the card below, as every destructive answer on this screen is.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding ShowsVaultActions}">
|
||||
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}"
|
||||
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted." />
|
||||
<Button Classes="ghost" Content="HAND OVER" Command="{Binding HandOverCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
|
||||
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanDeleteSelected}"
|
||||
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Margin="14,14" Spacing="18">
|
||||
|
||||
<!-- The rename form, in place, exactly as the create form on the left is. -->
|
||||
<Border Padding="12" CornerRadius="4" BorderThickness="1"
|
||||
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsRenamingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="RENAME VAULT" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding EditVaultName}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes; the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveVaultNameCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The armed confirmation, drawn where the buttons that armed it were. The keychain screen's
|
||||
idiom, and for the same reason: there is no modal anywhere in this window.
|
||||
-->
|
||||
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="12"
|
||||
IsVisible="{Binding IsConfirming}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding PendingAction.Question}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding PendingAction.Consequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The personal vault, which is the one vault sharing cannot reach. Said here rather than by
|
||||
drawing the members section empty: an empty MEMBERS heading over a vault that can never have
|
||||
any reads as a feature that has not loaded.
|
||||
-->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsPersonal}">
|
||||
<TextBlock Classes="label" Text="YOURS ALONE" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright — a key wrapped to somebody it will go on refusing to serve would look like sharing and would not be. Make a vault above for the things you want to share, and put them in it." />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Members -->
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsShared}">
|
||||
<TextBlock Classes="label" Text="MEMBERS" />
|
||||
|
||||
<!--
|
||||
Only ever non-empty for a membership list this screen did not make. Adding somebody to one
|
||||
vault and silently adding them to three others is precisely the fact a vault-shaped screen
|
||||
is in a position to hide, so it says it instead.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasSharedMembershipWarning}"
|
||||
Text="{Binding SharedMembershipWarning}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="240">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding KeyState}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding LastActive}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Buttons and a command rather than a selector bound to the role, which is the choice the
|
||||
key editor and the category rail already make and for the reason they record: a selector
|
||||
moves its own highlight before anything can refuse, so it can end up showing a role
|
||||
nobody was given. OWNER is absent because it is not a role that can be assigned —
|
||||
handing the vault over is its own act, with its own confirmation.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May pull this vault and may not push. It does not withdraw a key they already hold." />
|
||||
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May read and change what is in this vault." />
|
||||
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May also add and remove people, rename the vault, and share its key." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
|
||||
Text="{Binding NewMemberEmail}" Margin="0,0,6,0" />
|
||||
<Button Grid.Column="1" Classes="accent" Content="ADD"
|
||||
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so — ask them to sign in to this server once, which is what creates the account, and then add them." />
|
||||
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
|
||||
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
|
||||
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
|
||||
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding CanAdministerSelected}"
|
||||
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<!-- Who holds the key -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding SelectedIsShared}">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Who can open this vault — the design's "shared with" avatars, as names and a state.
|
||||
Withdrawn and stale grants stay listed and say which they are, because a list that quietly
|
||||
dropped them would show a departed colleague as merely absent rather than as somebody whose
|
||||
key was taken away. The dot is Live and means exactly what it says: this person can open
|
||||
this vault right now.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="KEY HOLDERS" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
|
||||
MaxHeight="150">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
|
||||
<Grid ColumnDefinitions="10,*" Margin="0,3">
|
||||
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding SelectedIsShared}"
|
||||
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
|
||||
|
||||
<!--
|
||||
Said once, where somebody would otherwise go looking for a DELETE button. There is no call
|
||||
for it anywhere in the server, and archiving the membership list behind a vault is refused
|
||||
while the vault exists — so a button here would be one that always refuses.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A vault cannot be deleted. Nothing in this product removes one, and the server refuses to archive the membership list behind it while it still exists." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
|
||||
Text="Make a vault on the left, or wait for somebody to add you to one. A vault holds hosts and credentials that a group of people share; its key is what makes those readable, and that key is handed out by people rather than by the server." />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Classes="hint" FontSize="11.5" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -1,9 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Vaults: which there are, who is in each, and who holds a key to it.</summary>
|
||||
internal sealed partial class VaultsScreen : UserControl
|
||||
{
|
||||
public VaultsScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
@@ -54,6 +55,16 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
internal string Address => host.Address;
|
||||
|
||||
/// <summary>What <c>HostName</c> said, on its own — the v5c table's own column, beside <see cref="User"/>
|
||||
/// and <see cref="Port"/> rather than folded into <see cref="Address"/>.</summary>
|
||||
internal string Hostname => host.Hostname;
|
||||
|
||||
/// <summary>What <c>User</c> said, or an em dash where the entry named none.</summary>
|
||||
internal string User => host.Username is { Length: > 0 } user ? user : "—";
|
||||
|
||||
/// <summary>What <c>Port</c> said, defaulting to 22 the same way <see cref="ImportedHost"/> does.</summary>
|
||||
internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
||||
internal bool AlreadyPresent { get; }
|
||||
|
||||
@@ -61,6 +72,23 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
internal bool HasBadge => AlreadyPresent;
|
||||
|
||||
/// <summary>
|
||||
/// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually
|
||||
/// carries — nothing this screen cannot back up. Skipped patterns (a wildcard <c>Host</c> block) never
|
||||
/// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host
|
||||
/// warnings, from <see cref="HasWarnings"/>, are the amber case instead — a flattened <c>ProxyJump</c> or
|
||||
/// a dropped directive is exactly the kind of thing "quieter than the file" that <c>ImportScreen.axaml</c>'s
|
||||
/// own remark says has to be told before it looks like data loss.
|
||||
/// </summary>
|
||||
internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
|
||||
|
||||
/// <summary>The warned case wins over "already here" — a warning is the more actionable of the two facts.</summary>
|
||||
internal bool IsMeaningWarned => HasWarnings;
|
||||
|
||||
internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
|
||||
|
||||
internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
|
||||
|
||||
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
|
||||
/// <remarks>
|
||||
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
|
||||
@@ -138,7 +166,10 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
/// <see cref="KeyReport"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
||||
internal sealed partial class ImportViewModel(
|
||||
VaultViewModel vault,
|
||||
SshConfigLocator locator,
|
||||
Action? onCancel = null) : ObservableObject
|
||||
{
|
||||
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
||||
|
||||
@@ -202,6 +233,66 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
|
||||
/// <summary>Whether every row is ticked — what the v5c table's header tick-all box shows.</summary>
|
||||
internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
|
||||
|
||||
/// <summary>
|
||||
/// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two real facts and nothing invented — <see cref="ConfigPath"/> and <see cref="HasScanned"/>. The
|
||||
/// fuller narrative belongs to <see cref="Status"/>, which this does not replace: what happened on a scan
|
||||
/// or an import is a sentence, not a fact this header line has room to state honestly in a handful of
|
||||
/// words.
|
||||
/// </remarks>
|
||||
internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
|
||||
|
||||
/// <summary>
|
||||
/// The key-material card's own always-visible sentence, ahead of the tick.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The count is hosts naming a key file, not raw <c>IdentityFile</c> lines — a fact <see cref="Rows"/>
|
||||
/// actually carries, where a literal line count would not survive a host that names more than one and is
|
||||
/// only ever bound to the first. The rest of the sentence is <c>ImportViewModel</c>'s own long-standing
|
||||
/// claim, restated in the design's words after checking it against <c>SshConfigLocator</c>: this type is
|
||||
/// the only place in the application that reads a private key out of a directory nobody pointed at file
|
||||
/// by file, and <see cref="ImportAsync"/> is the only place that ever calls
|
||||
/// <see cref="SshConfigLocator.ReadIdentity"/> — never <see cref="ScanAsync"/> — so nothing is read until
|
||||
/// IMPORT is pressed.
|
||||
/// </remarks>
|
||||
internal string KeyMaterialIntro
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = Rows.Count(row => row.HasKeyFile);
|
||||
var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath;
|
||||
var noun = count == 1 ? "host names" : "hosts name";
|
||||
|
||||
return $"The scan found {count} {noun} a key file in {directory}. This is the only control in "
|
||||
+ "DodoSSH that opens key material from a directory you did not point at file by file — "
|
||||
+ "nothing is read until Import is pressed.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Fixed rather than offered as a picker: the import goes through the same
|
||||
/// <c>session.ActiveVaultId</c> every other bulk write does, and there is no per-import target choice to
|
||||
/// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown.
|
||||
/// </remarks>
|
||||
internal string VaultName => vault.VaultName;
|
||||
|
||||
/// <summary>The v5c footer's own sentence: how many are ticked, out of how many, and where they land.</summary>
|
||||
internal string SelectionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var noun = Rows.Count == 1 ? "entry" : "entries";
|
||||
|
||||
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
@@ -389,6 +480,16 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
internal void NoteSelectionChanged() => RaiseListState();
|
||||
|
||||
/// <summary>The footer's own Cancel button: back to the Preferences page, nothing stored.</summary>
|
||||
/// <remarks>
|
||||
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the same reasoning
|
||||
/// <c>VaultViewModel</c>'s own <c>copyToClipboard</c> is one: this type has no business knowing settings
|
||||
/// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes
|
||||
/// the button a no-op rather than a crash.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Cancel() => onCancel?.Invoke();
|
||||
|
||||
/// <remarks>
|
||||
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
|
||||
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
|
||||
@@ -441,5 +542,11 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
OnPropertyChanged(nameof(HasKeyReport));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
OnPropertyChanged(nameof(AllTicked));
|
||||
OnPropertyChanged(nameof(KeyMaterialIntro));
|
||||
OnPropertyChanged(nameof(SelectionSummary));
|
||||
}
|
||||
|
||||
/// <remarks>The header's own status line is a function of <see cref="HasScanned"/> alone.</remarks>
|
||||
partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
|
||||
}
|
||||
|
||||
@@ -106,10 +106,19 @@ internal sealed class KnownHostRowViewModel(
|
||||
internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
private readonly Action? onBack;
|
||||
|
||||
internal KnownHostsViewModel(VaultViewModel vault)
|
||||
/// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
|
||||
/// <param name="onBack">
|
||||
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
|
||||
/// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
|
||||
/// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
|
||||
/// directly makes the button a no-op rather than a crash.
|
||||
/// </param>
|
||||
internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
|
||||
{
|
||||
this.vault = vault;
|
||||
this.onBack = onBack;
|
||||
|
||||
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
||||
// copy of a trust decision is the one kind of staleness that matters here.
|
||||
@@ -155,6 +164,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
|
||||
internal bool HasPins => Shown.Any();
|
||||
|
||||
/// <summary>
|
||||
/// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
|
||||
/// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
|
||||
/// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
|
||||
/// whatever somebody last typed into the filter.
|
||||
/// </summary>
|
||||
internal int Count => Shown.Count();
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
@@ -218,6 +235,22 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CopyFingerprintAsync()
|
||||
{
|
||||
if (Selected is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
|
||||
[RelayCommand]
|
||||
private void Back() => onBack?.Invoke();
|
||||
|
||||
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
@@ -247,6 +280,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasPins));
|
||||
OnPropertyChanged(nameof(Count));
|
||||
OnPropertyChanged(nameof(HasVisiblePins));
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
|
||||
@@ -206,6 +206,54 @@ internal enum ShellSurface
|
||||
Terminal = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which page the settings mode is showing, while <see cref="MainWindowViewModel.ActiveSettingsPage"/> is
|
||||
/// not null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page
|
||||
/// area with its own — see <c>SettingsView.axaml</c> and the settings-mode remark on
|
||||
/// <see cref="MainWindowViewModel.ActiveSettingsPage"/>. This is a second, orthogonal notion of "where am I"
|
||||
/// from <see cref="ShellScreen"/>, not a replacement for it: <see cref="Preferences"/> and <see cref="Vaults"/>
|
||||
/// still set <see cref="MainWindowViewModel.Screen"/> to the matching <see cref="ShellScreen"/> member, so
|
||||
/// every existing binding and test that asks "is the screen Preferences" keeps its answer. <see cref="General"/>,
|
||||
/// <see cref="Account"/> and <see cref="Security"/> have no <see cref="ShellScreen"/> counterpart — nothing
|
||||
/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>v5c-2: Groups and Tags joined.</b> The design's rail lists them beside Security and Preferences; v5c-1
|
||||
/// omitted both from <c>SettingsView.axaml</c> rather than building a placeholder for either, and this wave
|
||||
/// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a
|
||||
/// <see cref="ShellScreen"/> counterpart: managing groups and tags has never been its own screen before this,
|
||||
/// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding
|
||||
/// for either to keep in step with.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum SettingsPage
|
||||
{
|
||||
/// <summary>Updates, and the refused items from the design's General page, as an essay.</summary>
|
||||
General = 0,
|
||||
|
||||
/// <summary>The vaults themselves and the people in them — the existing <see cref="ShellScreen.Vaults"/> screen.</summary>
|
||||
Vaults = 1,
|
||||
|
||||
/// <summary>The signed-in profile, the sign-in fact, and signing out of this machine.</summary>
|
||||
Account = 2,
|
||||
|
||||
/// <summary>The end-to-end explainer, Windows Hello, and approved host keys.</summary>
|
||||
Security = 3,
|
||||
|
||||
/// <summary>This machine's terminal and keychain settings — the existing <see cref="ShellScreen.Preferences"/> screen.</summary>
|
||||
Preferences = 4,
|
||||
|
||||
/// <summary>Every group, and the hosts filed under each — the existing group commands, given their own page.</summary>
|
||||
Groups = 5,
|
||||
|
||||
/// <summary>Every tag, and how many hosts wear each — the existing tag commands, given their own page.</summary>
|
||||
Tags = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
|
||||
/// </summary>
|
||||
@@ -368,6 +416,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Where <see cref="LeaveSettings"/> goes back to — captured once, on the turn settings mode is
|
||||
/// entered, and not touched again until it is left.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not re-captured on every <see cref="EnterSettings"/> call, which is what makes switching pages inside
|
||||
/// settings mode (Preferences, then Security, then Account) still come back to the one screen the user
|
||||
/// was actually on beforehand rather than to whichever settings page they last visited.
|
||||
/// </remarks>
|
||||
private ShellScreen settingsReturnScreen;
|
||||
|
||||
/// <inheritdoc cref="settingsReturnScreen" />
|
||||
private ShellSurface settingsReturnSurface;
|
||||
|
||||
/// <summary>
|
||||
/// Establishes a connection to a server.
|
||||
/// </summary>
|
||||
@@ -660,6 +722,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private string? email;
|
||||
|
||||
/// <summary>
|
||||
/// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
|
||||
/// settings page's SIGN-IN row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// v5c: <c>MeResponse.Issuer</c> was already being cached into <c>StoredUnlockMaterial.Issuer</c> by
|
||||
/// <see cref="AccountProvisioner"/>, for no reader — nothing before this wave surfaced it. Set from the
|
||||
/// same two places <see cref="AccountName"/> and <see cref="Email"/> are, in <see cref="AdoptIdentity"/>,
|
||||
/// so the three can never drift out of step with which account is actually signed in.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? issuer;
|
||||
|
||||
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
|
||||
/// <remarks>
|
||||
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
|
||||
@@ -700,10 +775,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
|
||||
/// did not.
|
||||
/// </remarks>
|
||||
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
|
||||
private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null)
|
||||
{
|
||||
AccountName = displayName ?? emailAddress ?? subject;
|
||||
Email = emailAddress;
|
||||
Issuer = issuer;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -980,9 +1056,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsImportScreen => Screen is ShellScreen.Import;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
|
||||
|
||||
@@ -1186,10 +1259,175 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void ShowScreen(ShellScreen target)
|
||||
{
|
||||
// v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either
|
||||
// of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant
|
||||
// to land in settings mode rather than on the bare screen the design retired. Redirecting here,
|
||||
// rather than at every caller, is what makes that true without hunting down every existing call.
|
||||
if (target is ShellScreen.Preferences)
|
||||
{
|
||||
EnterSettings(SettingsPage.Preferences);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is ShellScreen.Vaults)
|
||||
{
|
||||
EnterSettings(SettingsPage.Vaults);
|
||||
return;
|
||||
}
|
||||
|
||||
// v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on
|
||||
// Preferences, and what changes underneath it is the content column and the titlebar's own back
|
||||
// label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport.
|
||||
if (target is ShellScreen.Import)
|
||||
{
|
||||
OpenImport();
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
|
||||
// the way in — the caller named a destination, and that destination wins over "go back".
|
||||
ActiveSettingsPage = null;
|
||||
Screen = target;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
|
||||
/// see <c>SettingsView.axaml</c>. Not null exactly while that chrome, rather than the ordinary titlebar
|
||||
/// and nav rail, is what <c>MainWindow.axaml</c> draws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A second notion of "where am I" from <see cref="Screen"/> rather than a replacement for it — see the
|
||||
/// remark on <see cref="SettingsPage"/>. Two of its five members, <see cref="SettingsPage.Preferences"/>
|
||||
/// and <see cref="SettingsPage.Vaults"/>, keep <see cref="Screen"/> in step with the matching
|
||||
/// <see cref="ShellScreen"/> member so every binding and test written against that screen before this
|
||||
/// mode existed keeps working; the other three have nothing to keep in step with.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private SettingsPage? activeSettingsPage;
|
||||
|
||||
/// <summary>Whether the settings chrome, rather than the ordinary one, is what the window is drawing.</summary>
|
||||
internal bool IsSettingsMode => ActiveSettingsPage is not null;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;
|
||||
|
||||
/// <inheritdoc cref="IsSettingsMode" />
|
||||
internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the importer is showing over the Preferences page, inside settings mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A flag layered on top of <see cref="ActiveSettingsPage"/> rather than a <see cref="SettingsPage"/>
|
||||
/// member of its own — Import.dc.html draws <c>SettingsNav</c> lit on Preferences the whole time the
|
||||
/// importer is up, which this makes true for free: <see cref="ActiveSettingsPage"/> never leaves
|
||||
/// <see cref="SettingsPage.Preferences"/>, so <see cref="IsSettingsPreferencesPage"/> and the nav row it
|
||||
/// drives stay exactly as they were. What moves is only the content column, via
|
||||
/// <see cref="IsSettingsPreferencesContentShowing"/>, and the titlebar's own back label — see
|
||||
/// <c>SettingsTitleBar.axaml</c>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isImportOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
|
||||
/// content column shows.
|
||||
/// </summary>
|
||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
|
||||
|
||||
partial void OnActiveSettingsPageChanged(SettingsPage? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsSettingsMode));
|
||||
OnPropertyChanged(nameof(IsSettingsGeneralPage));
|
||||
OnPropertyChanged(nameof(IsSettingsVaultsPage));
|
||||
OnPropertyChanged(nameof(IsSettingsAccountPage));
|
||||
OnPropertyChanged(nameof(IsSettingsSecurityPage));
|
||||
OnPropertyChanged(nameof(IsSettingsPreferencesPage));
|
||||
OnPropertyChanged(nameof(IsSettingsGroupsPage));
|
||||
OnPropertyChanged(nameof(IsSettingsTagsPage));
|
||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||
}
|
||||
|
||||
partial void OnIsImportOpenChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||
|
||||
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
|
||||
/// <remarks>
|
||||
/// The return screen is captured only on the way in from outside settings mode — see
|
||||
/// <see cref="settingsReturnScreen"/> — so switching between settings pages, which calls this
|
||||
/// repeatedly, cannot overwrite it with another settings page.
|
||||
/// <para>
|
||||
/// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is
|
||||
/// a request for that page, not for whatever was drawn over it the last time settings mode was up.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void EnterSettings(SettingsPage page)
|
||||
{
|
||||
if (ActiveSettingsPage is null)
|
||||
{
|
||||
settingsReturnScreen = Screen;
|
||||
settingsReturnSurface = Surface;
|
||||
}
|
||||
|
||||
ActiveSettingsPage = page;
|
||||
IsImportOpen = false;
|
||||
|
||||
Screen = page switch
|
||||
{
|
||||
SettingsPage.Preferences => ShellScreen.Preferences,
|
||||
SettingsPage.Vaults => ShellScreen.Vaults,
|
||||
_ => Screen,
|
||||
};
|
||||
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
|
||||
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.Import"/> for every other caller.
|
||||
/// </summary>
|
||||
private void OpenImport()
|
||||
{
|
||||
EnterSettings(SettingsPage.Preferences);
|
||||
IsImportOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseImport() => IsImportOpen = false;
|
||||
|
||||
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
|
||||
[RelayCommand]
|
||||
private void LeaveSettings()
|
||||
{
|
||||
if (ActiveSettingsPage is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveSettingsPage = null;
|
||||
IsImportOpen = false;
|
||||
Screen = settingsReturnScreen;
|
||||
Surface = settingsReturnSurface;
|
||||
}
|
||||
|
||||
/// <summary>Switches to the terminal surface.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -1911,7 +2149,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
|
||||
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer);
|
||||
ServerUrl = profile.ServerUrl;
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
||||
@@ -1972,7 +2210,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
.RefreshAsync(ServerUrl, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
|
||||
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer);
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||
@@ -2659,21 +2897,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void CancelSignOut() => IsConfirmingSignOut = false;
|
||||
|
||||
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
|
||||
/// <summary>
|
||||
/// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever
|
||||
/// the window is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
|
||||
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
|
||||
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
|
||||
/// inline on the Account settings page while the vault is unlocked — see <c>SettingsAccountPage.axaml</c>
|
||||
/// — and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
|
||||
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
|
||||
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
|
||||
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
|
||||
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
|
||||
/// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is
|
||||
/// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see
|
||||
/// <see cref="ShowNewVault"/>.
|
||||
/// <para>
|
||||
/// v5c: went to <c>ShellScreen.Preferences</c> before this wave, because that bare screen was the only
|
||||
/// place the confirmation card could be seen. It moved to the Account settings page with the card — see
|
||||
/// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row
|
||||
/// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void SignOutFromPopover()
|
||||
{
|
||||
ShowScreen(ShellScreen.Preferences);
|
||||
EnterSettings(SettingsPage.Account);
|
||||
SignOut();
|
||||
}
|
||||
|
||||
@@ -2744,6 +2991,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
AccountName = null;
|
||||
Email = null;
|
||||
Issuer = null;
|
||||
Passphrase = string.Empty;
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = null;
|
||||
@@ -3017,8 +3265,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
|
||||
// would keep a disposed vault alive and repaint a screen nobody can reach.
|
||||
KnownHostsScreen?.Detach();
|
||||
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
|
||||
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
|
||||
|
||||
// v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below
|
||||
// — KnownHostsViewModel has no business knowing ShellScreen exists.
|
||||
KnownHostsScreen = newValue is null
|
||||
? null
|
||||
: new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain));
|
||||
|
||||
// v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page
|
||||
// beneath it without ImportViewModel knowing anything about settings mode — the same reasoning
|
||||
// VaultViewModel's copyToClipboard delegate is built on.
|
||||
ImportScreen = newValue is null
|
||||
? null
|
||||
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
|
||||
|
||||
SnippetsScreen?.Detach();
|
||||
SnippetsScreen = newValue is null
|
||||
@@ -4086,7 +4345,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsVaultsScreen));
|
||||
OnPropertyChanged(nameof(IsPreferencesScreen));
|
||||
OnPropertyChanged(nameof(IsKnownHostsScreen));
|
||||
OnPropertyChanged(nameof(IsImportScreen));
|
||||
OnPropertyChanged(nameof(IsSnippetsScreen));
|
||||
OnPropertyChanged(nameof(IsLogsScreen));
|
||||
OnPropertyChanged(nameof(IsMoreScreen));
|
||||
|
||||
@@ -123,6 +123,9 @@ internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
|
||||
|
||||
/// <summary>What the row says under the name.</summary>
|
||||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||||
|
||||
/// <summary>The single letter the settings page's card draws on this group's tile.</summary>
|
||||
internal string Initial => Label.Length > 0 ? Label[..1].ToUpperInvariant() : "?";
|
||||
}
|
||||
|
||||
/// <summary>An entry in the host editor's group picker.</summary>
|
||||
@@ -4667,8 +4670,20 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasTagItems));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is at least one tag to manage.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// v5c-2: the settings Tags page's empty state. Named apart from <c>HostRowViewModel.HasTags</c>, which
|
||||
/// answers a different question — whether one host wears any — rather than reusing that name at this
|
||||
/// level and relying on which <c>x:DataType</c> a binding happens to be inside to tell the two apart.
|
||||
/// </remarks>
|
||||
internal bool HasTagItems => Tags.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
|
||||
/// authenticate.
|
||||
@@ -4763,6 +4778,28 @@ internal sealed partial class VaultViewModel(
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
OnPropertyChanged(nameof(UngroupedHostCount));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many hosts, across every shown vault, carry no group that still exists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// v5c: the settings Groups page's "No group" footer row. The same dangling-reference reading
|
||||
/// <see cref="FlattenIntoSections"/> gives the sidebar's own UNGROUPED heading — a host naming a group
|
||||
/// this vault no longer has counts as ungrouped rather than vanishing — but counted over every shown
|
||||
/// vault's hosts rather than over a find-box-filtered subset, because a settings page has no find box
|
||||
/// and a count that shrank while somebody typed in one would be answering the wrong question.
|
||||
/// </remarks>
|
||||
internal int UngroupedHostCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||||
|
||||
return Hosts.Count(row =>
|
||||
IsVaultShown(row.VaultId) && (row.Host.GroupId is not { } id || !known.Contains(id)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -9881,6 +9918,35 @@ internal sealed partial class VaultViewModel(
|
||||
Status = $"Renaming {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the tag editor for a specific row, from the settings page's per-row edit icon.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A thin wrapper around <see cref="EditTag"/> rather than a second implementation of what it does. The
|
||||
/// keychain screen's own table drives <see cref="EditTag"/> off a <c>ListBox</c> selection; the settings
|
||||
/// page draws one card per tag with no such selection to lean on, so this puts the row on
|
||||
/// <see cref="SelectedTag"/> first and then asks the command that already knows how to open it — the
|
||||
/// same trick <see cref="EditGroupFromHeading"/> plays for a group, except that command took the row as
|
||||
/// an argument from the start and this one did not, because nothing needed it to until now.
|
||||
/// </remarks>
|
||||
/// <param name="row">The tag to edit.</param>
|
||||
[RelayCommand]
|
||||
private void EditTagRow(TagRowViewModel? row)
|
||||
{
|
||||
SelectedTag = row;
|
||||
EditTagCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Arms the delete confirmation for a specific row, from the settings page's per-row delete icon.</summary>
|
||||
/// <inheritdoc cref="EditTagRow" path="/remarks" />
|
||||
/// <param name="row">The tag to ask about deleting.</param>
|
||||
[RelayCommand]
|
||||
private void DeleteTagRow(TagRowViewModel? row)
|
||||
{
|
||||
SelectedTag = row;
|
||||
DeleteTagCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Abandons the tag editor.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelTagEdit()
|
||||
@@ -10341,6 +10407,35 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the selected pin's fingerprint on the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The twin of <see cref="CopyPublicKeyAsync"/> and the opposite call about secrecy: a host key
|
||||
/// fingerprint is not one. Operators publish theirs on purpose, and the whole workflow this screen exists
|
||||
/// for is comparing a pinned one against what was published — which is a copy-and-paste somebody should
|
||||
/// not have to retype by hand out of a box that refuses to trim it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CopyPinFingerprintAsync()
|
||||
{
|
||||
if (SelectedKnownHost is not { } row)
|
||||
{
|
||||
Status = "Choose a pinned key first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (copyToClipboard is null)
|
||||
{
|
||||
Status = "This machine has no clipboard.";
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(row.Fingerprint).ConfigureAwait(true);
|
||||
|
||||
Status = $"Copied the fingerprint for {row.Host}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on the selected host.
|
||||
/// </summary>
|
||||
|
||||
@@ -107,6 +107,9 @@ internal sealed record VaultRowViewModel(
|
||||
};
|
||||
|
||||
internal bool HasState => State.Length > 0;
|
||||
|
||||
/// <summary>The single letter the settings page's card draws on this vault's tile.</summary>
|
||||
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
|
||||
}
|
||||
|
||||
/// <summary>One member of a vault, as a row in the members table.</summary>
|
||||
@@ -153,6 +156,32 @@ internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool Is
|
||||
|
||||
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
|
||||
|
||||
/// <summary>
|
||||
/// The two letters the members panel draws on this row's avatar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same rule <c>HostRowViewModel.Monogram</c> draws a card's monogram by — the first letters of the
|
||||
/// first two words in the name, or the first two characters where it is one word — except uppercase,
|
||||
/// which is how the design draws a person's initials rather than a host's.
|
||||
/// </remarks>
|
||||
internal string Initials
|
||||
{
|
||||
get
|
||||
{
|
||||
var words = Name.Split(
|
||||
InitialsWordSeparators,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var letters = words.Length >= 2
|
||||
? string.Concat(words[0][0], words[1][0])
|
||||
: Name.Length >= 2 ? Name[..2] : Name;
|
||||
|
||||
return letters.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly char[] InitialsWordSeparators = [' ', '-', '_', '.'];
|
||||
|
||||
/// <summary>Whether this member's role can be changed at all.</summary>
|
||||
/// <remarks>
|
||||
/// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
|
||||
@@ -312,6 +341,33 @@ internal sealed partial class VaultsViewModel(
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Desktop-only presentation state, not a fact about any vault: it says which rectangle is on screen and
|
||||
/// nothing else. Selecting a vault is what actually reads its members, in
|
||||
/// <see cref="OnSelectedVaultChanged"/>; opening this panel over one already selected re-reads nothing.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isMembersPanelOpen;
|
||||
|
||||
/// <summary>Opens the members panel, selecting the vault it is about first if it is not selected already.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenMembersPanel(VaultRowViewModel? vault)
|
||||
{
|
||||
if (vault is not null && !ReferenceEquals(vault, SelectedVault))
|
||||
{
|
||||
SelectedVault = vault;
|
||||
}
|
||||
|
||||
IsMembersPanelOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Closes the members panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseMembersPanel() => IsMembersPanelOpen = false;
|
||||
|
||||
// ---- Creating a vault ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -824,6 +880,38 @@ internal sealed partial class VaultsViewModel(
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens the rename form for a specific vault, from the settings page's per-card edit icon.</summary>
|
||||
/// <remarks>
|
||||
/// A thin wrapper around <see cref="RenameVault"/> rather than a second implementation: the settings
|
||||
/// page draws one card per vault with no list selection to lean on the way this screen used to have, so
|
||||
/// this selects the row first and then asks the command that already knows how to open the form.
|
||||
/// </remarks>
|
||||
/// <param name="vault">The vault to rename.</param>
|
||||
[RelayCommand]
|
||||
private void RenameVaultRow(VaultRowViewModel? vault)
|
||||
{
|
||||
if (vault is not null)
|
||||
{
|
||||
SelectedVault = vault;
|
||||
}
|
||||
|
||||
RenameVaultCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.</summary>
|
||||
/// <inheritdoc cref="RenameVaultRow" path="/remarks" />
|
||||
/// <param name="vault">The vault to ask about deleting.</param>
|
||||
[RelayCommand]
|
||||
private void DeleteVaultRow(VaultRowViewModel? vault)
|
||||
{
|
||||
if (vault is not null)
|
||||
{
|
||||
SelectedVault = vault;
|
||||
}
|
||||
|
||||
DeleteVaultCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the renamed vault.
|
||||
/// </summary>
|
||||
@@ -1538,6 +1626,13 @@ internal sealed partial class VaultsViewModel(
|
||||
PendingAction = null;
|
||||
IsRenamingVault = false;
|
||||
|
||||
// The members panel is drawn over one vault's people; a selection that clears altogether — the
|
||||
// vault it was showing got deleted, or the list emptied — leaves nothing for it to be about.
|
||||
if (value is null)
|
||||
{
|
||||
IsMembersPanelOpen = false;
|
||||
}
|
||||
|
||||
if (isReselecting)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -55,6 +55,33 @@ internal static class LayoutHarness
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
/// <summary>Settings mode's own rail, from <c>SettingsNav.axaml</c> — wider than <see cref="NavRailWidth"/>.</summary>
|
||||
internal const double SettingsNavWidth = 340;
|
||||
|
||||
/// <summary>
|
||||
/// The width settings mode's own content column asks for, from the design's <c>width:1100px</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>MaxWidth</c> on the page, not a <c>Width</c> — see the same trade <c>TitleBar.axaml</c>'s own
|
||||
/// search box makes with its own <c>MaxWidth="514"</c>, and for the identical reason:
|
||||
/// <see cref="SettingsContentWidth"/> below is smaller than this at the window's minimum, and a page
|
||||
/// that insisted on the full 1100 would arrange its own rows past the edge of the rectangle settings
|
||||
/// mode actually gives them.
|
||||
/// </remarks>
|
||||
internal const double SettingsDesignContentWidth = 1100;
|
||||
|
||||
/// <summary>The width a settings page's content column actually gets at the window's minimum.</summary>
|
||||
internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
|
||||
|
||||
/// <summary>What settings mode leaves a page between its own titlebar and the window's bottom edge.</summary>
|
||||
/// <remarks>
|
||||
/// Settings mode has no status bar and no update banner of its own — see <c>MainWindow.axaml</c>'s own
|
||||
/// remark on why both are hidden while <c>IsSettingsMode</c> is true — so this is
|
||||
/// <see cref="MinimumHeight"/> less only <see cref="TitleBarHeight"/>, not <see cref="ContentHeight"/>'s
|
||||
/// own subtraction of <see cref="StatusBarHeight"/> too.
|
||||
/// </remarks>
|
||||
internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
|
||||
|
||||
/// <summary>
|
||||
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
|
||||
/// </summary>
|
||||
@@ -193,6 +220,18 @@ internal static class LayoutHarness
|
||||
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
||||
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
||||
|
||||
/// <summary>
|
||||
/// v5c-3: what the S3 usage of <c>TransfersScreen</c> gets, now that <c>MainWindow.axaml</c> gives it the
|
||||
/// session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — no tab row, header,
|
||||
/// status bar or sidebar to take further space off it.
|
||||
/// </summary>
|
||||
internal static double BucketsScreenWidth =>
|
||||
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
||||
|
||||
/// <inheritdoc cref="BucketsScreenWidth" />
|
||||
internal static double BucketsScreenHeight =>
|
||||
ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
||||
|
||||
private static readonly HeadlessUnitTestSession Session =
|
||||
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
|
||||
|
||||
|
||||
@@ -207,24 +207,35 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
|
||||
/// <summary>
|
||||
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
|
||||
/// popover behind them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
|
||||
/// <para>
|
||||
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
|
||||
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
|
||||
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
|
||||
/// and "Preferences" are no longer the same click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
|
||||
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
|
||||
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
||||
|
||||
[Fact]
|
||||
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
|
||||
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
|
||||
|
||||
[Fact]
|
||||
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
|
||||
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
|
||||
|
||||
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
|
||||
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
||||
OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
var chip = UserChip(rail);
|
||||
@@ -232,19 +243,20 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
|
||||
Click(PopoverRow(window, label), window);
|
||||
|
||||
shell.Screen.ShouldBe(target);
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(target);
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
|
||||
/// room for at all is drawn inline on that one screen while the vault is unlocked, and arming it from
|
||||
/// anywhere else would be a card raised nobody could see.
|
||||
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
|
||||
/// that one page while the vault is unlocked, and arming it from anywhere else would be a card raised
|
||||
/// nobody could see.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePopoversLogoutRow_GoesToPreferencesAndArmsTheSignOutConfirmation()
|
||||
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
|
||||
{
|
||||
await OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
@@ -253,7 +265,8 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
|
||||
Click(PopoverRow(window, "Logout"), window);
|
||||
|
||||
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -825,9 +824,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
|
||||
|
||||
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
|
||||
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
|
||||
// usage is "deliberately not given the session shell above."
|
||||
// The buckets budget, not the full session shell's: MainWindow.axaml gives S3 the same TransfersScreen
|
||||
// control inside a padded, bordered container but with no tab row, no header, no status bar and no
|
||||
// sidebar around it — see its own remark on why the S3 usage is "deliberately not given the full
|
||||
// session shell above."
|
||||
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
|
||||
}
|
||||
|
||||
@@ -847,27 +847,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
|
||||
}
|
||||
|
||||
// ---- The import screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
||||
{
|
||||
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
||||
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
||||
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
||||
/// understands and one they think is broken.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
||||
{
|
||||
await MeasureImportAsync(
|
||||
faults => faults.ShouldBeEmpty("with a scanned list"),
|
||||
await ScannedImportAsync());
|
||||
}
|
||||
// v5c-3: the import screen's own layout coverage moved to SettingsPagesLayoutTests — it is a settings
|
||||
// page now, drawn inside settings mode over the Preferences page rather than beside the ordinary nav
|
||||
// rail; see MainWindowViewModel.IsImportOpen and design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
// ---- The host keys screen ----
|
||||
|
||||
@@ -1898,87 +1880,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
ImportViewModel? import = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new ImportScreen
|
||||
{
|
||||
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
||||
};
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// An import view model that has scanned a real file, so the table has rows in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
||||
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
||||
/// without, and inventing the rows would measure a layout nothing generates.
|
||||
/// </remarks>
|
||||
private async Task<ImportViewModel> ScannedImportAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "config"),
|
||||
"""
|
||||
Host *
|
||||
ServerAliveInterval 30
|
||||
|
||||
Host prod-db
|
||||
HostName database.production.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host bastion-eu-west-1
|
||||
HostName bastion.eu-west-1.example.com
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
Compression yes
|
||||
compression no
|
||||
|
||||
Match host anything
|
||||
User root
|
||||
""");
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
||||
|
||||
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
||||
// table — which is the other test.
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
||||
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
||||
|
||||
return import;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasurePinsAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
@@ -2142,12 +2043,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
|
||||
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the budget it actually gets.</summary>
|
||||
/// <remarks>
|
||||
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
|
||||
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
|
||||
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
|
||||
/// the same budget every other full-bleed page gets.
|
||||
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height. v5c-3
|
||||
/// gives S3 the session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — see
|
||||
/// <c>MainWindow.axaml</c>'s own remark on why — so it is measured at
|
||||
/// <see cref="LayoutHarness.BucketsScreenWidth"/>/<see cref="LayoutHarness.BucketsScreenHeight"/>, which
|
||||
/// take that padding and border off the full-bleed budget every other page gets and stop there: no tab
|
||||
/// row, header, status bar or sidebar to subtract, unlike <see cref="LayoutHarness.SessionScreenWidth"/>.
|
||||
/// </remarks>
|
||||
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
@@ -2156,7 +2059,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
screen, LayoutHarness.BucketsScreenWidth, LayoutHarness.BucketsScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -2190,123 +2093,12 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
bytesPerSecond,
|
||||
failure)));
|
||||
|
||||
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
|
||||
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
|
||||
/// widened the rail, because the minimum grew by exactly what the rail did.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
|
||||
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
|
||||
/// confirmation, are measured in their own tests below rather than here, because a control that is
|
||||
/// collapsed when the window is laid out is a control this suite has not checked.
|
||||
/// </para>
|
||||
/// v5c-2: the old VaultsScreen this suite used to measure here is gone — Vaults is a settings page now,
|
||||
/// and its own layout coverage (populated lists, the rename and new-vault forms, the hand-over
|
||||
/// confirmation, the members panel) lives in <c>SettingsPagesLayoutTests</c> beside every other settings
|
||||
/// page's, measured against the settings content budget rather than this suite's full-chrome one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => { },
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with members and key holders"));
|
||||
|
||||
/// <remarks>
|
||||
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.RenameVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the rename form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The name-a-vault form is in the left column under the vault list. Worth its own case because the
|
||||
/// column is 268 wide and the sentence under the field wraps.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.NewVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the new-vault form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
||||
/// tallest thing that can appear above the members list, so it is the case most likely to push the
|
||||
/// key-holders list off the bottom.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults =>
|
||||
{
|
||||
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
|
||||
vaults.HandOverCommand.Execute(null);
|
||||
},
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
|
||||
/// are needed and they answer different questions: the vault list is the session's, and who is in each
|
||||
/// vault is the server's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A shared vault is created into the session first, because a session that has only ever been unlocked
|
||||
/// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
|
||||
/// is created through the real <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
|
||||
/// being measured is one the application could actually produce.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selected before the second load rather than after it, so the members read is the awaited one: a
|
||||
/// selection assignment starts a read nothing can wait for, and measuring a window while it was still
|
||||
/// in flight would certify a screen with empty lists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OnTheVaultsScreenAsync(
|
||||
Action<VaultsViewModel> arrange,
|
||||
Action<Window> assert)
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
var vaults = new VaultsViewModel(() => teamServer, () => session);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
arrange(vaults);
|
||||
|
||||
var screen = new VaultsScreen { DataContext = vaults };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
||||
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v5c: whether each of settings mode's four pages fits the rectangle it is actually given, and whether the
|
||||
/// rows a fidelity pass could quietly unwire — Updates on General, Windows Hello on Security — are still
|
||||
/// wired to the real commands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The budget is <see cref="LayoutHarness.SettingsContentWidth"/> by <see cref="LayoutHarness.SettingsContentHeight"/>
|
||||
/// — the space beside <c>SettingsNav</c>'s own 340 pixels, under settings mode's own titlebar and with no
|
||||
/// status bar or update banner beneath it, at the window's minimum. Not the design's own 1100-wide column,
|
||||
/// which is wider than that budget: see the <c>MaxWidth</c> remark on <see cref="LayoutHarness.SettingsDesignContentWidth"/>
|
||||
/// for why the pages ask for 1100 at most rather than exactly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real unlocked vault, on the same reasoning <see cref="ScreenLayoutTests"/> gives: the Security page
|
||||
/// binds <c>KnownHostsScreen.Summary</c>, which is null until a vault is open, and a stand-in vault would
|
||||
/// still have to be the real <see cref="VaultViewModel"/> type for the compiled bindings to resolve at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"settings-layout-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-settings-layout-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
AccountName = "Ripley Vega",
|
||||
Email = "ripley@example.test",
|
||||
Issuer = "https://sso.example.test/realms/dodotech",
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheGeneralPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsGeneralPage(), faults => faults.ShouldBeEmpty());
|
||||
|
||||
[Fact]
|
||||
public Task ThePreferencesPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsPreferencesPage(), faults => faults.ShouldBeEmpty());
|
||||
|
||||
[Fact]
|
||||
public Task TheAccountPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("the ordinary shape"));
|
||||
|
||||
/// <remarks>The other shape the Account page takes — the sign-out confirmation card in place of the row.</remarks>
|
||||
[Fact]
|
||||
public async Task TheAccountPageFitsWithTheSignOutConfirmationUp()
|
||||
{
|
||||
shell.SignOutCommand.Execute(null);
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
|
||||
await MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("with the confirm card up"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsWhileThisMachineCanRegisterADeviceKey()
|
||||
{
|
||||
shell.CanRegisterDevice = true;
|
||||
shell.CanForgetDevice = false;
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to register"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsWhileThisMachineIsAlreadyRegistered()
|
||||
{
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = true;
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to withdraw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsOnAMachineWithNowhereToKeepADeviceKey()
|
||||
{
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = false;
|
||||
shell.HasNoDeviceKeyOption.ShouldBeTrue();
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("the no-TPM explanation"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checking for updates on the General page is still the real <c>UpdateViewModel</c> command, not a
|
||||
/// row a fidelity pass silently detached while restyling it into the card idiom.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public Task TheGeneralPagesCheckNowButton_IsWiredToTheRealUpdatesCommand() =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsGeneralPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "CHECK NOW").Command.ShouldBeSameAs(shell.Updates.CheckNowCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// Windows Hello's two commands — moved here whole from the old Preferences screen — are reachable from
|
||||
/// the Security page in both of the states they can be in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheSecurityPagesHelloButtons_AreWiredToTheRealDeviceCommands()
|
||||
{
|
||||
shell.CanRegisterDevice = true;
|
||||
shell.CanForgetDevice = false;
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsSecurityPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "REGISTER").Command.ShouldBeSameAs(shell.RegisterDeviceCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = true;
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsSecurityPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "STOP UNLOCKING HERE").Command.ShouldBeSameAs(shell.ForgetDeviceCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- v5c-2: Vaults, Groups, Tags ----
|
||||
//
|
||||
// Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
|
||||
// LayoutHarness.ScreenWidth/ScreenHeight (the space beside the ordinary nav rail); it is a settings page
|
||||
// now, so its own layout coverage belongs here, against the narrower settings budget, beside every other
|
||||
// settings page's. Groups and Tags are new pages with nothing to migrate.
|
||||
|
||||
/// <remarks>
|
||||
/// A shared vault added straight into the fixture's own session — the same technique the retired
|
||||
/// VaultsScreen layout tests used — so the card list draws both the personal vault and a shared one
|
||||
/// without a live server: <c>VaultRowViewModel.IsShared</c> only asks whether the vault carries a team
|
||||
/// id, which this sets without needing <see cref="MainWindowViewModel"/>'s own (offline) connection.
|
||||
/// </remarks>
|
||||
private async Task AddSharedVaultAsync()
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheVaultListPopulated()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("a personal vault and a shared one"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheNewVaultFormOpen()
|
||||
{
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
shell.Vaults.NewVaultCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the new-vault form open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheRenameFormOpen()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
shell.Vaults.RenameVaultRowCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the rename form open"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The panel's own geometry — the ListBoxes, the SHARE KEY/WITHDRAW KEY row, the KEY HOLDERS list —
|
||||
/// with nothing in Members or Grants, since the fixture's connection is offline and both are read from
|
||||
/// the server on selection. An empty ListBox is zero pixels tall and exempt from this harness's own
|
||||
/// "no size" rule, so this is still a real check of everything around it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheMembersPanelOpen()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the members panel open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithGroupsPopulated()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
await AddGroupAsync("staging");
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("two groups and the No group footer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithTheEditorOpen()
|
||||
{
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("the group editor open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithTheDeleteConfirmationArmed()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTagsPopulated()
|
||||
{
|
||||
await AddTagAsync("production");
|
||||
await AddTagAsync("staging");
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("two tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTheEditorOpen()
|
||||
{
|
||||
vault.NewTagCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("the tag editor open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTheDeleteConfirmationArmed()
|
||||
{
|
||||
await AddTagAsync("production");
|
||||
|
||||
vault.DeleteTagRowCommand.Execute(vault.Tags.Single());
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
|
||||
}
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private async Task AddTagAsync(string label)
|
||||
{
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = label;
|
||||
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// ---- v5c-3: Import ----
|
||||
//
|
||||
// The importer moved into settings mode's own chrome — see MainWindowViewModel.IsImportOpen and
|
||||
// design-notes/v5c-fidelity-notes.md — so its layout coverage moved here from ScreenLayoutTests, against
|
||||
// the same SettingsContentWidth/SettingsContentHeight budget every other settings page is measured
|
||||
// against, rather than the plain full-bleed one it used to get beside the ordinary nav rail.
|
||||
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
||||
{
|
||||
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
||||
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
||||
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
||||
/// understands and one they think is broken.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
||||
{
|
||||
await MeasureImportAsync(
|
||||
faults => faults.ShouldBeEmpty("with a scanned list"),
|
||||
await ScannedImportAsync());
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <remarks>
|
||||
/// The page is built by the factory rather than handed in already constructed: an Avalonia control is
|
||||
/// owned by whichever thread creates it, and every caller of this helper must build its page on the
|
||||
/// dispatcher thread <see cref="LayoutHarness.OnTheUiThreadAsync"/> switches onto, not on the test
|
||||
/// runner's own thread the factory is captured from.
|
||||
/// </remarks>
|
||||
private Task MeasureAsync(Func<UserControl> page, Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var control = page();
|
||||
control.DataContext = shell;
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
control, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private static Button ButtonNamed(Visual root, string label) =>
|
||||
root.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Lays the importer out at the budget settings mode's own content column actually gets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not built through <see cref="MeasureAsync"/>: every other settings page is typed to
|
||||
/// <see cref="MainWindowViewModel"/> and takes <see cref="shell"/> as its data context, where
|
||||
/// <c>ImportScreen</c> is typed to <c>ImportViewModel</c> — the same split <c>SettingsView.axaml</c>
|
||||
/// draws by handing it <c>{Binding ImportScreen}</c> rather than the shell itself.
|
||||
/// </remarks>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
ImportViewModel? import = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new ImportScreen
|
||||
{
|
||||
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
||||
};
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// An import view model that has scanned a real file, so the table has rows in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
||||
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
||||
/// without, and inventing the rows would measure a layout nothing generates.
|
||||
/// </remarks>
|
||||
private async Task<ImportViewModel> ScannedImportAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-settings-import-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "config"),
|
||||
"""
|
||||
Host *
|
||||
ServerAliveInterval 30
|
||||
|
||||
Host prod-db
|
||||
HostName database.production.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host bastion-eu-west-1
|
||||
HostName bastion.eu-west-1.example.com
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
Compression yes
|
||||
compression no
|
||||
|
||||
Match host anything
|
||||
User root
|
||||
""");
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
||||
|
||||
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
||||
// table — which is the other test.
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
||||
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
||||
|
||||
return import;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3815,6 +3815,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.KnownHostPins.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-3: fingerprints are public — operators publish theirs on purpose — so this is the one clipboard
|
||||
/// copy on this screen that needs no confirmation and no refusal, unlike a private key's own
|
||||
/// <c>CopyPublicKeyCommand</c>. In full, because a shortened fingerprint cannot be compared against what
|
||||
/// was published.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CopyingAPinsFingerprint_PutsTheFullFingerprintOnTheClipboard()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
vault.SelectedKnownHost = vault.KnownHostPins.ShouldHaveSingleItem();
|
||||
|
||||
await vault.CopyPinFingerprintCommand.ExecuteAsync(null);
|
||||
|
||||
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
|
||||
}
|
||||
|
||||
/// <remarks>The v5c screen's own restyle over <see cref="KnownHostsViewModel"/> forwards the same command.</remarks>
|
||||
[Fact]
|
||||
public async Task CopyingAPinsFingerprintThroughTheKnownHostsScreen_ReachesTheVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
|
||||
pins.Selected = pins.VisiblePins.ShouldHaveSingleItem();
|
||||
|
||||
await pins.CopyFingerprintCommand.ExecuteAsync(null);
|
||||
|
||||
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The v5c header's own back arrow, reached through the same onBack delegate ImportViewModel's Cancel
|
||||
/// button uses — see MainWindowViewModel.OnVaultChanged. Its destination is the Keychain screen this list
|
||||
/// was pulled out of.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKnownHostsScreensBackArrow_ReturnsToKeychain()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
|
||||
shell.IsKnownHostsScreen.ShouldBeTrue();
|
||||
|
||||
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
|
||||
pins.BackCommand.Execute(null);
|
||||
|
||||
shell.IsKeychainScreen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
|
||||
/// is the seam that could silently come apart: the screen's view model is built from the vault in
|
||||
@@ -4332,6 +4392,150 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
import.Status.ShouldContain("no", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ---- v5c-3: the WHAT THIS MEANS chip, tick-all, and the footer's own facts ----
|
||||
|
||||
/// <remarks>
|
||||
/// The three real states a row can be in, and nothing else: a skipped <c>Host</c> pattern never becomes a
|
||||
/// row at all (see <c>SshConfigImport.SkippedPatterns</c>), so there is no fourth, invented "skipped" chip
|
||||
/// to test for. A warned row wins over "already here" — see <c>ImportRowViewModel.Meaning</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportersMeaningChipsMapTheRealRowStatesHonestly()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
var sshDirectory = Path.Combine(directory, $"ssh-meaning-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(sshDirectory);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(sshDirectory, "config"),
|
||||
"""
|
||||
Host already-here
|
||||
HostName db.internal
|
||||
User deploy
|
||||
|
||||
Host bastion
|
||||
HostName bastion.internal
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
|
||||
Host fresh
|
||||
HostName fresh.internal
|
||||
User deploy
|
||||
""",
|
||||
Token);
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
||||
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.Rows.Count.ShouldBe(3);
|
||||
|
||||
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
|
||||
known.IsMeaningExisting.ShouldBeTrue();
|
||||
known.IsMeaningNew.ShouldBeFalse();
|
||||
known.IsMeaningWarned.ShouldBeFalse();
|
||||
known.Meaning.ShouldBe("already here");
|
||||
|
||||
var warned = import.Rows.Single(row => string.Equals(row.Alias, "bastion", StringComparison.Ordinal));
|
||||
warned.IsMeaningWarned.ShouldBeTrue();
|
||||
warned.IsMeaningNew.ShouldBeFalse();
|
||||
warned.IsMeaningExisting.ShouldBeFalse();
|
||||
// The warned chip carries the row's own real reason.
|
||||
warned.Meaning.ShouldContain("ProxyCommand");
|
||||
|
||||
var fresh = import.Rows.Single(row => string.Equals(row.Alias, "fresh", StringComparison.Ordinal));
|
||||
fresh.IsMeaningNew.ShouldBeTrue();
|
||||
fresh.IsMeaningExisting.ShouldBeFalse();
|
||||
fresh.IsMeaningWarned.ShouldBeFalse();
|
||||
fresh.Meaning.ShouldBe("new host");
|
||||
}
|
||||
|
||||
/// <remarks>The header's own tick-all box, over <see cref="ImportViewModel.ToggleAllCommand"/>.</remarks>
|
||||
[Fact]
|
||||
public async Task TickingAllTogglesEveryRowAndTheHeaderTickReflectsIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
var sshDirectory = Path.Combine(directory, $"ssh-tickall-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(sshDirectory);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(sshDirectory, "config"),
|
||||
"""
|
||||
Host a
|
||||
HostName a.internal
|
||||
|
||||
Host b
|
||||
HostName b.internal
|
||||
""",
|
||||
Token);
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.AllTicked.ShouldBeTrue("both are new hosts, which start ticked");
|
||||
|
||||
import.Rows[0].IsSelected = false;
|
||||
import.NoteSelectionChanged();
|
||||
|
||||
import.AllTicked.ShouldBeFalse();
|
||||
|
||||
import.ToggleAllCommand.Execute(null);
|
||||
|
||||
import.AllTicked.ShouldBeTrue("fewer than all ticked toggles everything on");
|
||||
import.Rows.ShouldAllBe(row => row.IsSelected);
|
||||
|
||||
import.ToggleAllCommand.Execute(null);
|
||||
|
||||
import.AllTicked.ShouldBeFalse();
|
||||
import.Rows.ShouldAllBe(row => !row.IsSelected);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The key-material opt-in card's own always-visible sentence: a real count of hosts naming a key file,
|
||||
/// the real directory, and the same "nothing is read until Import is pressed" claim verified against
|
||||
/// <see cref="SshConfigLocator.ReadIdentity"/> only ever being called from <c>ImportAsync</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKeyMaterialCardsIntroSentence_NamesTheRealCountAndDirectory()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
var sshDirectory = KeyedConfigDirectory();
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
||||
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.KeyMaterialIntro.ShouldContain("1 host names");
|
||||
import.KeyMaterialIntro.ShouldContain(sshDirectory);
|
||||
import.KeyMaterialIntro.ShouldContain(
|
||||
"nothing is read until Import is pressed", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFooterSummary_NamesTheRealSelectionCountAndVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
var sshDirectory = Path.Combine(directory, $"ssh-summary-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(sshDirectory);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(sshDirectory, "config"), "Host a\n HostName a.internal\n", Token);
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.SelectionSummary.ShouldBe($"1 of 1 entry selected · saving to {vault.VaultName}");
|
||||
}
|
||||
|
||||
// ---- Filtering the host sidebar ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -4435,6 +4639,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-2: the settings Groups page's "No group" footer row. Counts a host whose group has never been set
|
||||
/// and one whose group id dangles (deleted from under it) the same way — both are "ungrouped" to a person
|
||||
/// looking at the list, per the reading <c>FlattenIntoSections</c> already gives the sidebar's own
|
||||
/// heading, and <c>UngroupedHostCount</c> has to agree with it rather than invent a second definition.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task UngroupedHostCount_CountsHostsWithNoGroupAndHostsWhoseGroupHasGone()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
await AddHostAsync(vault, "bastion");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.UngroupedHostCount.ShouldBe(3, "no host has been filed under the new group yet");
|
||||
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.UngroupedHostCount.ShouldBe(2, "one host now belongs to a real group");
|
||||
|
||||
var group = vault.Groups.Single();
|
||||
vault.DeleteGroupCommand.Execute(group);
|
||||
vault.PendingDeletion.ShouldNotBeNull();
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.UngroupedHostCount.ShouldBe(
|
||||
3, "a host whose group was deleted falls back to ungrouped rather than vanishing from the count");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
|
||||
/// somebody made and can put things in, the second is an absence of search results — and a heading with
|
||||
@@ -6282,6 +6518,46 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own
|
||||
/// table does, so <c>EditTagRow</c>/<c>DeleteTagRow</c> select the row and then hand off to the real
|
||||
/// commands above — this proves the hand-off reaches the same place, with the same guard sentences.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EditTagRow_SelectsTheRowThenOpensTheSameEditorEditTagDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddTagAsync(vault, "pci");
|
||||
var row = vault.Tags.Single();
|
||||
|
||||
vault.EditTagRowCommand.Execute(row);
|
||||
|
||||
vault.SelectedTag.ShouldBe(row);
|
||||
vault.IsEditingTag.ShouldBeTrue();
|
||||
vault.TagEditorLabel.ShouldBe("pci");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTagRow_SelectsTheRowThenArmsTheSameConfirmationDeleteTagDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddTagAsync(vault, "pci");
|
||||
await TagAsync(vault, "prod-db", "pci");
|
||||
|
||||
var row = vault.Tags.Single();
|
||||
|
||||
vault.DeleteTagRowCommand.Execute(row);
|
||||
|
||||
vault.SelectedTag.ShouldBe(row);
|
||||
vault.PendingDeletion.ShouldNotBeNull().Usage
|
||||
.ShouldContain("1 host", Case.Insensitive, "the same guard sentence DeleteTag would have armed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATagCreatedFromTheHostEditor_IsPutOnTheHostBeingEdited()
|
||||
{
|
||||
@@ -7915,6 +8191,249 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
|
||||
}
|
||||
|
||||
// ---- v5c: settings mode ----
|
||||
//
|
||||
// The window-level mode that swaps the titlebar, the rail and the page area for settings mode's own —
|
||||
// see MainWindowViewModel.EnterSettings and design-notes/v5c-fidelity-notes.md. What is worth proving at
|
||||
// this level, with no Avalonia involved, is the state machine itself: entering and leaving preserves
|
||||
// wherever the user actually was, switching between settings pages does not forget it, and the two
|
||||
// pages that mirror an existing ShellScreen keep every binding written against that screen before this
|
||||
// mode existed.
|
||||
|
||||
/// <remarks>
|
||||
/// The core promise of "Back to application": whatever screen a user was on survives a trip through
|
||||
/// settings mode untouched, however many pages they visit while they are there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnteringAndLeavingSettingsMode_PreservesTheScreenItWasEnteredFrom()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.General);
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.General);
|
||||
|
||||
// Switching pages inside settings mode must not overwrite the remembered return screen with a
|
||||
// settings page of its own — see the remark on MainWindowViewModel.settingsReturnScreen.
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
||||
|
||||
shell.LeaveSettingsCommand.Execute(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeFalse();
|
||||
shell.ActiveSettingsPage.ShouldBeNull();
|
||||
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
|
||||
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
|
||||
/// to bring it back rather than leaving the user on a page they never asked for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnteringSettingsModeFromATerminal_CollapsesItAndLeavingRestoresIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeTrue();
|
||||
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeFalse("settings mode occupies the same rectangle a page does");
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
|
||||
shell.LeaveSettingsCommand.Execute(null);
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeTrue();
|
||||
shell.IsSettingsMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c: <see cref="ShellScreen.Preferences"/> and <see cref="ShellScreen.Vaults"/> are settings pages
|
||||
/// now, so anything that still navigates to either — a test written before this wave, the phone's own
|
||||
/// hub — is redirected into settings mode on the matching page rather than landing on a screen the
|
||||
/// design retired. <see cref="MainWindowViewModel.Screen"/> is kept in step with the two so every
|
||||
/// existing binding written against either screen keeps its answer.
|
||||
/// <para>
|
||||
/// Two <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
|
||||
/// and <c>SettingsPage</c> are both <c>internal</c>, and a public theory method may not carry an
|
||||
/// internal type in its signature.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() =>
|
||||
ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences);
|
||||
|
||||
[Fact]
|
||||
public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() =>
|
||||
ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults);
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-2: Groups and Tags joined settings mode with no <see cref="ShellScreen"/> counterpart — managing
|
||||
/// either has never been its own screen before this wave — so there is no redirect to prove, only that
|
||||
/// <see cref="MainWindowViewModel.EnterSettingsCommand"/> reaches each directly.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void EnteringSettingsOnGroups_ShowsTheGroupsPage()
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Groups);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Groups);
|
||||
shell.IsSettingsGroupsPage.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnteringSettingsOnTags_ShowsTheTagsPage()
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Tags);
|
||||
shell.IsSettingsTagsPage.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private void ShowingAScreenEntersSettingsModeOn(ShellScreen screen, SettingsPage page)
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(screen);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(page);
|
||||
shell.Screen.ShouldBe(screen);
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A caller that names an ordinary screen while settings mode is up is not asking to go back to
|
||||
/// wherever settings was entered from — it is asking for that screen, which wins over "Back to
|
||||
/// application" restoring anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void NavigatingToAnOrdinaryScreenWhileInSettingsMode_LeavesSettingsModeOutright()
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeFalse();
|
||||
shell.Screen.ShouldBe(ShellScreen.Hosts);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The confirmation card moved from the old bare Preferences screen to the Account settings page — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> — and this is the one command both the rail's
|
||||
/// popover Logout row and settings mode's own bottom Logout row call, so there is exactly one place the
|
||||
/// card is armed from.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SignOutFromPopover_EntersSettingsOnAccountAndArmsTheConfirmation()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.SignOutFromPopoverCommand.Execute(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- v5c-3: the importer, inside settings mode ----
|
||||
//
|
||||
// Import.dc.html draws the importer over the Preferences page, with SettingsNav still lit on
|
||||
// Preferences — so ActiveSettingsPage never actually leaves SettingsPage.Preferences; only
|
||||
// MainWindowViewModel.IsImportOpen and IsSettingsPreferencesContentShowing move. See ShowScreen's own
|
||||
// translation of ShellScreen.Import, which is the Preferences page's "OPEN IMPORTER" row and every other
|
||||
// caller that used to land on the old bare screen.
|
||||
|
||||
[Fact]
|
||||
public void ShowingImport_OpensTheImporterOverThePreferencesPage()
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences, "SettingsNav stays lit on Preferences");
|
||||
shell.IsSettingsPreferencesPage.ShouldBeTrue();
|
||||
shell.IsImportOpen.ShouldBeTrue();
|
||||
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the importer is drawn over it, not beside it");
|
||||
}
|
||||
|
||||
/// <remarks>The titlebar's own "Back to preferences": closes the importer without leaving settings mode.</remarks>
|
||||
[Fact]
|
||||
public void CloseImport_ReturnsToPreferencesWithoutLeavingSettingsMode()
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||
|
||||
shell.CloseImportCommand.Execute(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences);
|
||||
shell.IsImportOpen.ShouldBeFalse();
|
||||
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate.</remarks>
|
||||
[Fact]
|
||||
public async Task TheImporterScreensCancelButton_ClosesItTheSameWayTheTitlebarDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||
shell.IsImportOpen.ShouldBeTrue();
|
||||
|
||||
shell.ImportScreen!.CancelCommand.Execute(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeTrue("Cancel backs out to Preferences, not out of Settings altogether");
|
||||
shell.IsImportOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Naming a settings page — including Preferences again — while the importer is up is a request for that
|
||||
/// page, not for whatever was drawn over it last time. Covers the nav rail's own Preferences row as well
|
||||
/// as every other page.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void EnteringAnySettingsPageWhileImportIsOpen_ClosesTheImporter()
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||
shell.IsImportOpen.ShouldBeTrue();
|
||||
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
||||
|
||||
shell.IsImportOpen.ShouldBeFalse();
|
||||
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingSettingsModeWhileImportIsOpen_ClosesTheImporterToo()
|
||||
{
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
||||
|
||||
shell.LeaveSettingsCommand.Execute(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeFalse();
|
||||
shell.IsImportOpen.ShouldBeFalse("a stale flag here would reopen the importer the next time Settings is entered");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="MainWindowViewModel.Issuer"/> is new in v5c, for the Account settings page's SIGN-IN row —
|
||||
/// see the property's own remark. <c>MeResponse.Issuer</c> was already being cached into
|
||||
/// <c>StoredUnlockMaterial</c> for no reader before this wave; this is the first assertion that it also
|
||||
/// reaches the shell.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
|
||||
}
|
||||
|
||||
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectAsync()
|
||||
{
|
||||
|
||||
@@ -1910,6 +1910,85 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
.ShouldBe("Platform");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
|
||||
/// <c>RenameVaultRow</c>/<c>DeleteVaultRow</c> select the row first and then hand off to the commands
|
||||
/// above — this proves the hand-off selects the right vault and reaches the same form.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenameVaultRow_SelectsTheCardThenOpensTheSameFormRenameVaultDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
var shared = vaults.SelectedVault!;
|
||||
|
||||
// A different vault selected first, so the row argument is what actually decides which one the
|
||||
// form is about rather than whatever was already selected.
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
|
||||
|
||||
vaults.RenameVaultRowCommand.Execute(shared);
|
||||
|
||||
vaults.SelectedVault.ShouldBe(shared);
|
||||
vaults.IsRenamingVault.ShouldBeTrue();
|
||||
vaults.EditVaultName.ShouldBe("Platform secrets");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVaultRow_SelectsTheCardThenArmsTheSameConfirmationDeleteVaultDoes()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
var shared = vaults.SelectedVault!;
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
|
||||
|
||||
vaults.DeleteVaultRowCommand.Execute(shared);
|
||||
|
||||
vaults.SelectedVault.ShouldBe(shared);
|
||||
vaults.IsConfirming.ShouldBeTrue();
|
||||
vaults.PendingAction!.Question.ShouldContain("Platform secrets");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The settings page's members panel: pressing the card's members icon on a vault that is not already
|
||||
/// selected has to select it first, or the panel would open over whichever vault the list last landed
|
||||
/// on rather than the one that was actually clicked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpenMembersPanel_SelectsTheVaultItWasOpenedForAndReadsItsMembers()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
var shared = vaults.SelectedVault!;
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
|
||||
vaults.IsMembersPanelOpen.ShouldBeFalse();
|
||||
|
||||
vaults.OpenMembersPanelCommand.Execute(shared);
|
||||
|
||||
vaults.IsMembersPanelOpen.ShouldBeTrue();
|
||||
vaults.SelectedVault.ShouldBe(shared);
|
||||
|
||||
// OpenMembersPanel's own selection assignment starts a read nothing here can await — see
|
||||
// OnSelectedVaultChanged — so this reads it again through LoadAsync, which is awaited, rather than
|
||||
// racing the fire-and-forget one.
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.Members.ShouldContain(member => member.IsSelf);
|
||||
|
||||
vaults.CloseMembersPanelCommand.Execute(null);
|
||||
vaults.IsMembersPanelOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
|
||||
|
||||
Reference in New Issue
Block a user