Give the application a settings area built from what really exists

This commit is contained in:
jaap-jan committed 2026-08-08 14:17:19 +02:00
1 parent 422d5ca10e
commit c8507b44fe
42 files changed
+3810 -1083

No files matched your search

+188 -119
View File
@@ -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="&#xE5CA;" 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>