Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
@@ -1,4 +1,8 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth;
@@ -38,6 +42,41 @@ internal enum ShellState
Unlocked = 5,
}
/// <summary>
/// Which of the unlocked application's screens the nav rail is pointing at.
/// </summary>
/// <remarks>
/// <para>
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
/// three entries would make the eventual arrival of the other two look like a new product rather than a
/// milestone.
/// </para>
/// </remarks>
internal enum ShellScreen
{
/// <summary>The host list and the terminals, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer. Nothing implements it yet.</summary>
Transfers = 1,
/// <summary>Everything in the vault that is not a host.</summary>
Vault = 2,
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
Team = 3,
/// <summary>Preferences.</summary>
Preferences = 4,
}
/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
@@ -111,6 +150,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.signIn = signIn;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
}
[ObservableProperty]
@@ -134,6 +177,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private bool canForgetDevice;
/// <summary>
/// Whether this machine can neither register a device key nor withdraw one.
/// </summary>
/// <remarks>
/// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
/// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
/// register either — and only the second has something to take back. Both false at once is the one case
/// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
/// screen where the alternative is a section with no controls in it and no explanation.
/// </remarks>
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
/// <remarks>
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
@@ -212,6 +267,299 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>Whether a connection to the server is currently held.</summary>
internal bool IsOnline => connection is not null;
/// <summary>
/// Whether everything this machine has changed has reached the server.
/// </summary>
/// <remarks>
/// <para>
/// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
/// of that claim, and it is deliberately conservative: true only while a connection is held, the last
/// pass actually reached the server, and the outbox is empty.
/// </para>
/// <para>
/// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
/// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
/// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
/// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
/// </para>
/// <para>
/// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
/// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
/// machine can reach the server and has nothing stuck.
/// </para>
/// </remarks>
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
/// <summary>The same fact as a word, for the titlebar.</summary>
internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
{
(false, _, _) => "OFFLINE",
(true, true, _) => "UNREACHABLE",
(true, false, 0) => "SYNCED",
(true, false, 1) => "1 PENDING",
(true, false, var pending) => $"{pending} PENDING",
};
// ---- Which screen is showing ----
[ObservableProperty]
private ShellScreen screen;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsVaultScreen => Screen is ShellScreen.Vault;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsTeamScreen => Screen is ShellScreen.Team;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
/// screens all use the full width), and the quick-connect palette.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
/// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
/// while this is false; only the bounds are withheld. Removing the control from the tree would not be
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
/// <summary>Points the nav rail at a screen.</summary>
[RelayCommand]
private void ShowScreen(ShellScreen target) => Screen = target;
// ---- Open terminals ----
/// <summary>
/// Every terminal that has been opened this run, in the order they were opened.
/// </summary>
/// <remarks>
/// On the shell rather than on the vault, and that follows from the lock policy rather than from
/// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
/// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
/// admit to. This object is the window's data context for the life of the process, and so is this list.
/// </remarks>
internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];
[ObservableProperty]
private TerminalTabViewModel? selectedTab;
internal bool HasTabs => Tabs.Count > 0;
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
/// <summary>
/// Closes one terminal, ending its shell.
/// </summary>
/// <remarks>
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
/// </remarks>
[RelayCommand]
private async Task CloseTabAsync(TerminalTabViewModel tab)
{
if (tab is null)
{
return;
}
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
// way out is repainted as disconnected.
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, which is where the eye already is.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
// terminal just went would keep a lit dot until something else happened to move the selection.
RefreshConnectedHosts();
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
}
// ---- Quick connect ----
/// <summary>Whether the quick-connect palette is open over the window.</summary>
/// <remarks>
/// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
/// this is shell state rather than something a view could hold on its own.
/// </remarks>
[ObservableProperty]
private bool isSearching;
[ObservableProperty]
private string searchText = string.Empty;
/// <summary>
/// The hosts the palette is offering, best match first.
/// </summary>
/// <remarks>
/// The design's box says "search hosts · run command". Only the first half is here: a command palette
/// needs commands to run, and this application has no snippet or saved-command item type — see
/// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
/// worse than a box that promises only what it does.
/// </remarks>
internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedSearchResult;
/// <summary>Whether the palette has anything to offer.</summary>
/// <remarks>
/// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
/// </remarks>
internal bool HasSearchResults => SearchResults.Count > 0;
/// <summary>Opens the palette, or closes it if it is already open.</summary>
[RelayCommand]
private void ToggleSearch()
{
if (IsSearching)
{
CloseSearch();
return;
}
if (!IsUnlocked)
{
return;
}
SearchText = string.Empty;
RefreshSearchResults();
IsSearching = true;
}
/// <summary>Dismisses the palette without connecting.</summary>
[RelayCommand]
private void CloseSearch()
{
IsSearching = false;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>
/// Selects the highlighted host and connects to it.
/// </summary>
/// <remarks>
/// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
/// palette inherits every refusal that path already makes — a dangling key binding, a host with no
/// username, a host key that has changed. A second connect path would be a second place for those to be
/// forgotten.
/// </remarks>
[RelayCommand]
private async Task ConnectToSearchResultAsync()
{
if (Vault is not { } vault || SelectedSearchResult is not { } row)
{
return;
}
CloseSearch();
Screen = ShellScreen.Hosts;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
// generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
// own source — so passing this one would read as cancellation plumbing that is not there.
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <remarks>
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
/// that merely contains it, and both come before a match found only in the address. Typing three
/// characters of a name people use daily should not put that host third.
/// </remarks>
private void RefreshSearchResults()
{
SearchResults.Clear();
if (Vault is not { } vault)
{
SelectedSearchResult = null;
return;
}
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
{
SearchResults.Add(host);
}
SelectedSearchResult = SearchResults.FirstOrDefault();
OnPropertyChanged(nameof(HasSearchResults));
}
private static int Rank(HostRowViewModel host, string query)
{
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
{
return 0;
}
if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
{
return 1;
}
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 2
: int.MaxValue;
}
/// <summary>
/// Brings the schema up to date and works out which screen to show.
/// </summary>
@@ -285,6 +633,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
var outcome = await Provisioner()!
.RefreshAsync(ServerUrl, cancellationToken)
@@ -551,6 +900,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
// observable collections single-threaded.
@@ -615,6 +969,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
knownHosts.Close();
if (Vault is { } open)
@@ -731,16 +1087,138 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (oldValue is not null)
{
oldValue.SessionOpened -= OnVaultSessionOpened;
oldValue.PropertyChanged -= OnVaultPropertyChanged;
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
}
if (newValue is not null)
{
newValue.SessionOpened += OnVaultSessionOpened;
newValue.PropertyChanged += OnVaultPropertyChanged;
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
// disconnected — so without this the status dots go out once a minute underneath terminals that
// are still open. The rows belong to the vault and the connection state belongs to the shell,
// which is exactly why the shell has to repaint them rather than the vault carrying the flag.
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
}
RaiseSyncState();
}
/// <remarks>
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
/// </remarks>
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
|| string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
{
RaiseSyncState();
}
}
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
TerminalSessionOpened?.Invoke(this, e);
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
RefreshConnectedHosts();
private void RaiseSyncState()
{
OnPropertyChanged(nameof(IsFullySynced));
OnPropertyChanged(nameof(SyncLabel));
}
/// <remarks>
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
/// runs against a tab strip that already shows the session it is focusing.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
Tabs.Add(tab);
RaiseTabState();
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged. The page
// also activates a newly created pane on its own, so this is belt and braces for the first session
// and load-bearing for every one after it.
SelectedTab = tab;
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <remarks>
/// <para>
/// Fire-and-forget, and it has to be: this runs from a property setter, and a selection that awaited a
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
/// </para>
/// <para>
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
/// an already-open socket and returns immediately when there is no renderer.
/// </para>
/// </remarks>
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
{
foreach (var tab in Tabs)
{
tab.IsSelected = ReferenceEquals(tab, value);
}
RefreshConnectedHosts();
if (value is not null)
{
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
}
}
/// <summary>Brings one terminal's pane to the front.</summary>
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
/// <summary>
/// Marks a tab dead when its shell ends on its own.
/// </summary>
/// <remarks>
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
/// the scrollback, and the renderer has already written the reason into it.
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
RefreshConnectedHosts();
});
/// <summary>
/// Repaints the host list's status dots from the tab list.
/// </summary>
/// <remarks>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
/// wrong than a dot that goes dark when the vault is reopened.
/// </remarks>
private void RefreshConnectedHosts()
{
if (Vault is not { } vault)
{
return;
}
foreach (var host in vault.Hosts)
{
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
}
}
partial void OnLiveSessionCountChanged(int value)
{
@@ -756,5 +1234,39 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsShowingRecoveryCode));
OnPropertyChanged(nameof(IsLocked));
OnPropertyChanged(nameof(IsUnlocked));
OnPropertyChanged(nameof(IsTerminalShowing));
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
}
}
/// <remarks>
/// Every screen flag, on every change, for the same reason the vault column raises all four of its
/// section flags: a rail lighting the current screen and a body showing it are one fact read from two
/// directions, and raising only the one that became true leaves the old button lit.
/// </remarks>
partial void OnScreenChanged(ShellScreen value)
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsTerminalShowing));
}
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
partial void OnCanRegisterDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
partial void OnCanForgetDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
partial void OnSearchTextChanged(string value) => RefreshSearchResults();
}
@@ -0,0 +1,56 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>
/// One open terminal, as a tab.
/// </summary>
/// <remarks>
/// <para>
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
/// is the WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
/// shell outlives every lock, and so does this.
/// </para>
/// </remarks>
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this is logged in as, and where.</param>
internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address)
: ObservableObject
{
internal uint SessionId { get; } = sessionId;
internal string Label { get; } = label;
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; } = address;
/// <summary>
/// Whether the shell behind this tab is still running.
/// </summary>
/// <remarks>
/// Cleared when the workspace says the session ended, never inferred from the tab being closed — closing
/// a tab removes it, and a removed tab has nothing left to report. A dead tab is kept on purpose: its
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
/// </remarks>
[ObservableProperty]
private bool isLive = true;
/// <summary>
/// Whether this is the tab whose pane is showing.
/// </summary>
/// <remarks>
/// A flag on the tab as well as a selection on the shell, because the strip is an
/// <c>ItemsControl</c> of buttons rather than a control that owns a selection — and a button has no
/// <c>:selected</c> pseudo-class to style against. The shell writes it; nothing else does.
/// </remarks>
[ObservableProperty]
private bool isSelected;
}
@@ -19,7 +19,7 @@ namespace DodoSSH.Client.App.ViewModels;
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
/// </remarks>
internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
internal sealed partial class HostRowViewModel(VaultItem<HostSecret> host) : ObservableObject
{
internal Guid EntityId => host.EntityId;
@@ -53,6 +53,24 @@ internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
/// <summary>
/// Whether a terminal is open on this host right now.
/// </summary>
/// <remarks>
/// <para>
/// The one thing on a host row that is not a property of the host. It is written by the shell, which owns
/// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one
/// holding the answer. The design's status dot is this, and it is the reason the row is observable at
/// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright.
/// </para>
/// <para>
/// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
/// would be a claim this application never checks.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isConnected;
}
/// <summary>What a host can authenticate with.</summary>
@@ -257,6 +275,8 @@ internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool
/// </remarks>
internal bool IsDialledByAHost { get; } = isDialledByAHost;
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
internal string Badge => IsDialledByAHost
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
: "no host uses this";
@@ -280,6 +300,21 @@ internal static class ItemBadge
};
}
/// <summary>
/// A terminal session that has just opened.
/// </summary>
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">The account and endpoint actually dialled.</param>
internal sealed class TerminalSessionEventArgs(uint sessionId, string label, string address) : EventArgs
{
internal uint SessionId { get; } = sessionId;
internal string Label { get; } = label;
internal string Address { get; } = address;
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
@@ -320,10 +355,10 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice)
/// </remarks>
internal enum VaultSection
{
/// <summary>The hosts to connect to, and the column's opening state.</summary>
Hosts,
/// <summary>Every kind at once, which is where the screen opens.</summary>
All,
/// <summary>The SSH keys those hosts authenticate with.</summary>
/// <summary>The SSH keys hosts authenticate with.</summary>
Keys,
/// <summary>The usernames and passwords they authenticate with instead.</summary>
@@ -333,6 +368,60 @@ internal enum VaultSection
KnownHosts,
}
/// <summary>What kind of thing a row in the vault table is.</summary>
internal enum VaultItemKind
{
/// <summary>An SSH key.</summary>
Key,
/// <summary>A stored username and password.</summary>
Credential,
/// <summary>A pinned host key.</summary>
KnownHost,
}
/// <summary>
/// One row of the vault table, whatever kind of item it is.
/// </summary>
/// <remarks>
/// <para>
/// The table has one shape and a <c>TYPE</c> column, which is what lets a single category show every kind
/// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row
/// rather than a replacement for one: selecting here sets the typed selection the editors and the delete
/// commands already act on, so nothing downstream had to learn about a second way of naming an item.
/// </para>
/// <para>
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> an item — whether a
/// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key
/// or the password itself. The same rule the three list templates already followed, now in one place where
/// it is harder to break by pointing a template at the obvious property.
/// </para>
/// </remarks>
/// <param name="Kind">Which of the three lists this came from.</param>
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
/// <param name="Name">The label the user gave it.</param>
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
/// <param name="Detail">What is known about it.</param>
/// <param name="Badge">Its sync state, or empty.</param>
/// <param name="HasUnsyncedChanges">
/// Whether this machine has a change to this item that the server has not accepted. Carried separately from
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
/// by comparing it against the literal "not synced" would break the day that wording improves.
/// </param>
internal sealed record VaultItemRowViewModel(
VaultItemKind Kind,
Guid EntityId,
string Name,
string Type,
string Detail,
string Badge,
bool HasUnsyncedChanges)
{
/// <summary>Whether this row has anything to say about its sync state.</summary>
internal bool HasBadge => Badge.Length > 0;
}
/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
@@ -395,8 +484,36 @@ internal sealed partial class VaultViewModel(
internal VaultSession Session => session;
/// <summary>The hosts to show, unpushed local state included.</summary>
/// <remarks>
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
/// host key list checks itself against, so a filter applied here would change what the application can
/// do rather than what it shows. <see cref="HostGroups"/> is the filtered view.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
/// <remarks>
/// A second collection rather than a filtered view over the first, because the sidebar's list has to be
/// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
/// terminal gives it back, and neither of those survives being split across several lists.
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>
/// What the sidebar's one group heading says.
/// </summary>
/// <remarks>
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
/// presented as structure. One heading, because one vault is reachable: the server denies access to
/// every vault that is not this user's own. See <c>docs/design-import-gaps.md</c>.
/// </remarks>
internal string HostsHeading => VaultName.ToUpperInvariant();
/// <summary>Whether the host list under the heading is folded away.</summary>
[ObservableProperty]
private bool areHostsExpanded = true;
/// <summary>The SSH keys to show, unpushed local state included.</summary>
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
@@ -424,12 +541,49 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private KnownHostRowViewModel? selectedKnownHost;
/// <summary>
/// What the sidebar's filter box holds.
/// </summary>
/// <remarks>
/// Matched against the label, the address and the notes, case-insensitively, because those are the three
/// things a person remembers a machine by. It narrows <see cref="HostGroups"/> only: the selection, the
/// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
/// unusable — only unlisted.
/// </remarks>
[ObservableProperty]
private string hostFilter = string.Empty;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private int pendingChanges;
/// <summary>
/// Whether the last synchronisation attempt failed to reach the server.
/// </summary>
/// <remarks>
/// <para>
/// Holding a connection object is not the same as being able to reach anything, and this is the
/// difference. <c>IVaultServer</c> is obtained once at sign-in and never dropped, so a laptop whose lid
/// has been shut all afternoon still has one — and the background pass swallows its socket errors on
/// purpose, which means nothing else would ever notice.
/// </para>
/// <para>
/// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has
/// not reached the server since lunch is exactly the sort of thing this project writes down instead of
/// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable
/// machine is the ordinary state of a laptop nobody has changed anything on.
/// </para>
/// <para>
/// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the
/// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic
/// would flash UNREACHABLE at every launch by somebody who is not.
/// </para>
/// </remarks>
[ObservableProperty]
private bool lastSyncFailed;
[ObservableProperty]
private int unreadableItems;
@@ -448,18 +602,123 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private VaultSection section;
/// <summary>Whether the hosts section is the one showing.</summary>
internal bool ShowsHosts => Section is VaultSection.Hosts;
/// <summary>Whether every kind is showing at once.</summary>
internal bool ShowsAll => Section is VaultSection.All;
/// <inheritdoc cref="ShowsHosts" />
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsKeys => Section is VaultSection.Keys;
/// <inheritdoc cref="ShowsHosts" />
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsCredentials => Section is VaultSection.Credentials;
/// <inheritdoc cref="ShowsHosts" />
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts;
/// <summary>
/// The rows the vault table is showing, for whichever category is selected.
/// </summary>
/// <remarks>
/// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from
/// storage — so it costs one pass over what is already decrypted in memory and can never disagree with
/// the lists the editors act on.
/// </remarks>
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
/// <summary>
/// The selected row of the vault table.
/// </summary>
/// <remarks>
/// Setting it sets the matching typed selection, which is what every editor and every delete command
/// reads. The typed selections stay the state; this is the way the table names one of them.
/// </remarks>
[ObservableProperty]
private VaultItemRowViewModel? selectedVaultItem;
/// <summary>What the vault screen's header calls the category showing.</summary>
internal string SectionTitle => Section switch
{
VaultSection.Keys => "SSH KEYS",
VaultSection.Credentials => "PASSWORDS",
VaultSection.KnownHosts => "HOST KEYS",
_ => "ALL ITEMS",
};
/// <summary>
/// What the header says about the category, under its name.
/// </summary>
/// <remarks>
/// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this
/// user's — so the second half is what this build can actually count instead: how many of the ones on
/// screen this machine has not managed to push yet.
/// <para>
/// Counted over the rows showing rather than over the whole outbox, which is what this said first and
/// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads
/// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen.
/// </para>
/// </remarks>
internal string SectionSummary
{
get
{
var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items";
var waiting = VaultItems.Count(row => row.HasUnsyncedChanges);
return waiting == 0 ? items : $"{items} · {waiting} not synced";
}
}
/// <summary>Everything in the vault except the hosts, which have their own screen.</summary>
internal int TotalItemCount => Keys.Count + Credentials.Count + KnownHostPins.Count;
internal bool HasVaultItems => VaultItems.Count > 0;
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>Whether the selected row is one with an editor behind it.</summary>
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
VaultItemKind.Key or VaultItemKind.Credential;
/// <summary>Whether the selected row is a pinned host key, which is edited by being withdrawn.</summary>
internal bool SelectedItemIsPin => SelectedVaultItem?.Kind is VaultItemKind.KnownHost;
/// <summary>What the detail pane calls the block under the chips.</summary>
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
{
VaultItemKind.KnownHost => "FINGERPRINT",
VaultItemKind.Credential => "ACCOUNT",
_ => "WHAT IS STORED",
};
internal bool HasUnreadableItems => UnreadableItems > 0;
/// <remarks>
/// A sentence rather than a number, because the number alone reads as a count of something you have
/// rather than of something you cannot open — and what to do about it is not guessable.
/// </remarks>
internal string UnreadableSummary => UnreadableItems == 1
? "1 item will not decrypt"
: $"{UnreadableItems} items will not decrypt";
/// <summary>What an empty category says instead of showing an empty grid.</summary>
internal string EmptySectionMessage => Section switch
{
VaultSection.Keys =>
"No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
VaultSection.Credentials =>
"No stored passwords yet. Add one to stop typing the same password into every connection.",
VaultSection.KnownHosts =>
"No host keys approved yet. One appears here the first time you accept a host's fingerprint.",
_ => "Nothing in the vault but your hosts. Add an SSH key or a password to stop typing one.",
};
/// <summary>Whether the category showing is one that can have something added to it.</summary>
/// <remarks>
/// Pins are the exception and always have been: one appears because somebody approved a fingerprint at
/// the moment of connecting, which is the only place it can be checked against what the operator
/// published. A form for typing one in would be a form for pasting whatever a man in the middle offered.
/// </remarks>
internal bool CanAddToSection => Section is not VaultSection.KnownHosts;
// ---- The editor ----
[ObservableProperty]
@@ -621,13 +880,20 @@ internal sealed partial class VaultViewModel(
/// Raised once a terminal session is open and its renderer has it.
/// </summary>
/// <remarks>
/// <para>
/// An event rather than a property because handing the terminal the keyboard is something that
/// happens, not something that is true: connecting a second host while one is already open has to
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
/// controls directly.
/// </para>
/// <para>
/// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The
/// vault is the only thing that knows what this session is <em>of</em> — a host's name is a decrypted
/// item — so the naming happens here and the tab list happens there.
/// </para>
/// </remarks>
internal event EventHandler? SessionOpened;
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
internal bool HasPendingHostKey => PendingHostKey is not null;
@@ -695,6 +961,9 @@ internal sealed partial class VaultViewModel(
UnreadableItems = unreadable;
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
// After all four lists, because the table is a projection of three of them.
RebuildVaultItems();
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
@@ -718,9 +987,59 @@ internal sealed partial class VaultViewModel(
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
RebuildVisibleHosts();
return listing.Unreadable;
}
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
/// <remarks>
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
/// keeps this method from undoing its own caller. <c>ListBox.SelectedItem</c> is two-way bound to
/// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> the list reacts to by
/// nulling its selection, and the binding writes that null straight back — silently, and before this
/// method's own refill has a chance to matter. <see cref="ReloadHostsAsync"/> restores the selection and
/// calls this immediately after, which used to mean every reload undid what it had just restored, and
/// every keystroke in the filter box did the same.
/// </remarks>
private void RebuildVisibleHosts()
{
var selected = SelectedHost;
VisibleHosts.Clear();
foreach (var host in Hosts.Where(Matches))
{
VisibleHosts.Add(host);
}
// Restored when it still matches, and explicitly cleared when it does not — rather than left alone
// and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
// the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
// property directly, and a host that is not on screen is not one any of them should act on.
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
}
/// <remarks>
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
/// wrote down about a machine is often the only place its purpose is recorded.
/// </remarks>
private bool Matches(HostRowViewModel row)
{
var filter = HostFilter.Trim();
if (filter.Length == 0)
{
return true;
}
return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes);
bool Contains(string? value) =>
value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
}
/// <returns>How many keys would not decrypt.</returns>
/// <remarks>
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
@@ -825,6 +1144,7 @@ internal sealed partial class VaultViewModel(
{
if (connection() is not { } server)
{
LastSyncFailed = true;
Status = "Offline. Changes are queued and will be sent after you sign in.";
return;
}
@@ -899,10 +1219,13 @@ internal sealed partial class VaultViewModel(
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Swallowed on purpose, and this is the one place in the view model where that is right: a
// laptop that has been closed all afternoon would otherwise replace whatever the user was
// reading with a socket error once a minute. The failure is not hidden — the account bar
// already shows when there is no connection, and pressing Sync reports the real reason.
// The message is swallowed on purpose, and this is the one place in the view model where that
// is right: a laptop that has been closed all afternoon would otherwise replace whatever the
// user was reading with a socket error once a minute. Pressing Sync still reports the reason.
//
// The *fact* is not swallowed, and that is the half that used to be missing. Recording it is
// what lets the titlebar stop claiming to be up to date with a server it cannot reach.
LastSyncFailed = true;
}
}
@@ -922,6 +1245,8 @@ internal sealed partial class VaultViewModel(
{
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
LastSyncFailed = false;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
@@ -980,7 +1305,7 @@ internal sealed partial class VaultViewModel(
return;
}
if (AnEditorIsInTheWay())
if (AVaultEditorIsInTheWay())
{
return;
}
@@ -997,12 +1322,11 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void NewHost()
{
if (AnEditorIsInTheWay())
if (AHostEditorIsInTheWay())
{
return;
}
Section = VaultSection.Hosts;
editingEntityId = null;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
@@ -1019,7 +1343,7 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost is not { } row || AnEditorIsInTheWay())
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
{
return;
}
@@ -1032,7 +1356,6 @@ internal sealed partial class VaultViewModel(
return;
}
Section = VaultSection.Hosts;
editingEntityId = row.EntityId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
@@ -1045,6 +1368,64 @@ internal sealed partial class VaultViewModel(
Status = $"Editing {row.Label}.";
}
/// <summary>
/// Opens whichever editor the selected row belongs to.
/// </summary>
/// <remarks>
/// One button over three kinds, because the table is one table. It delegates rather than duplicating:
/// each kind's own command already knows how to refuse a read-only item and how to load an editor
/// without a second decryption, and a merged copy of that would be a second place to get it wrong.
/// </remarks>
[RelayCommand]
private void EditSelectedItem()
{
switch (SelectedVaultItem?.Kind)
{
case VaultItemKind.Key:
EditSelectedKeyCommand.Execute(null);
break;
case VaultItemKind.Credential:
EditSelectedCredentialCommand.Execute(null);
break;
default:
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
break;
}
}
/// <summary>Deletes whatever the selected row is.</summary>
/// <remarks>
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
/// </remarks>
[RelayCommand]
private async Task DeleteSelectedItemAsync()
{
switch (SelectedVaultItem?.Kind)
{
// Null rather than a token, and deliberately: a [RelayCommand] over a method whose only
// parameter is a CancellationToken generates ExecuteAsync(object? parameter) that ignores the
// argument and supplies a token from its own source. Passing one would read as plumbing.
case VaultItemKind.Key:
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
break;
case VaultItemKind.Credential:
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
break;
default:
break;
}
}
/// <summary>Folds the host list away, or brings it back.</summary>
[RelayCommand]
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
@@ -1129,7 +1510,7 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void NewKey()
{
if (AnEditorIsInTheWay())
if (AVaultEditorIsInTheWay())
{
return;
}
@@ -1149,7 +1530,7 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void EditSelectedKey()
{
if (SelectedKey is not { } row || AnEditorIsInTheWay())
if (SelectedKey is not { } row || AVaultEditorIsInTheWay())
{
return;
}
@@ -1254,7 +1635,7 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void NewCredential()
{
if (AnEditorIsInTheWay())
if (AVaultEditorIsInTheWay())
{
return;
}
@@ -1274,7 +1655,7 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void EditSelectedCredential()
{
if (SelectedCredential is not { } row || AnEditorIsInTheWay())
if (SelectedCredential is not { } row || AVaultEditorIsInTheWay())
{
return;
}
@@ -1627,7 +2008,7 @@ internal sealed partial class VaultViewModel(
authentication.Username,
authentication.Credential);
await workspace
var sessionId = await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
@@ -1637,7 +2018,18 @@ internal sealed partial class VaultViewModel(
// the document, which does nothing while the window's keyboard focus is still on the
// Connect button — so without this the first keystrokes of the session go to the shell's
// UI instead of the remote shell.
SessionOpened?.Invoke(this, EventArgs.Empty);
//
// The address is built from what was actually dialled rather than from the host's own fields,
// because a bound credential can supply the username — so a host saved with no username of its
// own still has one here, and it is the one the remote saw.
SessionOpened?.Invoke(
this,
new TerminalSessionEventArgs(
sessionId,
row.Label,
string.Create(
CultureInfo.InvariantCulture,
$"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}")));
}
catch (TimeoutException)
{
@@ -1901,49 +2293,61 @@ internal sealed partial class VaultViewModel(
};
/// <summary>
/// Whether an open editor has to be dealt with before the column does anything else.
/// Whether the host editor has to be dealt with before the sidebar starts another one.
/// </summary>
/// <remarks>
/// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
/// <c>HostSidebar</c>, on the Hosts screen, and nothing on the Vault screen shares its column or its
/// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start
/// editing a host — the two cannot even be looked at at the same time — so this no longer asks about
/// them. See <see cref="AVaultEditorIsInTheWay"/> for the reasoning this once shared with them, and why
/// splitting it was necessary rather than cosmetic: the earlier single check refused every host action
/// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an
/// editor the user could not see and no way to reach it without abandoning what they had just started
/// on the Hosts screen.
/// </remarks>
private bool AHostEditorIsInTheWay()
{
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
}
return IsEditing;
}
/// <summary>
/// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
/// </summary>
/// <remarks>
/// <para>
/// One editor open at a time, and <b>the reason has changed</b>. It used to be a layout constraint:
/// both editors sat in the same 340-pixel column as <c>Auto</c> rows and their desired heights together
/// exceeded it, so opening both pushed the lower one's Save and Cancel past the bottom edge. Sections
/// dissolved that — the two editors are now in different sections and only one section is ever laid out,
/// so two open editors no longer clip anything. That is measured, not assumed:
/// <c>BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut</c> is the same test that used to prove the
/// opposite.
/// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted
/// private key in a bound string for as long as it is open, and only <c>CancelKeyEdit</c> lets go of
/// it. Letting the rail move the category, or another editor open, with that editor still holding
/// material would leave a private key in a form nobody can see, with nothing on screen to say it is
/// there.
/// </para>
/// <para>
/// The rule stays for a better reason. The key editor holds a pasted private key in a bound string for
/// as long as it is open, and only <c>CancelKeyEdit</c> lets go of it. Letting the column move on with
/// that editor open would leave key material in a form nobody can see, with nothing on screen to say it
/// is there — so what was a workaround for a sizing problem is now a rule about not hiding a private key
/// from the person holding it.
/// </para>
/// <para>
/// Refused rather than resolved by closing the other editor, because closing it would silently discard
/// Refused rather than resolved by closing the open editor, because closing it would silently discard
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
/// else. One sentence and one click is the cheaper of the two.
/// </para>
/// <para>
/// One check rather than the pair this replaced. Each of those asked about the <i>other</i> editor,
/// which only made sense while the two lists shared a column; the question a selector asks is whether
/// anything is open at all, and every caller wants that same answer.
/// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
/// see <see cref="AHostEditorIsInTheWay"/> — and asking about it here is what used to leave three
/// quarters of this screen inert with a status line pointing at an editor the user was not looking at.
/// </para>
/// </remarks>
private bool AnEditorIsInTheWay()
private bool AVaultEditorIsInTheWay()
{
// Names the editor that is actually open, because "finish what you are editing" is useless advice
// in a column that shows one section: the thing to go back to may not be on screen.
Status = (IsEditing, IsEditingKey, IsEditingCredential) switch
Status = (IsEditingKey, IsEditingCredential) switch
{
(_, true, _) => "Finish or cancel the SSH key you are editing first.",
(_, _, true) => "Finish or cancel the credential you are editing first.",
(true, _, _) => "Finish or cancel the host you are editing first.",
(true, _) => "Finish or cancel the SSH key you are editing first.",
(_, true) => "Finish or cancel the credential you are editing first.",
_ => Status,
};
return IsEditing || IsEditingKey || IsEditingCredential;
return IsEditingKey || IsEditingCredential;
}
private void ClearKeyEditor()
@@ -2057,6 +2461,119 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
}
/// <remarks>
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
/// hosts already in memory, with no decryption and nothing on disk behind it.
/// </remarks>
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
/// <remarks>
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
/// them would put a pin nobody created between two things somebody did.
/// </remarks>
private void RebuildVaultItems()
{
var selectedId = SelectedVaultItem?.EntityId;
VaultItems.Clear();
if (Section is VaultSection.All or VaultSection.Keys)
{
foreach (var key in Keys)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.Key,
key.EntityId,
key.Label,
"SSH KEY",
key.Description,
key.Badge,
key.HasUnsyncedChanges));
}
}
if (Section is VaultSection.All or VaultSection.Credentials)
{
foreach (var credential in Credentials)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.Credential,
credential.EntityId,
credential.Label,
"PASSWORD",
credential.Description,
credential.Badge,
credential.HasUnsyncedChanges));
}
}
if (Section is VaultSection.All or VaultSection.KnownHosts)
{
foreach (var pin in KnownHostPins)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.KnownHost,
pin.EntityId,
pin.Label,
"HOST KEY",
pin.Fingerprint,
pin.Badge,
pin.HasUnsyncedChanges));
}
}
// The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(SectionSummary));
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
}
/// <remarks>
/// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving
/// the other two alone means switching category and back does not clear what an editor was pointing at.
/// </remarks>
partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value)
{
OnPropertyChanged(nameof(HasSelectedVaultItem));
OnPropertyChanged(nameof(SelectedItemIsEditable));
OnPropertyChanged(nameof(SelectedItemIsPin));
OnPropertyChanged(nameof(SelectedDetailHeading));
switch (value?.Kind)
{
case VaultItemKind.Key:
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId);
break;
case VaultItemKind.Credential:
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
break;
case VaultItemKind.KnownHost:
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == value.EntityId);
break;
default:
break;
}
}
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
partial void OnUnreadableItemsChanged(int value)
{
OnPropertyChanged(nameof(HasUnreadableItems));
OnPropertyChanged(nameof(UnreadableSummary));
}
/// <remarks>
/// Both, on every change. A selector that highlights the showing section and a column that shows the
/// selected one are the same fact read from two directions, and raising only the one that became true
@@ -2064,10 +2581,14 @@ internal sealed partial class VaultViewModel(
/// </remarks>
partial void OnSectionChanged(VaultSection value)
{
OnPropertyChanged(nameof(ShowsHosts));
OnPropertyChanged(nameof(ShowsAll));
OnPropertyChanged(nameof(ShowsKeys));
OnPropertyChanged(nameof(ShowsCredentials));
OnPropertyChanged(nameof(ShowsKnownHosts));
OnPropertyChanged(nameof(SectionTitle));
OnPropertyChanged(nameof(CanAddToSection));
RebuildVaultItems();
}
/// <remarks>