diff --git a/README.md b/README.md index 9da831c..4ebf4c4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ Three consequences worth knowing before you read further: The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with [the E2EE trust model](docs/adr/0001-e2ee-trust-model.md). +The desktop client's interface was built from a design covering more product than exists yet — file +transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not +got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each +piece would land in and what the interface shows in its place. Nothing was rendered with invented data to +fill a screen. + ## Repository layout ``` @@ -64,6 +70,7 @@ src/ DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit tests/ one test project per source project docs/adr/ architecture decision records +docs/design-import-gaps.md what the client's design asked for and this build has not got ``` Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md new file mode 100644 index 0000000..a1dd79a --- /dev/null +++ b/docs/design-import-gaps.md @@ -0,0 +1,186 @@ +# What the design asked for and this build has not got + +The Avalonia client's interface was rebuilt from a Claude Design project — a five-screen spec covering +the chrome, hosts and terminals, file transfer, the vault, teams, and preferences. + +Most of it landed. This file is the rest: every element of that design with nothing behind it, which +project each piece would have to land in, and **what the shipped interface does instead**. That last +column is the important one. This codebase states its limits rather than implying them — the README does +it for revocation, for relay session recording and for what locking does not do — so nothing here was +rendered with invented data to make a screenshot look finished. Where a feature does not exist, the +interface either omits it or says so. + +Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect; it is +M2 and M3 arriving in a design before it arrives in the code. + +## The shape of the problem + +Three facts explain nearly every row below. + +**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing +else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No SFTP subsystem, no port forwarding, no ProxyJump. That +one fact removes the whole file-transfer screen, the `FORWARDS` chip, the status bar's port list, and every +`via bastion-eu` in the design. + +**Teams are schema and nothing else.** The `team` and `team_membership` tables exist from the first +migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or +writes any of it. `VaultAccessService.ResolveAsync` returns `Denied` for every vault that is not the +caller's own personal one. That removes the Teams screen entirely, and with it every role chip, scope and +"shared with" affordance the vault screen was drawn with. + +**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the +cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be +saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway. + +One thing cuts the other way and is worth knowing before planning any of this: **the wire protocol already +reserves the slots**. `SyncEntityType` (`src/DodoSSH.Contracts/SyncEntityType.cs`) has `HostGroup = 4`, +`Tag = 5`, `HostTag = 6`, `HostCredential = 7`, `Snippet = 8` and `PortForward = 9` — reserved, unused, and +already covered by the AAD resource-type table. Groups, tags and snippets are new item types on an existing +protocol rather than a protocol change. + +--- + +## Chrome — titlebar, nav rail, status bar + +| Design element | Layer | What it would take | What ships instead | +| --- | --- | --- | --- | +| Org chip `dodotech / platform` | contracts + server | An organisation name a client can read. `VaultSummary.TeamId` exists and is always null. | The vault's own name, and the account this machine is enrolled as. | +| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | +| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | +| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | +| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. | +| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. | +| Status bar `sftp · 2 transfers` | client-ssh | File transfer. See below. | Omitted. | +| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. | +| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. | +| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). | + +**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the +client area. `ExtendClientAreaChromeHints` was removed in Avalonia 12, so extending leaves the system +caption buttons and window title drawn on top of the application's own — two sets of window controls, and +"DodoSSH" printed twice. Measured, not assumed. + +--- + +## Hosts + terminal + +| Design element | Layer | What it would take | What ships instead | +| --- | --- | --- | --- | +| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. | +| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | One collapsible heading, named after the vault — the only grouping a host actually has. A second appears when a second vault becomes reachable. | +| Group badge `TEAM·PLATFORM` | server | Teams. | Omitted. | +| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. | +| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. | +| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. | +| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. | +| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | Omitted. | +| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. | +| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. | +| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. | +| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. | +| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. | +| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the sidebar already is. | Omitted. Connect opens a tab; Ctrl+K opens one by name. | +| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. | + +--- + +## File transfer (the design's SFTP screen) + +Nothing on this screen exists. It is listed in the nav rail and reaches a screen that says so, naming the +milestone and what is missing — see `ShellScreen` for why it is not simply dropped from the rail. + +| Design element | Layer | What it would take | +| --- | --- | --- | +| SFTP itself | client-ssh | An SFTP subsystem channel on `ISshConnection`. There is no `SftpClient`, `ScpClient` or transfer type anywhere in `src/`. | +| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | The channel, plus a listing record and a POSIX mode formatter — nothing in the repo formats a `drwxr-xr-x`. | +| Local listing | client-app | The App project contains no `System.IO` usage at all. The only paths this client knows are its own two files. | +| Path breadcrumbs, per-host last directory | client-storage | Navigation state for two panes, and somewhere to persist it. There is no settings table. | +| Transfer queue, progress, throughput | client-ssh | A transfer engine. **Do not reach for `CreditWindow`** — that is a 256 KiB flow-control window for terminal output, not a transfer primitive. | +| `resume supported` | client-ssh | Offset-based reads and writes, plus partial-transfer bookkeeping that survives a restart. | +| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. | + +--- + +## Vault + +The screen ships and is real: four categories over the vault's four item types, one table, a detail pane, +and both editors. What follows is what the design drew around them. + +| Design element | Layer | What it would take | What ships instead | +| --- | --- | --- | --- | +| `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. | +| `SHARED WITH · 6` avatars | server | The whole sharing stack: grants, a member directory, and re-wrapping a vault key for another account. | Omitted. | +| "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. | +| Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | Team vaults. `VaultAccessService.ListAsync` filters to personal vaults owned by the caller. | The real vault list, which today has one entry, with a line saying why. | +| `SCOPE` column | server | As above — and note scope here is a property of a *vault*, never of an item. | Omitted. | +| `LAST` column (`11:02`, `1d ago`) | contracts | No last-used timestamp at any layer. `VaultItem` is `(id, secret, version, three sync flags)`. | Omitted. | +| `FINGERPRINT` for SSH keys | client-domain | `SshKeySecret` has no fingerprint field, and computing one means parsing key formats the type deliberately stores verbatim. | The `DETAIL` column carries what *is* known — whether a passphrase and a public half are stored. Pins show their real fingerprint, in full and untruncated. | +| `•••• rotated jul 14` for passwords | client-domain | `CredentialSecret` has no rotation date or password age. | The account the password is for. | +| `IDENTITIES` category | contracts | A new item type, not even reserved in `SyncEntityType`. | Omitted. | +| `CERTIFICATES` category, `expires 2026-09-01` | contracts | Same ladder, plus certificate parsing. | Omitted. | +| `18 items · 12 shared` | server | The sharing stack. | `N items · N not synced`, counted over the rows on screen. | +| `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. | +| `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. | +| `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. | +| `REVOKE` | server | Revoking someone else's access needs grants to revoke. Deleting an item is a different act and is already there. **ADR 0001** also constrains how any future revoke may be presented: revocation is not retroactive. | Delete, named for what it does. | +| `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. | +| One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. | +| — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. | + +--- + +## Teams + +Nothing on this screen exists. It is in the nav rail and reaches a screen that says so. + +| Design element | Layer | What it would take | +| --- | --- | --- | +| The team itself | server | Team endpoints. The server has eight routes and none is about people. | +| Shared vaults | server | Vault creation, grants, and membership evaluation. Exactly one vault exists per user, created as a side effect of enrollment. | +| Roles (`OWNER`/`ADMIN`/`MEMBER`/`CONNECT-ONLY`/`VIEWER`) | contracts + server | `TeamRole` exists in `DodoSSH.Domain`, is read by no code path, has no wire representation, and has no `ConnectOnly` member. | +| Members table | server | A member DTO and a directory endpoint. | +| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | +| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` exists and is written at just-in-time provisioning and at enrollment, and never on an ordinary authenticated request — so the column cannot answer "last active". | +| Avatars | server | `MeResponse` has no picture field, and no other user's display name is fetchable. | +| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` and `TeamMembership.InvitedByUserId` are the only hints, and nothing writes them. | +| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | + +> **A trap for whoever builds this.** `GET /api/v1/meta` advertises `features: ["teams"]` +> *unconditionally* (`MetaEndpoints.cs`). Do not gate a Teams screen on that string — it is true of every +> deployment today and means nothing. + +--- + +## Preferences + +The screen ships with what is real — this machine's device key, locking, and syncing — and lists the rest +as absent rather than omitting it silently. + +| Design element | Layer | What it would take | +| --- | --- | --- | +| Any preference, saved | client-storage | A seventh table in the client cache, or a preference item type in the vault. Which one matters: the vault syncs, so a preference stored there follows you between machines, and a terminal font size probably should not. | +| Terminal font, size, cursor style, cursor blink, scrollback | client-terminal | The store, **and** a frame to carry it. `TerminalServerOpcode` has four values (`Output`, `SessionOpened`, `SessionClosed`, `SessionActivated`) and none carries an option. | +| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. | +| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. | +| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. | +| `KEYS & AGENT` section | client-ssh | **There is no agent, at all** — no own agent, no forwarding, no Pageant or OpenSSH-agent interop. | +| `SYNC & VAULT` section | client-domain | Nothing here is adjustable. The auto-sync interval is a `private static readonly` with a remark arguing for its value. | +| `SECURITY & SSO` section | server | The SSO half needs team endpoints; there is no policy for the screen to show. | +| `SHORTCUTS` section | ui | There is no keybinding infrastructure and no rebinding surface. | +| Auto-lock after idle | client-session | An activity source, a decision about what counts as idle, and — the hard part — a policy for a shell mid-job. `LockAsync`'s own remark already argues that an unattended timeout which killed a running job would be worse than the exposure it removed. | +| Require biometric to sign | crypto | Three separate falsehoods in one row. There is no signing service — the private key is decrypted and handed to SSH.NET whole, so there is no per-signature moment to interrupt; there are no connect-only keys; and per-use consent would need the key to live in the TPM, which is a different key hierarchy from the one in `docs/crypto.md`. | +| Pill toggle switches | ui | Purely presentational — `App.axaml` has no `ToggleSwitch` selector, so Fluent's default would appear un-themed beside the hairline chrome. | + +--- + +## Two things the import changed on purpose + +**Hosts left the vault column.** They have their own sidebar beside the terminal, and the vault screen +holds keys, passwords and pinned host keys. This follows the design, and it is also the better split: the +host list is what you look at while you work, and the rest is what you go and manage. `VaultSection` lost +its `Hosts` member and gained `All`. + +**Tabs moved to the shell, not the vault.** Locking disposes the vault and deliberately leaves shells +running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very +sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire, +`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected. diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml index f8c4047..f8fc514 100644 --- a/src/DodoSSH.Client.App/App.axaml +++ b/src/DodoSSH.Client.App/App.axaml @@ -9,11 +9,84 @@ and then cannot read the text. --> + + + + #0A0C0B + + + + + + + + + + + + + + + + + + + + + + + + + #3CE88F + + + + + + + + + + + + + + ui-monospace,Cascadia Mono,Consolas,monospace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs index a818d5d..c22a633 100644 --- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs @@ -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, } +/// +/// Which of the unlocked application's screens the nav rail is pointing at. +/// +/// +/// +/// Only meaningful while . The setup and unlock screens are +/// , 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. +/// +/// +/// and 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 +/// docs/design-import-gaps.md: 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. +/// +/// +internal enum ShellScreen +{ + /// The host list and the terminals, which is where the application opens. + Hosts = 0, + + /// File transfer. Nothing implements it yet. + Transfers = 1, + + /// Everything in the vault that is not a host. + Vault = 2, + + /// Shared vaults and the people in them. Nothing implements it yet. + Team = 3, + + /// Preferences. + Preferences = 4, +} + /// /// The shell: get to an unlocked vault, then hand over to . /// @@ -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; + /// + /// Whether this machine can neither register a device key nor withdraw one. + /// + /// + /// 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. + /// + internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice; + /// /// The address dotnet run --project src/DodoSSH.Api actually serves, so the first launch after /// a clone works without the user having to know a port. This was https://localhost:7217, which @@ -212,6 +267,299 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// Whether a connection to the server is currently held. internal bool IsOnline => connection is not null; + /// + /// Whether everything this machine has changed has reached the server. + /// + /// + /// + /// 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. + /// + /// + /// The middle condition is the one that is easy to leave out, and was. Holding an IVaultServer + /// 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 VaultViewModel.LastSyncFailed. + /// + /// + /// 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. + /// + /// + internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false }; + + /// The same fact as a word, for the titlebar. + 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; + + /// + internal bool IsTransfersScreen => Screen is ShellScreen.Transfers; + + /// + internal bool IsVaultScreen => Screen is ShellScreen.Vault; + + /// + internal bool IsTeamScreen => Screen is ShellScreen.Team; + + /// + internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences; + + /// + /// Whether the terminal's WebView may be on screen at this instant. + /// + /// + /// + /// This is an occlusion rule, not a styling one. 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. + /// + /// + /// Not gated on there being a tab. 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 + /// Focus() that hands it the keyboard, which is the one moment on the connect path that has to + /// work. NativeControlHost 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. + /// + /// + /// Collapsing is cheap and safe. NativeControlHost 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. + /// + /// + internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching; + + /// Points the nav rail at a screen. + [RelayCommand] + private void ShowScreen(ShellScreen target) => Screen = target; + + // ---- Open terminals ---- + + /// + /// Every terminal that has been opened this run, in the order they were opened. + /// + /// + /// 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 exists to + /// admit to. This object is the window's data context for the life of the process, and so is this list. + /// + internal ObservableCollection Tabs { get; } = []; + + [ObservableProperty] + private TerminalTabViewModel? selectedTab; + + internal bool HasTabs => Tabs.Count > 0; + + private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs)); + + /// + /// Closes one terminal, ending its shell. + /// + /// + /// 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. + /// + [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 ---- + + /// Whether the quick-connect palette is open over the window. + /// + /// It has to collapse the terminal while it is open — see — which is why + /// this is shell state rather than something a view could hold on its own. + /// + [ObservableProperty] + private bool isSearching; + + [ObservableProperty] + private string searchText = string.Empty; + + /// + /// The hosts the palette is offering, best match first. + /// + /// + /// 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 + /// docs/design-import-gaps.md. Offering an empty command list under a box that promised one is + /// worse than a box that promises only what it does. + /// + internal ObservableCollection SearchResults { get; } = []; + + [ObservableProperty] + private HostRowViewModel? selectedSearchResult; + + /// Whether the palette has anything to offer. + /// + /// A property rather than {Binding !SearchResults.Count} in the markup. Avalonia's ! is a + /// boolean operator: against an int it produces a binding error, IsVisible falls back to + /// its default of true, and "No host matches that" is shown permanently — under a list of matches. + /// + internal bool HasSearchResults => SearchResults.Count > 0; + + /// Opens the palette, or closes it if it is already open. + [RelayCommand] + private void ToggleSearch() + { + if (IsSearching) + { + CloseSearch(); + return; + } + + if (!IsUnlocked) + { + return; + } + + SearchText = string.Empty; + RefreshSearchResults(); + IsSearching = true; + } + + /// Dismisses the palette without connecting. + [RelayCommand] + private void CloseSearch() + { + IsSearching = false; + SearchText = string.Empty; + SearchResults.Clear(); + SelectedSearchResult = null; + + OnPropertyChanged(nameof(HasSearchResults)); + } + + /// + /// Selects the highlighted host and connects to it. + /// + /// + /// Goes through the vault's own ConnectCommand 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. + /// + [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); + } + + /// + /// 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. + /// + 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; + } + /// /// Brings the schema up to date and works out which screen to show. /// @@ -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(); + } + + /// + /// 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. + /// + 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)); + } + + /// + /// 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. + /// + 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); + } + + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// + 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(); + } + } + + /// Brings one terminal's pane to the front. + [RelayCommand] + private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab; + + /// + /// Marks a tab dead when its shell ends on its own. + /// + /// + /// 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. + /// + private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => + Dispatcher.UIThread.Post(() => + { + if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) + { + tab.IsLive = false; + } + + RefreshConnectedHosts(); + }); + + /// + /// Repaints the host list's status dots from the tab list. + /// + /// + /// 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. + /// + 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; + } } + + /// + /// 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. + /// + 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(); } diff --git a/src/DodoSSH.Client.App/ViewModels/TerminalTabViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TerminalTabViewModel.cs new file mode 100644 index 0000000..7d0a849 --- /dev/null +++ b/src/DodoSSH.Client.App/ViewModels/TerminalTabViewModel.cs @@ -0,0 +1,56 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DodoSSH.Client.App.ViewModels; + +/// +/// One open terminal, as a tab. +/// +/// +/// +/// 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 TerminalWorkspace, 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. +/// +/// +/// Tabs belong to the shell, not to the vault. 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. +/// +/// +/// Identifies this terminal to the renderer. +/// The host's name, as the vault has it. +/// Who this is logged in as, and where. +internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address) + : ObservableObject +{ + internal uint SessionId { get; } = sessionId; + + internal string Label { get; } = label; + + /// The account and endpoint, for the pane header and the status bar. + internal string Address { get; } = address; + + /// + /// Whether the shell behind this tab is still running. + /// + /// + /// 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. + /// + [ObservableProperty] + private bool isLive = true; + + /// + /// Whether this is the tab whose pane is showing. + /// + /// + /// A flag on the tab as well as a selection on the shell, because the strip is an + /// ItemsControl of buttons rather than a control that owns a selection — and a button has no + /// :selected pseudo-class to style against. The shell writes it; nothing else does. + /// + [ObservableProperty] + private bool isSelected; +} diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs index c7ca69c..978ae24 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -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. /// -internal sealed class HostRowViewModel(VaultItem host) +internal sealed partial class HostRowViewModel(VaultItem host) : ObservableObject { internal Guid EntityId => host.EntityId; @@ -53,6 +53,24 @@ internal sealed class HostRowViewModel(VaultItem host) /// A short marker for the row, so the list says what it knows without a tooltip. internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges); + + /// + /// Whether a terminal is open on this host right now. + /// + /// + /// + /// 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. + /// + /// + /// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability + /// would be a claim this application never checks. + /// + /// + [ObservableProperty] + private bool isConnected; } /// What a host can authenticate with. @@ -257,6 +275,8 @@ internal sealed class KnownHostRowViewModel(VaultItem pin, bool /// 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 }; } +/// +/// A terminal session that has just opened. +/// +/// Identifies the session to the renderer and to the workspace. +/// The host's name, as the vault has it. +/// The account and endpoint actually dialled. +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; +} + /// A conflict, as a row. internal sealed class ConflictRowViewModel(ConflictNotice notice) { @@ -320,10 +355,10 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice) /// internal enum VaultSection { - /// The hosts to connect to, and the column's opening state. - Hosts, + /// Every kind at once, which is where the screen opens. + All, - /// The SSH keys those hosts authenticate with. + /// The SSH keys hosts authenticate with. Keys, /// The usernames and passwords they authenticate with instead. @@ -333,6 +368,60 @@ internal enum VaultSection KnownHosts, } +/// What kind of thing a row in the vault table is. +internal enum VaultItemKind +{ + /// An SSH key. + Key, + + /// A stored username and password. + Credential, + + /// A pinned host key. + KnownHost, +} + +/// +/// One row of the vault table, whatever kind of item it is. +/// +/// +/// +/// The table has one shape and a TYPE 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. +/// +/// +/// Nothing here is a secret. is what is known about 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. +/// +/// +/// Which of the three lists this came from. +/// The item, so a selection can be mapped back. +/// The label the user gave it. +/// The one-word kind, for the table's TYPE column. +/// What is known about it. +/// Its sync state, or empty. +/// +/// 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. +/// +internal sealed record VaultItemRowViewModel( + VaultItemKind Kind, + Guid EntityId, + string Name, + string Type, + string Detail, + string Badge, + bool HasUnsyncedChanges) +{ + /// Whether this row has anything to say about its sync state. + internal bool HasBadge => Badge.Length > 0; +} + /// /// An open vault: the host list, the editor, syncing, and connecting a terminal. /// @@ -395,8 +484,36 @@ internal sealed partial class VaultViewModel( internal VaultSession Session => session; /// The hosts to show, unpushed local state included. + /// + /// 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. is the filtered view. + /// internal ObservableCollection Hosts { get; } = []; + /// The hosts the sidebar is showing: the filter applied, nothing else. + /// + /// A second collection rather than a filtered view over the first, because the sidebar's list has to be + /// one ListBox — it owns and it is where the keyboard lands when the + /// terminal gives it back, and neither of those survives being split across several lists. + /// + internal ObservableCollection VisibleHosts { get; } = []; + + /// + /// What the sidebar's one group heading says. + /// + /// + /// The vault's name, because the vault is the only grouping a host has — there are no tags and no + /// folders on HostSecret, 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 docs/design-import-gaps.md. + /// + internal string HostsHeading => VaultName.ToUpperInvariant(); + + /// Whether the host list under the heading is folded away. + [ObservableProperty] + private bool areHostsExpanded = true; + /// The SSH keys to show, unpushed local state included. internal ObservableCollection Keys { get; } = []; @@ -424,12 +541,49 @@ internal sealed partial class VaultViewModel( [ObservableProperty] private KnownHostRowViewModel? selectedKnownHost; + /// + /// What the sidebar's filter box holds. + /// + /// + /// 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 only: the selection, the + /// connect path and everything else read , so filtering can never make a host + /// unusable — only unlisted. + /// + [ObservableProperty] + private string hostFilter = string.Empty; + [ObservableProperty] private string status = string.Empty; [ObservableProperty] private int pendingChanges; + /// + /// Whether the last synchronisation attempt failed to reach the server. + /// + /// + /// + /// Holding a connection object is not the same as being able to reach anything, and this is the + /// difference. IVaultServer 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// + [ObservableProperty] + private bool lastSyncFailed; + [ObservableProperty] private int unreadableItems; @@ -448,18 +602,123 @@ internal sealed partial class VaultViewModel( [ObservableProperty] private VaultSection section; - /// Whether the hosts section is the one showing. - internal bool ShowsHosts => Section is VaultSection.Hosts; + /// Whether every kind is showing at once. + internal bool ShowsAll => Section is VaultSection.All; - /// + /// internal bool ShowsKeys => Section is VaultSection.Keys; - /// + /// internal bool ShowsCredentials => Section is VaultSection.Credentials; - /// + /// internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts; + /// + /// The rows the vault table is showing, for whichever category is selected. + /// + /// + /// 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. + /// + internal ObservableCollection VaultItems { get; } = []; + + /// + /// The selected row of the vault table. + /// + /// + /// 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. + /// + [ObservableProperty] + private VaultItemRowViewModel? selectedVaultItem; + + /// What the vault screen's header calls the category showing. + internal string SectionTitle => Section switch + { + VaultSection.Keys => "SSH KEYS", + VaultSection.Credentials => "PASSWORDS", + VaultSection.KnownHosts => "HOST KEYS", + _ => "ALL ITEMS", + }; + + /// + /// What the header says about the category, under its name. + /// + /// + /// 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. + /// + /// 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. + /// + /// + 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"; + } + } + + /// Everything in the vault except the hosts, which have their own screen. + internal int TotalItemCount => Keys.Count + Credentials.Count + KnownHostPins.Count; + + internal bool HasVaultItems => VaultItems.Count > 0; + + internal bool HasSelectedVaultItem => SelectedVaultItem is not null; + + /// Whether the selected row is one with an editor behind it. + internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is + VaultItemKind.Key or VaultItemKind.Credential; + + /// Whether the selected row is a pinned host key, which is edited by being withdrawn. + internal bool SelectedItemIsPin => SelectedVaultItem?.Kind is VaultItemKind.KnownHost; + + /// What the detail pane calls the block under the chips. + internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch + { + VaultItemKind.KnownHost => "FINGERPRINT", + VaultItemKind.Credential => "ACCOUNT", + _ => "WHAT IS STORED", + }; + + internal bool HasUnreadableItems => UnreadableItems > 0; + + /// + /// 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. + /// + internal string UnreadableSummary => UnreadableItems == 1 + ? "1 item will not decrypt" + : $"{UnreadableItems} items will not decrypt"; + + /// What an empty category says instead of showing an empty grid. + 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.", + }; + + /// Whether the category showing is one that can have something added to it. + /// + /// 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. + /// + 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. /// /// + /// /// 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 ConfigureAwait(true) — so a handler may touch /// controls directly. + /// + /// + /// 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 of — a host's name is a decrypted + /// item — so the naming happens here and the tab list happens there. + /// /// - internal event EventHandler? SessionOpened; + internal event EventHandler? 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; } + /// Refills the sidebar's list from and the filter. + /// + /// 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. ListBox.SelectedItem is two-way bound to + /// , so VisibleHosts.Clear() is a Reset 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. 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. + /// + 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; + } + + /// + /// 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. + /// + 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); + } + /// How many keys would not decrypt. /// /// Unlike the host list, the selection is not 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}."; } + /// + /// Opens whichever editor the selected row belongs to. + /// + /// + /// 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. + /// + [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; + } + } + + /// Deletes whatever the selected row is. + /// + /// 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. + /// + [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; + } + } + + /// Folds the host list away, or brings it back. + [RelayCommand] + private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded; + /// Abandons the editor. [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( }; /// - /// 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. + /// + /// + /// Scoped to the host editor alone, and that scoping is the point: the host editor lives in + /// HostSidebar, 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 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. + /// + private bool AHostEditorIsInTheWay() + { + if (IsEditing) + { + Status = "Finish or cancel the host you are editing first."; + } + + return IsEditing; + } + + /// + /// Whether a vault-screen editor has to be dealt with before the rail or another editor opens. /// /// /// - /// One editor open at a time, and the reason has changed. It used to be a layout constraint: - /// both editors sat in the same 340-pixel column as Auto 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: - /// BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut 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 CancelKeyEdit 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. /// /// - /// 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 CancelKeyEdit 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. - /// - /// - /// 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. /// /// - /// One check rather than the pair this replaced. Each of those asked about the other 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 . The host editor is a different screen's business now — + /// see — 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. /// /// - 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)); } + /// + /// 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. + /// + partial void OnHostFilterChanged(string value) => RebuildVisibleHosts(); + + /// + /// Refills the vault table from the typed lists. + /// + /// + /// 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. + /// + 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)); + } + + /// + /// 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. + /// + 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)); + } + /// /// 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( /// 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(); } /// diff --git a/src/DodoSSH.Client.App/Views/HostSidebar.axaml b/src/DodoSSH.Client.App/Views/HostSidebar.axaml new file mode 100644 index 0000000..4146659 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/HostSidebar.axaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs new file mode 100644 index 0000000..ee94c91 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace DodoSSH.Client.App.Views; + +/// The tab strip above the terminal. +internal sealed partial class TerminalTabs : UserControl +{ + public TerminalTabs() => InitializeComponent(); +} diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml b/src/DodoSSH.Client.App/Views/TitleBar.axaml new file mode 100644 index 0000000..357d066 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs new file mode 100644 index 0000000..8fdd919 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs @@ -0,0 +1,68 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace DodoSSH.Client.App.Views; + +/// +/// The window's own titlebar, drawn because the design draws one. +/// +/// +/// +/// Everything here is a window operation, which is why it is code-behind and not a command on a view model: +/// dragging, maximising and closing are properties of the this control happens to be +/// inside, and a view model that knew about them would be a view model that could not be tested without one. +/// +/// +/// The window is found by walking up rather than injected, so this control drops into any window — including +/// the bare one the layout harness hosts it in, where is simply a different window and +/// every handler still has something to act on. +/// +/// +internal sealed partial class TitleBar : UserControl +{ + public TitleBar() => InitializeComponent(); + + private Window? Host => TopLevel.GetTopLevel(this) as Window; + + /// + /// Left button only, and only on a press that has not already been handled by something inside the bar — + /// otherwise dragging would start from the close button and swallow the click that was meant to close + /// the window. + /// + 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, which is the convention Windows sets and + /// the one people reach for without thinking about it. + /// + 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/VaultColumn.axaml b/src/DodoSSH.Client.App/Views/VaultColumn.axaml deleted file mode 100644 index d115079..0000000 --- a/src/DodoSSH.Client.App/Views/VaultColumn.axaml +++ /dev/null @@ -1,389 +0,0 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +