diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml
index f906eb0..c36b9fc 100644
--- a/src/DodoSSH.Client.App/App.axaml
+++ b/src/DodoSSH.Client.App/App.axaml
@@ -1261,6 +1261,57 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/ImportScreen.axaml b/src/DodoSSH.Client.App/Views/ImportScreen.axaml
index daff5e7..6c0c2d1 100644
--- a/src/DodoSSH.Client.App/Views/ImportScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/ImportScreen.axaml
@@ -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.
-->
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs b/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs
index 74e6995..137d62c 100644
--- a/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs
@@ -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 ~/.ssh/config .
///
///
-/// 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: MainWindow.axaml.cs 's KeyboardHome 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 KeyboardTarget property here would be dead code nothing reads.
///
internal sealed partial class ImportScreen : UserControl
{
@@ -24,9 +27,6 @@ internal sealed partial class ImportScreen : UserControl
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
}
- /// Where the keyboard lands when this screen is the one showing.
- internal IInputElement KeyboardTarget => this;
-
private void OnTickChanged(object? sender, RoutedEventArgs e)
{
if (DataContext is ImportViewModel import)
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 155f3ec..df447bc 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -91,12 +91,13 @@
-
+
-
+
-
+
+
+
@@ -217,30 +224,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -319,6 +317,22 @@
+
+
+
-
+
+
+
+
-
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index d464df8..2c2f472 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -111,11 +111,13 @@ internal sealed partial class MainWindow : Window
///
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);
}
diff --git a/src/DodoSSH.Client.App/Views/NavRail.axaml b/src/DodoSSH.Client.App/Views/NavRail.axaml
index 6a93251..b565fb9 100644
--- a/src/DodoSSH.Client.App/Views/NavRail.axaml
+++ b/src/DodoSSH.Client.App/Views/NavRail.axaml
@@ -249,12 +249,15 @@
-
+
@@ -282,8 +285,8 @@
diff --git a/src/DodoSSH.Client.App/Views/NavRail.axaml.cs b/src/DodoSSH.Client.App/Views/NavRail.axaml.cs
index 38747f7..df6e415 100644
--- a/src/DodoSSH.Client.App/Views/NavRail.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/NavRail.axaml.cs
@@ -48,30 +48,42 @@ internal sealed partial class NavRail : UserControl
}
}
- /// Settings and Preferences both land here — see the remark in the markup.
+ /// Opens settings mode on its default landing page — see the remark in the markup.
+ private void OnPopoverSettingsPressed(object? sender, RoutedEventArgs e)
+ {
+ ClosePopover();
+
+ if (DataContext is MainWindowViewModel shell)
+ {
+ shell.EnterSettingsCommand.Execute(SettingsPage.General);
+ }
+ }
+
+ /// Opens settings mode on its Preferences page.
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
- shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
+ shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
}
}
+ /// Opens settings mode on its Vaults page.
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
- shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
+ shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
}
}
///
- /// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
- /// .
+ /// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
+ /// seen — see .
///
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
{
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
deleted file mode 100644
index 3c8a36a..0000000
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
+++ /dev/null
@@ -1,275 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml.cs b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml.cs
deleted file mode 100644
index 3c40047..0000000
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Avalonia.Controls;
-
-namespace DodoSSH.Client.App.Views;
-
-/// Preferences: what this build can actually change, and a list of what it cannot.
-internal sealed partial class PreferencesScreen : UserControl
-{
- public PreferencesScreen() => InitializeComponent();
-}
diff --git a/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml b/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml
new file mode 100644
index 0000000..0015440
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml.cs
new file mode 100644
index 0000000..d93cdef
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsAccountPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's Account page: the read-only profile, the sign-in fact, and signing out.
+internal sealed partial class SettingsAccountPage : UserControl
+{
+ public SettingsAccountPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml b/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml
new file mode 100644
index 0000000..d07c6ad
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml.cs
new file mode 100644
index 0000000..6f65dbb
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsGeneralPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's General page: the Updates card, and the refused items as an essay.
+internal sealed partial class SettingsGeneralPage : UserControl
+{
+ public SettingsGeneralPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml b/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml
new file mode 100644
index 0000000..02caf6d
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml.cs
new file mode 100644
index 0000000..28edd4c
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsGroupsPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Groups: every group, and the hosts filed under each — see the remark at the top of the markup.
+internal sealed partial class SettingsGroupsPage : UserControl
+{
+ public SettingsGroupsPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsNav.axaml b/src/DodoSSH.Client.App/Views/SettingsNav.axaml
new file mode 100644
index 0000000..4613531
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsNav.axaml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsNav.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsNav.axaml.cs
new file mode 100644
index 0000000..e5112cb
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsNav.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's own 340px rail — General/Vaults/Account, Security/Preferences, and Logout.
+internal sealed partial class SettingsNav : UserControl
+{
+ public SettingsNav() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml b/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml
new file mode 100644
index 0000000..4812d36
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml.cs
new file mode 100644
index 0000000..fa9b9df
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsPreferencesPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's Preferences page: the Terminal and Keychain cards.
+internal sealed partial class SettingsPreferencesPage : UserControl
+{
+ public SettingsPreferencesPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml
new file mode 100644
index 0000000..de290e1
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs
new file mode 100644
index 0000000..c1b9af3
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.
+internal sealed partial class SettingsSecurityPage : UserControl
+{
+ public SettingsSecurityPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml b/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml
new file mode 100644
index 0000000..eb94b91
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml.cs
new file mode 100644
index 0000000..9605def
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsTagsPage.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Tags: every tag, and how many hosts wear each — see the remark at the top of the markup.
+internal sealed partial class SettingsTagsPage : UserControl
+{
+ public SettingsTagsPage() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
new file mode 100644
index 0000000..b1e151a
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
new file mode 100644
index 0000000..d48ec3e
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
@@ -0,0 +1,47 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Settings mode's own titlebar — see the remark in the markup for why it is not TitleBar itself.
+internal sealed partial class SettingsTitleBar : UserControl
+{
+ public SettingsTitleBar() => InitializeComponent();
+
+ private Window? Host => TopLevel.GetTopLevel(this) as Window;
+
+ /// Left button only, and only on a press nothing inside the bar has already handled.
+ 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;
+ }
+ }
+
+ /// Both the button and a double-click on the bar arrive here, as Windows convention expects.
+ 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();
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml b/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml
new file mode 100644
index 0000000..a9b87f2
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml
@@ -0,0 +1,407 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml.cs
new file mode 100644
index 0000000..58e9a62
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsVaultsPage.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using DodoSSH.Client.Shell.ViewModels;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Vaults, restyled into the settings page idiom — see the remark at the top of the markup.
+internal sealed partial class SettingsVaultsPage : UserControl
+{
+ public SettingsVaultsPage() => InitializeComponent();
+
+ private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
+
+ ///
+ /// Only a press on the wash itself, the same test QuickConnect.OnBackdropPressed 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.
+ ///
+ 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;
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsView.axaml b/src/DodoSSH.Client.App/Views/SettingsView.axaml
new file mode 100644
index 0000000..19b8d21
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsView.axaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsView.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsView.axaml.cs
new file mode 100644
index 0000000..858e586
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SettingsView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The settings mode's whole chrome: its own titlebar, its own 340px rail, and whichever of its five pages
+/// names.
+///
+internal sealed partial class SettingsView : UserControl
+{
+ public SettingsView() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/VaultsScreen.axaml b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml
deleted file mode 100644
index 6a8582d..0000000
--- a/src/DodoSSH.Client.App/Views/VaultsScreen.axaml
+++ /dev/null
@@ -1,362 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs
deleted file mode 100644
index 71defe9..0000000
--- a/src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Avalonia.Controls;
-
-namespace DodoSSH.Client.App.Views;
-
-/// Vaults: which there are, who is in each, and who holds a key to it.
-internal sealed partial class VaultsScreen : UserControl
-{
- public VaultsScreen() => InitializeComponent();
-}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
index 25ba49e..439d600 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
@@ -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;
+ /// What HostName said, on its own — the v5c table's own column, beside
+ /// and rather than folded into .
+ internal string Hostname => host.Hostname;
+
+ /// What User said, or an em dash where the entry named none.
+ internal string User => host.Username is { Length: > 0 } user ? user : "—";
+
+ /// What Port said, defaulting to 22 the same way does.
+ internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
+
/// Whether a host with this address is already in the keychain.
internal bool AlreadyPresent { get; }
@@ -61,6 +72,23 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal bool HasBadge => AlreadyPresent;
+ ///
+ /// 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 Host 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 , are the amber case instead — a flattened ProxyJump or
+ /// a dropped directive is exactly the kind of thing "quieter than the file" that ImportScreen.axaml 's
+ /// own remark says has to be told before it looks like data loss.
+ ///
+ internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
+
+ /// The warned case wins over "already here" — a warning is the more actionable of the two facts.
+ internal bool IsMeaningWarned => HasWarnings;
+
+ internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
+
+ internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
+
/// Whether this row's ssh_config entry named a key at all.
///
/// 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
/// .
///
///
-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 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";
+ /// Whether every row is ticked — what the v5c table's header tick-all box shows.
+ internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
+
+ ///
+ /// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
+ ///
+ ///
+ /// Two real facts and nothing invented — and . The
+ /// fuller narrative belongs to , 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.
+ ///
+ internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
+
+ ///
+ /// The key-material card's own always-visible sentence, ahead of the tick.
+ ///
+ ///
+ /// The count is hosts naming a key file, not raw IdentityFile lines — a fact
+ /// 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 ImportViewModel 's own long-standing
+ /// claim, restated in the design's words after checking it against SshConfigLocator : 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 is the only place that ever calls
+ /// — never — so nothing is read until
+ /// IMPORT is pressed.
+ ///
+ 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.";
+ }
+ }
+
+ /// The vault every import lands in — see .
+ ///
+ /// Fixed rather than offered as a picker: the import goes through the same
+ /// session.ActiveVaultId 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.
+ ///
+ internal string VaultName => vault.VaultName;
+
+ /// The v5c footer's own sentence: how many are ticked, out of how many, and where they land.
+ internal string SelectionSummary
+ {
+ get
+ {
+ var noun = Rows.Count == 1 ? "entry" : "entries";
+
+ return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
+ }
+ }
+
/// Reads the file and shows what it found. Writes nothing.
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
@@ -389,6 +480,16 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal void NoteSelectionChanged() => RaiseListState();
+ /// The footer's own Cancel button: back to the Preferences page, nothing stored.
+ ///
+ /// A delegate rather than a reference up to MainWindowViewModel , on the same reasoning
+ /// VaultViewModel 's own copyToClipboard 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.
+ ///
+ [RelayCommand]
+ private void Cancel() => onCancel?.Invoke();
+
///
/// 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));
}
+
+ /// The header's own status line is a function of alone.
+ partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
index c5b0aca..486298c 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
@@ -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)
+ /// Where the pins, the reload and the withdrawal all actually live.
+ ///
+ /// What the v5c header's own back arrow does — a delegate rather than a reference up to
+ /// MainWindowViewModel , on the same reasoning ImportViewModel 's own onCancel is one:
+ /// this type has no business knowing ShellScreen exists. Null in a layout test that builds this
+ /// directly makes the button a no-op rather than a crash.
+ ///
+ 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();
+ ///
+ /// 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 reads off
+ /// rather than : it is a fact about the list, not about
+ /// whatever somebody last typed into the filter.
+ ///
+ 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);
}
+ /// Puts the selected pin's fingerprint on the clipboard. Forwarded, like .
+ [RelayCommand]
+ private async Task CopyFingerprintAsync()
+ {
+ if (Selected is null)
+ {
+ return;
+ }
+
+ await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
+ }
+
+ /// The header's own back arrow: to the Keychain screen this list was pulled out of.
+ [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));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 73dc91e..0af025e 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -206,6 +206,54 @@ internal enum ShellSurface
Terminal = 1,
}
+///
+/// Which page the settings mode is showing, while is
+/// not null.
+///
+///
+///
+/// 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 SettingsView.axaml and the settings-mode remark on
+/// . This is a second, orthogonal notion of "where am I"
+/// from , not a replacement for it: and
+/// still set to the matching member, so
+/// every existing binding and test that asks "is the screen Preferences" keeps its answer. ,
+/// and have no counterpart — nothing
+/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
+///
+///
+/// v5c-2: Groups and Tags joined. The design's rail lists them beside Security and Preferences; v5c-1
+/// omitted both from SettingsView.axaml 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
+/// 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.
+///
+///
+internal enum SettingsPage
+{
+ /// Updates, and the refused items from the design's General page, as an essay.
+ General = 0,
+
+ /// The vaults themselves and the people in them — the existing screen.
+ Vaults = 1,
+
+ /// The signed-in profile, the sign-in fact, and signing out of this machine.
+ Account = 2,
+
+ /// The end-to-end explainer, Windows Hello, and approved host keys.
+ Security = 3,
+
+ /// This machine's terminal and keychain settings — the existing screen.
+ Preferences = 4,
+
+ /// Every group, and the hosts filed under each — the existing group commands, given their own page.
+ Groups = 5,
+
+ /// Every tag, and how many hosts wear each — the existing tag commands, given their own page.
+ Tags = 6,
+}
+
///
/// The shell: get to an unlocked vault, then hand over to .
///
@@ -368,6 +416,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private bool disposed;
+ ///
+ /// Where goes back to — captured once, on the turn settings mode is
+ /// entered, and not touched again until it is left.
+ ///
+ ///
+ /// Not re-captured on every 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.
+ ///
+ private ShellScreen settingsReturnScreen;
+
+ ///
+ private ShellSurface settingsReturnSurface;
+
///
/// Establishes a connection to a server.
///
@@ -660,6 +722,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? email;
+ ///
+ /// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
+ /// settings page's SIGN-IN row.
+ ///
+ ///
+ /// v5c: MeResponse.Issuer was already being cached into StoredUnlockMaterial.Issuer by
+ /// , for no reader — nothing before this wave surfaced it. Set from the
+ /// same two places and are, in ,
+ /// so the three can never drift out of step with which account is actually signed in.
+ ///
+ [ObservableProperty]
+ private string? issuer;
+
/// Two letters for the rail's avatar circle, read off the signed-in display name.
///
/// The first letter of the first two words in — 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 and the other
/// did not.
///
- 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
///
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
- ///
- internal bool IsImportScreen => Screen is ShellScreen.Import;
-
///
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;
}
+ ///
+ /// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
+ /// see SettingsView.axaml . Not null exactly while that chrome, rather than the ordinary titlebar
+ /// and nav rail, is what MainWindow.axaml draws.
+ ///
+ ///
+ /// A second notion of "where am I" from rather than a replacement for it — see the
+ /// remark on . Two of its five members,
+ /// and , keep in step with the matching
+ /// 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.
+ ///
+ [ObservableProperty]
+ private SettingsPage? activeSettingsPage;
+
+ /// Whether the settings chrome, rather than the ordinary one, is what the window is drawing.
+ internal bool IsSettingsMode => ActiveSettingsPage is not null;
+
+ ///
+ internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;
+
+ ///
+ internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;
+
+ ///
+ internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;
+
+ ///
+ internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;
+
+ ///
+ internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;
+
+ ///
+ internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;
+
+ ///
+ internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;
+
+ ///
+ /// Whether the importer is showing over the Preferences page, inside settings mode.
+ ///
+ ///
+ /// A flag layered on top of rather than a
+ /// member of its own — Import.dc.html draws SettingsNav lit on Preferences the whole time the
+ /// importer is up, which this makes true for free: never leaves
+ /// , so and the nav row it
+ /// drives stay exactly as they were. What moves is only the content column, via
+ /// , and the titlebar's own back label — see
+ /// SettingsTitleBar.axaml .
+ ///
+ [ObservableProperty]
+ private bool isImportOpen;
+
+ ///
+ /// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
+ /// content column shows.
+ ///
+ 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));
+
+ /// Enters settings mode on a page, remembering where "Back to application" returns to.
+ ///
+ /// The return screen is captured only on the way in from outside settings mode — see
+ /// — so switching between settings pages, which calls this
+ /// repeatedly, cannot overwrite it with another settings page.
+ ///
+ /// 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.
+ ///
+ ///
+ [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;
+ }
+
+ ///
+ /// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
+ /// 's translation of for every other caller.
+ ///
+ private void OpenImport()
+ {
+ EnterSettings(SettingsPage.Preferences);
+ IsImportOpen = true;
+ }
+
+ /// "Back to preferences": closes the importer without leaving settings mode.
+ [RelayCommand]
+ private void CloseImport() => IsImportOpen = false;
+
+ /// "Back to application": leaves settings mode for wherever it was entered from.
+ [RelayCommand]
+ private void LeaveSettings()
+ {
+ if (ActiveSettingsPage is null)
+ {
+ return;
+ }
+
+ ActiveSettingsPage = null;
+ IsImportOpen = false;
+ Screen = settingsReturnScreen;
+ Surface = settingsReturnSurface;
+ }
+
/// Switches to the terminal surface.
///
///
@@ -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;
- /// Starts a sign-out from the rail's user popover, from wherever the window is showing.
+ ///
+ /// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever
+ /// the window is showing.
+ ///
///
/// only arms ; the confirmation itself is drawn
- /// inline on the Preferences screen while the vault is unlocked — see PreferencesScreen.axaml —
- /// and nowhere else, because MainWindow.axaml 's own copy of SignOutCard is inside the
+ /// inline on the Account settings page while the vault is unlocked — see SettingsAccountPage.axaml
+ /// — and nowhere else, because MainWindow.axaml 's own copy of SignOutCard is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// 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
/// .
+ ///
+ /// v5c: went to ShellScreen.Preferences 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.
+ ///
///
[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));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index d33ace4..cf5634b 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -123,6 +123,9 @@ internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
/// What the row says under the name.
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
+
+ /// The single letter the settings page's card draws on this group's tile.
+ internal string Initial => Label.Length > 0 ? Label[..1].ToUpperInvariant() : "?";
}
/// An entry in the host editor's group picker.
@@ -4667,8 +4670,20 @@ internal sealed partial class VaultViewModel(
}
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
+
+ OnPropertyChanged(nameof(HasTagItems));
}
+ ///
+ /// Whether there is at least one tag to manage.
+ ///
+ ///
+ /// v5c-2: the settings Tags page's empty state. Named apart from HostRowViewModel.HasTags , which
+ /// answers a different question — whether one host wears any — rather than reusing that name at this
+ /// level and relying on which x:DataType a binding happens to be inside to tell the two apart.
+ ///
+ internal bool HasTagItems => Tags.Count > 0;
+
///
/// 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));
+ }
+
+ ///
+ /// How many hosts, across every shown vault, carry no group that still exists.
+ ///
+ ///
+ /// v5c: the settings Groups page's "No group" footer row. The same dangling-reference reading
+ /// 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.
+ ///
+ 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)));
+ }
}
///
@@ -9881,6 +9918,35 @@ internal sealed partial class VaultViewModel(
Status = $"Renaming {row.Label}.";
}
+ ///
+ /// Opens the tag editor for a specific row, from the settings page's per-row edit icon.
+ ///
+ ///
+ /// A thin wrapper around rather than a second implementation of what it does. The
+ /// keychain screen's own table drives off a ListBox selection; the settings
+ /// page draws one card per tag with no such selection to lean on, so this puts the row on
+ /// first and then asks the command that already knows how to open it — the
+ /// same trick 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.
+ ///
+ /// The tag to edit.
+ [RelayCommand]
+ private void EditTagRow(TagRowViewModel? row)
+ {
+ SelectedTag = row;
+ EditTagCommand.Execute(null);
+ }
+
+ /// Arms the delete confirmation for a specific row, from the settings page's per-row delete icon.
+ ///
+ /// The tag to ask about deleting.
+ [RelayCommand]
+ private void DeleteTagRow(TagRowViewModel? row)
+ {
+ SelectedTag = row;
+ DeleteTagCommand.Execute(null);
+ }
+
/// Abandons the tag editor.
[RelayCommand]
private void CancelTagEdit()
@@ -10341,6 +10407,35 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
+ ///
+ /// Puts the selected pin's fingerprint on the clipboard.
+ ///
+ ///
+ /// The twin of 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.
+ ///
+ [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}.";
+ }
+
///
/// Opens a terminal on the selected host.
///
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
index bbb3043..8e0184a 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultsViewModel.cs
@@ -107,6 +107,9 @@ internal sealed record VaultRowViewModel(
};
internal bool HasState => State.Length > 0;
+
+ /// The single letter the settings page's card draws on this vault's tile.
+ internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
}
/// One member of a vault, as a row in the members table.
@@ -153,6 +156,32 @@ internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool Is
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
+ ///
+ /// The two letters the members panel draws on this row's avatar.
+ ///
+ ///
+ /// The same rule HostRowViewModel.Monogram 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.
+ ///
+ 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 = [' ', '-', '_', '.'];
+
/// Whether this member's role can be changed at all.
///
/// 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;
+ ///
+ /// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
+ ///
+ ///
+ /// 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
+ /// ; opening this panel over one already selected re-reads nothing.
+ ///
+ [ObservableProperty]
+ private bool isMembersPanelOpen;
+
+ /// Opens the members panel, selecting the vault it is about first if it is not selected already.
+ [RelayCommand]
+ private void OpenMembersPanel(VaultRowViewModel? vault)
+ {
+ if (vault is not null && !ReferenceEquals(vault, SelectedVault))
+ {
+ SelectedVault = vault;
+ }
+
+ IsMembersPanelOpen = true;
+ }
+
+ /// Closes the members panel.
+ [RelayCommand]
+ private void CloseMembersPanel() => IsMembersPanelOpen = false;
+
// ---- Creating a vault ----
[ObservableProperty]
@@ -824,6 +880,38 @@ internal sealed partial class VaultsViewModel(
Status = string.Empty;
}
+ /// Opens the rename form for a specific vault, from the settings page's per-card edit icon.
+ ///
+ /// A thin wrapper around 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.
+ ///
+ /// The vault to rename.
+ [RelayCommand]
+ private void RenameVaultRow(VaultRowViewModel? vault)
+ {
+ if (vault is not null)
+ {
+ SelectedVault = vault;
+ }
+
+ RenameVaultCommand.Execute(null);
+ }
+
+ /// Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.
+ ///
+ /// The vault to ask about deleting.
+ [RelayCommand]
+ private void DeleteVaultRow(VaultRowViewModel? vault)
+ {
+ if (vault is not null)
+ {
+ SelectedVault = vault;
+ }
+
+ DeleteVaultCommand.Execute(null);
+ }
+
///
/// Saves the renamed vault.
///
@@ -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;
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
index 377d494..2454569 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
@@ -55,6 +55,33 @@ internal static class LayoutHarness
/// v5b: 190 became 255, the design's own number rather than this bar's old approximation.
internal const double NavRailWidth = 255;
+ /// Settings mode's own rail, from SettingsNav.axaml — wider than .
+ internal const double SettingsNavWidth = 340;
+
+ ///
+ /// The width settings mode's own content column asks for, from the design's width:1100px .
+ ///
+ ///
+ /// A MaxWidth on the page, not a Width — see the same trade TitleBar.axaml 's own
+ /// search box makes with its own MaxWidth="514" , and for the identical reason:
+ /// 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.
+ ///
+ internal const double SettingsDesignContentWidth = 1100;
+
+ /// The width a settings page's content column actually gets at the window's minimum.
+ internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
+
+ /// What settings mode leaves a page between its own titlebar and the window's bottom edge.
+ ///
+ /// Settings mode has no status bar and no update banner of its own — see MainWindow.axaml 's own
+ /// remark on why both are hidden while IsSettingsMode is true — so this is
+ /// less only , not 's
+ /// own subtraction of too.
+ ///
+ internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
+
///
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
///
@@ -193,6 +220,18 @@ internal static class LayoutHarness
/// The width a full-width screen gets, once the nav rail has taken its column.
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
+ ///
+ /// v5c-3: what the S3 usage of TransfersScreen gets, now that MainWindow.axaml 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.
+ ///
+ internal static double BucketsScreenWidth =>
+ ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
+
+ ///
+ internal static double BucketsScreenHeight =>
+ ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
+
private static readonly HeadlessUnitTestSession Session =
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
index 9db1490..8c50eea 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
@@ -207,24 +207,35 @@ public sealed class NavRailTests : IAsyncLifetime
});
}
- /// Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.
+ ///
+ /// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
+ /// popover behind them.
+ ///
///
- /// Three s over one private body rather than a : ShellScreen
+ ///
+ /// v5c: these three used to land on a bare ShellScreen — 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 — and "Settings"
+ /// and "Preferences" are no longer the same click.
+ ///
+ ///
+ /// Three s over one private body rather than a : SettingsPage
/// is internal , and a public theory method may not carry an internal type in its signature.
+ ///
///
[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");
});
///
- /// Through Preferences rather than a direct SignOutCommand — see
- /// 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 SignOutCommand — see
+ /// 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.
///
[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();
});
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index b2588a1..0c955d2 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -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"));
- }
-
- ///
- /// 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.
- ///
- [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);
- /// Lays the import screen out at the size it gets beside the nav rail.
- private Task MeasureImportAsync(
- Action> 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);
-
- ///
- /// An import view model that has scanned a real file, so the table has rows in it.
- ///
- ///
- /// 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.
- ///
- private async Task 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);
- }
- }
-
/// Lays the host keys screen out at the size it gets beside the nav rail.
private Task MeasurePinsAsync(
Action> assert,
@@ -2142,12 +2043,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
- /// Lays the S3 usage of TransfersScreen out at the plain-screen budget it actually gets.
+ /// Lays the S3 usage of TransfersScreen out at the budget it actually gets.
///
- /// The same control as measures, at a different width and height: S3
- /// is "deliberately not given the session shell" — see MainWindow.axaml 's own remark on why — so it
- /// is measured at / instead,
- /// the same budget every other full-bleed page gets.
+ /// The same control as 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
+ /// MainWindow.axaml 's own remark on why — so it is measured at
+ /// / , 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 .
///
private Task MeasureBucketsAsync(Action> 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)));
- /// Lays the vaults screen out at the width it gets once the nav rail has taken its column.
///
- ///
- /// 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.
- ///
- ///
- /// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
- /// . 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.
- ///
+ /// 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 SettingsPagesLayoutTests beside every other settings
+ /// page's, measured against the settings content budget rather than this suite's full-chrome one.
///
- [Fact]
- public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
- OnTheVaultsScreenAsync(
- vaults => { },
- window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the vaults screen with members and key holders"));
-
- ///
- /// The rename form is drawn in place, above the members list, and pushes everything below it down.
- ///
- [Fact]
- public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
- OnTheVaultsScreenAsync(
- vaults => vaults.RenameVaultCommand.Execute(null),
- window => LayoutHarness.Unreachable(window)
- .ShouldBeEmpty("the vaults screen with the rename form open"));
-
- ///
- /// 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.
- ///
- [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"));
-
- ///
- /// 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.
- ///
- [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"));
-
- ///
- ///
- /// A real VaultsViewModel 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.
- ///
- ///
- /// 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 CreateTeamVaultAsync rather than poked into the cache, so the row
- /// being measured is one the application could actually produce.
- ///
- ///
- /// 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.
- ///
- ///
- private async Task OnTheVaultsScreenAsync(
- Action arrange,
- Action 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> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs
new file mode 100644
index 0000000..910c0a7
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs
@@ -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;
+
+///
+/// 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.
+///
+///
+///
+/// The budget is by
+/// — the space beside SettingsNav '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 MaxWidth remark on
+/// for why the pages ask for 1100 at most rather than exactly.
+///
+///
+/// A real unlocked vault, on the same reasoning gives: the Security page
+/// binds KnownHostsScreen.Summary , which is null until a vault is open, and a stand-in vault would
+/// still have to be the real type for the compiled bindings to resolve at all.
+///
+///
+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;
+
+ ///
+ 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(StringComparer.Ordinal)),
+ Substitute.For(),
+ 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(),
+ CheapProfile)
+ {
+ State = ShellState.Unlocked,
+ Vault = vault,
+ AccountName = "Ripley Vega",
+ Email = "ripley@example.test",
+ Issuer = "https://sso.example.test/realms/dodotech",
+ };
+ }
+
+ ///
+ 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"));
+
+ /// The other shape the Account page takes — the sign-out confirmation card in place of the row.
+ [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"));
+ }
+
+ ///
+ /// Checking for updates on the General page is still the real UpdateViewModel command, not a
+ /// row a fidelity pass silently detached while restyling it into the card idiom.
+ ///
+ [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);
+
+ ///
+ /// 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.
+ ///
+ [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.
+
+ ///
+ /// 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: VaultRowViewModel.IsShared only asks whether the vault carries a team
+ /// id, which this sets without needing 's own (offline) connection.
+ ///
+ 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"));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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"));
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
+ {
+ await MeasureImportAsync(
+ faults => faults.ShouldBeEmpty("with a scanned list"),
+ await ScannedImportAsync());
+ }
+
+ // ---- Helpers ----
+
+ ///
+ /// 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 switches onto, not on the test
+ /// runner's own thread the factory is captured from.
+ ///
+ private Task MeasureAsync(Func page, Action> 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()
+ .First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));
+
+ ///
+ /// Lays the importer out at the budget settings mode's own content column actually gets.
+ ///
+ ///
+ /// Not built through : every other settings page is typed to
+ /// and takes as its data context, where
+ /// ImportScreen is typed to ImportViewModel — the same split SettingsView.axaml
+ /// draws by handing it {Binding ImportScreen} rather than the shell itself.
+ ///
+ private Task MeasureImportAsync(
+ Action> 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);
+
+ ///
+ /// An import view model that has scanned a real file, so the table has rows in it.
+ ///
+ ///
+ /// 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.
+ ///
+ private async Task 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);
+ }
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index ed6eb59..7d1aac5 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -3815,6 +3815,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.KnownHostPins.ShouldHaveSingleItem();
}
+ ///
+ /// 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
+ /// CopyPublicKeyCommand . In full, because a shortened fingerprint cannot be compared against what
+ /// was published.
+ ///
+ [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");
+ }
+
+ /// The v5c screen's own restyle over forwards the same command.
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+
///
/// 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 ----
+
+ ///
+ /// The three real states a row can be in, and nothing else: a skipped Host pattern never becomes a
+ /// row at all (see SshConfigImport.SkippedPatterns ), so there is no fourth, invented "skipped" chip
+ /// to test for. A warned row wins over "already here" — see ImportRowViewModel.Meaning .
+ ///
+ [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");
+ }
+
+ /// The header's own tick-all box, over .
+ [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);
+ }
+
+ ///
+ /// 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
+ /// only ever being called from ImportAsync .
+ ///
+ [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 ----
///
@@ -4435,6 +4639,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
rows[3].ShouldBeOfType().Label.ShouldBe("stage-web");
}
+ ///
+ /// 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 FlattenIntoSections already gives the sidebar's own
+ /// heading, and UngroupedHostCount has to agree with it rather than invent a second definition.
+ ///
+ [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");
+ }
+
///
/// 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);
}
+ ///
+ /// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own
+ /// table does, so EditTagRow /DeleteTagRow 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.
+ ///
+ [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.
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// Settings mode collapses the terminal the same way any other page does —
+ /// and 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.
+ ///
+ [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();
+ }
+
+ ///
+ /// v5c: and 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. is kept in step with the two so every
+ /// existing binding written against either screen keeps its answer.
+ ///
+ /// Two s over one private body rather than a : ShellScreen
+ /// and SettingsPage are both internal , and a public theory method may not carry an
+ /// internal type in its signature.
+ ///
+ ///
+ [Fact]
+ public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() =>
+ ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences);
+
+ [Fact]
+ public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() =>
+ ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults);
+
+ ///
+ /// v5c-2: Groups and Tags joined settings mode with no counterpart — managing
+ /// either has never been its own screen before this wave — so there is no redirect to prove, only that
+ /// reaches each directly.
+ ///
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// The confirmation card moved from the old bare Preferences screen to the Account settings page — see
+ /// — 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.
+ ///
+ [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");
+ }
+
+ /// The titlebar's own "Back to preferences": closes the importer without leaving settings mode.
+ [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();
+ }
+
+ /// The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate.
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// is new in v5c, for the Account settings page's SIGN-IN row —
+ /// see the property's own remark. MeResponse.Issuer was already being cached into
+ /// StoredUnlockMaterial for no reader before this wave; this is the first assertion that it also
+ /// reaches the shell.
+ ///
+ [Fact]
+ public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow()
+ {
+ await UnlockedAsync();
+
+ shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
+ }
+
/// An unlocked vault with one selected host and a renderer attached.
private async Task ReadyToConnectAsync()
{
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index f0b60c6..83d8935 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -1910,6 +1910,85 @@ public sealed class VaultSharingTests : IAsyncLifetime
.ShouldBe("Platform");
}
+ ///
+ /// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
+ /// RenameVaultRow /DeleteVaultRow 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+
///
///
/// An address with no account is a refusal, and the sentence has to say what to do about it.