diff --git a/README.md b/README.md index 9174511..96f881f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ With `--systemd` it also writes `~/.config/systemd/user/worktree-term-daemon.ser ## Configuration -The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.config/worktree-term/config.json`); every key is optional, and a new browser session picks up edits. `presets` lists the terminals a new tab or split can start, in order: `command` runs as `$SHELL -l -i -c `, and `null` starts the plain login shell. Without `presets`, the only preset is a plain `shell`. +The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.config/worktree-term/config.json`); every key is optional, and a new browser session picks up edits. `presets` lists the terminals a new tab or split can start, in order: `command` runs as `$SHELL -l -i -c `, and `null` starts the plain login shell. Without `presets`, they are `shell` (the login shell), `claude` and `codex`. "+ Add preset" and the × beside each preset in the picker edit `presets` here, starting from those defaults; every open page shows the change. `repos` and `roots` belong to this machine: `repos` are the repo tabs (absolute or `~/…`), `roots` are where "Add repo" looks for repositories, 3 levels deep (default `["~"]`). `hosts` lists remote hosts, each shown after this machine's repos: `name` labels its tabs (`name:repo`), `ssh` is the SSH alias, `repos` must be absolute paths on that host, and its `roots` (`~` meaning the remote home) default to `["~"]`. "Add repo" and "Remove repo" on the page edit `repos` here, keeping everything else as written. @@ -49,7 +49,7 @@ wtd install-remote devbox --node /opt/node22/bin/node or with "Install" on the page while the host is down. The remote gets the same layout as `wtd install-local`, without launcher or unit. Its shim runs the Node found at install time, so `"$HOME/.local/bin/wtd" connect` works over plain `ssh` with no dotfile changes. -Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's daemon speaks an older protocol, the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. +Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's running daemon speaks an older daemon protocol than its installed `wtd` (whose protocol `wtd --version` prints; it does not ask the running daemon), the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. ## Keyboard @@ -80,6 +80,7 @@ Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` ( ```sh pnpm install pnpm build # dist/wtd.mjs and dist/web +pnpm dev # the page through Vite with hot reload, at http://127.0.0.1:5173/login; needs a running hub pnpm test # unit, process, architecture and e2e tiers pnpm test:integration # opt-in real-SSH tests pnpm lint # tsc -b and ESLint diff --git a/architecture/daemon.arc42.md b/architecture/daemon.arc42.md index 22b2dba..16624b0 100644 --- a/architecture/daemon.arc42.md +++ b/architecture/daemon.arc42.md @@ -43,7 +43,7 @@ flowchart TD 1. `worktrees`, `terminals` and `state` never import each other; only `server` and `main` compose them. [enforced: arch_check:model-truth] 2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server.test.ts] -3. A PTY ends only by `closeTerm`, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts] +3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts] 4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals.test.ts] 5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals.test.ts] 6. Worktree changes are observed by events, never by polling. [review] diff --git a/architecture/protocol.arc42.md b/architecture/protocol.arc42.md index 1d7dfb6..76aa35e 100644 --- a/architecture/protocol.arc42.md +++ b/architecture/protocol.arc42.md @@ -2,7 +2,7 @@ ## Purpose & context -The wire contract between web, hub and daemons: message schemas, frame codecs, `PROTOCOL_VERSION`. Leaf node, used by all others. +The wire contract between web, hub and daemons: message schemas, frame codecs, and one version per link: `DAEMON_PROTOCOL_VERSION` (client ↔ daemon) and `BROWSER_PROTOCOL_VERSION` (browser ↔ hub). Leaf node, used by all others. ## Building blocks @@ -13,8 +13,8 @@ Leaf; see view `system`. 1. Pure: no I/O, no Node or DOM APIs; imports only `zod`. [enforced: test:test/architecture/dependencies.test.ts] 2. Every wire type is a schema; static types are inferred from it, never written separately. [review] 3. Decoders reject malformed or unknown input; they never coerce or repair. [enforced: test:src/protocol/messages.test.ts] -4. Any wire change bumps `PROTOCOL_VERSION`; `hello`, `shutdown` and the frame header never change. [enforced: test:src/protocol/wire.golden.test.ts] +4. A wire change bumps the version of the link it touches, both for a shared constant; `hello`, `shutdown` and the frame header never change. [enforced: test:src/protocol/wire.golden.test.ts] ## Rationale -One contract shared by browser and Node code removes hand-kept agreement between ends. Frozen `hello` and `shutdown` let any hub detect, and restart, a daemon of any other version. +One contract shared by browser and Node code removes hand-kept agreement between ends. Frozen `hello` and `shutdown` let any hub detect, and restart, a daemon of any other version. The hub serves the page it talks to, so the browser link can change without a daemon restart; a separate version keeps such changes from marking daemons outdated. diff --git a/openspec/changes/archive/2026-10-08-focused-page-size/design.md b/openspec/changes/archive/2026-10-08-focused-page-size/design.md new file mode 100644 index 0000000..8bf2031 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-focused-page-size/design.md @@ -0,0 +1,11 @@ +## Context + +Each page fits its shown terminals to their panes and, today, sends `resize` whenever its own fitted size changes. The daemon gives the PTY the last size it receives. Principles applied: web 2, 4, 7. + +## Decisions + +### D1 The page in focus owns the sizes +`document.hasFocus()` decides whether a fit is sent: a page in the background fits its xterm only. On `focus` the page forgets what it last sent and sends the size of every terminal it shows; the same happens for a host's terminals when its link connects again, since a size sent while disconnected was lost. Per terminal, the page remembers the size it sent since gaining the focus, so refits that change nothing send nothing. Alternative — the daemon picking the smallest size of all attached pages (as tmux does by default) — rejected: the window in use would get a narrower terminal than it has room for. + +### D2 No page in focus +When no page has the focus (another application is in front), sizes stay as the last focused page set them. diff --git a/openspec/changes/archive/2026-10-08-focused-page-size/proposal.md b/openspec/changes/archive/2026-10-08-focused-page-size/proposal.md new file mode 100644 index 0000000..50f4cd1 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-focused-page-size/proposal.md @@ -0,0 +1,21 @@ +## Why + +With the UI open in several windows, a terminal's PTY takes the size from whichever window fitted it last, even one in the background, so the window being used shows lines wrapped for another window's width. + +## What Changes + +- Web: only the page that has the focus sends `resize`; a page in the background still fits its own terminals but leaves the PTYs alone. +- Web: a page that gains the focus, or reconnects, sends the sizes of the terminals it shows, taking them over from the page that set them last. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `web`: worktree switching sends `resize` only from the page in focus; terminal size with several pages. + +## Impact + +- Code: `src/web/terminals/manager.ts`. +- Architecture, wire and dependencies: no change; no restart beyond reloading the page. +- Tests: `test/e2e/terminals.spec.ts` (two pages with stubbed focus, PTY size read with `stty size`; fails without the change). diff --git a/openspec/changes/archive/2026-10-08-focused-page-size/specs/web/spec.md b/openspec/changes/archive/2026-10-08-focused-page-size/specs/web/spec.md new file mode 100644 index 0000000..a83f469 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-focused-page-size/specs/web/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Worktree switching +Selecting a worktree SHALL show every pane of its active tab and hide the previous ones without waiting for any reply, fit each shown terminal to its pane, send `setVisible` with the host's shown terminals, and, while the page has the focus, send `resize` for each shown terminal whose size differs from the last one the page sent. It SHALL send no request that has a `req`. Resizing the window SHALL fit the shown terminals and, while the page has the focus, resize them. + +#### Scenario: Switch sends no request +- **WHEN** the user switches between two worktrees whose terminals were already viewed +- **THEN** the page sends only `setVisible` and `resize` messages + +#### Scenario: Split tab shown whole +- **WHEN** the user switches to a worktree whose active tab holds three panes +- **THEN** all three terminals are shown, each fitted to its pane, and `setVisible` lists all three + +## ADDED Requirements + +### Requirement: Terminal size with several pages +Only a page that has the focus SHALL send `resize`. A page that gains the focus SHALL send `resize` with the size of every terminal it shows, and so SHALL a page for the shown terminals of a host whose link connects again; afterwards it SHALL send a terminal's size only when it differs from the last one it sent. A page without the focus SHALL fit its terminals to its own panes without sending `resize`. + +#### Scenario: Background page leaves the size alone +- **WHEN** a second page opens in the background with a smaller window and shows a terminal of the focused page +- **THEN** the PTY keeps the focused page's size + +#### Scenario: Focus takes the size over +- **WHEN** the second page gains the focus +- **THEN** the PTY takes the second page's size, and resizing the first page's window while it is in the background does not change it + +#### Scenario: Refocus takes it back +- **WHEN** the first page gains the focus again +- **THEN** the PTY takes the first page's current size diff --git a/openspec/changes/archive/2026-10-08-focused-page-size/tasks.md b/openspec/changes/archive/2026-10-08-focused-page-size/tasks.md new file mode 100644 index 0000000..c359fea --- /dev/null +++ b/openspec/changes/archive/2026-10-08-focused-page-size/tasks.md @@ -0,0 +1,8 @@ +## 1. Web + +- [x] 1.1 `TerminalManager`: send `resize` only while the page has the focus, once per size per terminal; on `focus` and on a host reconnecting, send the shown terminals' sizes (design D1) +- [x] 1.2 e2e: two pages with stubbed focus; a background page opening and resizing leaves the PTY size alone, the focused page takes it over, refocusing takes it back; verify it fails without 1.1 + +## 2. Final gates + +- [x] 2.1 `pnpm test` green (incl. Playwright); `pnpm lint`, `la-typecheck`, `la-arch-check` exit 0; `openspec validate focused-page-size --strict` passes diff --git a/openspec/changes/archive/2026-10-08-preset-order/design.md b/openspec/changes/archive/2026-10-08-preset-order/design.md new file mode 100644 index 0000000..08d8133 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/design.md @@ -0,0 +1,14 @@ +## Context + +Builds on the unarchived changes `sidebar-presets-browser-protocol` (preset edits, two link versions) and `worktree-status-and-deletion` (versions 6 and 7); their MODIFIED and ADDED text is the base here. Principles applied: protocol 3, 4; hub 5; web 2, 7. + +## Decisions + +### D1 Move one preset by name +`movePreset{name, to}` names the preset and its new position rather than sending the whole order: an add or remove from another page at the same time is kept, and a stale page cannot reorder presets it never saw. `to` beyond the end means last; an unknown name or a move in place changes nothing and sends no `presets`. Edits stay serialized in the configuration editor. + +### D2 Native drag and drop, plus keys +Rows use HTML drag and drop; the gap under the pointer (upper or lower half of a row) is the drop slot, shown as an accent line, and `dropPosition` turns slot and origin into the target position. Alt+Up/Down moves the highlighted preset and keeps it highlighted; the list refocuses it once the new order arrives, since rows are keyed by name and the focus would otherwise stay on whichever preset now holds the old position. Until that order arrives (or the move fails) further keyboard moves are ignored: the list still shows the old order, so a second move would pick the wrong preset. + +### D3 Browser version only +Only the browser-link catalogue changes, so only `BROWSER_PROTOCOL_VERSION` rises (to 8): the reason for the version split. diff --git a/openspec/changes/archive/2026-10-08-preset-order/proposal.md b/openspec/changes/archive/2026-10-08-preset-order/proposal.md new file mode 100644 index 0000000..fa0cb29 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/proposal.md @@ -0,0 +1,29 @@ +## Why + +Presets can be added and removed from the page, but their order — which decides the digit that picks each one — can only be changed by editing `config.json`. + +## What Changes + +- **BREAKING** (browser wire only): `BROWSER_PROTOCOL_VERSION` 8 — browser-to-hub `movePreset{req, name, to}`. `DAEMON_PROTOCOL_VERSION` stays 6: replacing the hub needs no daemon restart. +- `hub.config` / `hub.router`: a preset edit that moves the named preset to position `to` (the last position when beyond it), answered and propagated like the other preset edits. +- Web: preset rows in the picker and in a worktree's choices can be dragged to a new position, with a drop marker; Alt+Up/Down moves the highlighted preset, which stays highlighted. +- CLI: the version line shows browser protocol 8. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `protocol`: browser version 8; `movePreset` in the browser-link catalogue; constants. +- `hub`: moving presets. +- `web`: reordering presets. +- `cli`: version line. + +## Impact + +- Code: `src/protocol/**`, `src/hub/config/editor.ts`, `src/hub/router/{index,session}.ts`, `src/web/client/connection.ts`, `src/web/ui/{app.tsx,picker.ts,view.ts}`, `src/web/main/styles.css`. +- Architecture: no change. +- Wire: `wire.golden.json` re-blessed for `{daemon: 6, browser: 8}`; daemon-link schemas unchanged (checked by the guard). +- Upgrade: restart the hub only. +- Tests: protocol, editor and `dropPosition` unit tests; `test/process/hub-router.test.ts`; `test/e2e/picker.spec.ts` (drag in the choices, Alt+Up in the picker). +- Builds on the changes `sidebar-presets-browser-protocol` and `worktree-status-and-deletion`; archive them first. diff --git a/openspec/changes/archive/2026-10-08-preset-order/specs/cli/spec.md b/openspec/changes/archive/2026-10-08-preset-order/specs/cli/spec.md new file mode 100644 index 0000000..234490f --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/specs/cli/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Version output +`wtd --version` SHALL print `wtd (daemon protocol , browser protocol )` and a newline to stdout and exit 0. + +#### Scenario: Version +- **WHEN** the user runs `wtd --version` +- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 8)` followed by a newline, stderr is empty, and the exit code is 0 diff --git a/openspec/changes/archive/2026-10-08-preset-order/specs/hub/spec.md b/openspec/changes/archive/2026-10-08-preset-order/specs/hub/spec.md new file mode 100644 index 0000000..8de90e2 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/specs/hub/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: Editing presets +`addPreset{req, preset}` SHALL append the preset to the presets, `removePreset{req, name}` SHALL remove the preset of exactly that name and `movePreset{req, name, to}` SHALL move the preset of exactly that name to position `to`, or to the last position when `to` is beyond it, each as a configuration edit. The requester SHALL receive `done`, and only then every open session `presets`. `addPreset` SHALL be refused with `error{req, host: null, code: internal}` naming the reason when a preset of that name exists or there would be more than 64 presets; `removePreset` SHALL be refused that way when it would remove the last preset. A `removePreset` or `movePreset` naming no preset, and a `movePreset` to the preset's own position, SHALL answer `done`, leave the file unchanged and send no `presets`. + +#### Scenario: Preset added +- **WHEN** a session adds the preset `htop` (command `htop`) while `config.json` lists `shell` and two sessions are open +- **THEN** the requester receives `done`, both sessions then receive `presets` listing `shell` and `htop`, and `config.json` lists both + +#### Scenario: Preset removed from the defaults +- **WHEN** `config.json` has no `presets` and a session removes `claude` +- **THEN** `config.json` lists `shell` and `codex`, and every open session receives those presets + +#### Scenario: Taken name refused +- **WHEN** a session adds a preset named `shell` while `shell` exists +- **THEN** it receives `error` with code `internal` saying a preset named `shell` exists, and the file is unchanged + +#### Scenario: Last preset kept +- **WHEN** `config.json` lists only `shell` and a session removes it +- **THEN** it receives `error` with code `internal` saying the last preset cannot be removed, and the file is unchanged + +#### Scenario: Unknown name +- **WHEN** a session removes a preset named `nope` that does not exist +- **THEN** it receives `done`, no session receives `presets`, and the file is unchanged + +#### Scenario: Preset moved +- **WHEN** `config.json` lists `shell`, `claude` and `htop`, two sessions are open and one moves `htop` to position 0 +- **THEN** the requester receives `done`, both sessions then receive `presets` listing `htop`, `shell` and `claude`, and `config.json` lists them in that order + +#### Scenario: Move beyond the end +- **WHEN** a session moves the first of three presets to position 63 +- **THEN** it becomes the last preset diff --git a/openspec/changes/archive/2026-10-08-preset-order/specs/protocol/spec.md b/openspec/changes/archive/2026-10-08-preset-order/specs/protocol/spec.md new file mode 100644 index 0000000..df885ff --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/specs/protocol/spec.md @@ -0,0 +1,72 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 8, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 7 is decoded +- **THEN** decoding succeeds and yields `protocol` 7 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + +### Requirement: Browser-link message catalogue +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. + +#### Scenario: Envelope cannot carry a handshake message +- **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Every browser-link message round-trips +- **WHEN** a valid instance of each browser-link message is encoded and decoded +- **THEN** each decoded value equals its original + +#### Scenario: Host entry without instance rejected +- **WHEN** a `hosts` message whose entry lacks `instance` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Host entry without reason rejected +- **WHEN** a `hosts` message whose entry lacks `reason` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Outdated host carries its instance +- **WHEN** a `hosts` entry with `status` "outdated" and a non-null `instance` is decoded +- **THEN** decoding succeeds and yields that instance + +#### Scenario: Token message only from the hub +- **WHEN** a `token` message is decoded as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset edits only from the browser +- **WHEN** a `removePreset` message is decoded as a hub-to-browser message, or an `addPreset` as a client-to-daemon message +- **THEN** decoding fails with a protocol error + +#### Scenario: Invalid preset edit rejected +- **WHEN** an `addPreset` whose preset has a blank name or an empty command, or a `removePreset` with an empty name, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset position bounded +- **WHEN** a `movePreset` with `to` −1, 64 or 0.5 is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Shared constants +The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 6; `BROWSER_PROTOCOL_VERSION` = 8; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. + +#### Scenario: Constants hold their values +- **WHEN** the exported constants are read +- **THEN** each equals the value stated above diff --git a/openspec/changes/archive/2026-10-08-preset-order/specs/web/spec.md b/openspec/changes/archive/2026-10-08-preset-order/specs/web/spec.md new file mode 100644 index 0000000..6d34090 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/specs/web/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: Editing presets +The preset picker and a worktree's preset choices SHALL offer "+ Add preset", opening a form for a name and a command; submitting SHALL trim both, treat an empty command as the login shell (`command` null) and send `addPreset`, closing the form on `done` and showing the refusal in the form otherwise. A blank name, a name of more than 64 characters, a name already taken, or a command of more than 4096 characters SHALL be refused in the form without sending anything; Escape SHALL close the form without sending anything and without closing the picker. While more than one preset exists, each SHALL offer a remove control sending `removePreset` for it; a refusal SHALL be shown on the page. While more than one preset exists, each SHALL be draggable to another position, the gap it would be dropped into marked while dragging, and dropping it elsewhere SHALL send `movePreset` with its new position; Alt+Up and Alt+Down SHALL move the highlighted preset one position, which stays highlighted and focused; until the new order arrives, or the move fails, further keyboard moves SHALL be ignored, so each moves the preset that is highlighted. Every list SHALL show the presets the hub last sent, and the picker SHALL stay open while presets are edited. + +#### Scenario: Preset added from the choices +- **WHEN** the user adds a preset `echo` with a command in a worktree's preset choices while another page is open +- **THEN** `addPreset` is sent once, both pages list `echo` last, `config.json` lists it, and choosing it runs its command + +#### Scenario: Preset added in the picker +- **WHEN** the user adds a preset in the new-tab picker +- **THEN** the picker stays open, lists the new preset, and choosing it opens a terminal running it + +#### Scenario: Preset removed +- **WHEN** the user removes one of two presets +- **THEN** `removePreset` is sent, every page lists only the other preset, `config.json` lists only it, and no remove control is offered any more + +#### Scenario: Taken name refused in the form +- **WHEN** the user submits a name that a preset already has, or a blank name +- **THEN** the form says why, nothing is sent, and Escape then closes the form + +#### Scenario: Preset dragged to the front +- **WHEN** the user drags the second of two presets onto the upper half of the first in a worktree's choices while another page is open +- **THEN** `movePreset` is sent with position 0, and both pages and `config.json` list the dragged preset first + +#### Scenario: Quick keyboard moves +- **WHEN** the first of three presets is highlighted and the user presses Alt+Down twice in quick succession +- **THEN** only that preset moves, by one or two positions + +#### Scenario: Preset moved with the keyboard +- **WHEN** the second preset is highlighted in the new-tab picker and the user presses Alt+Up and then Enter +- **THEN** it is listed first, still highlighted and focused, and a terminal running it opens diff --git a/openspec/changes/archive/2026-10-08-preset-order/tasks.md b/openspec/changes/archive/2026-10-08-preset-order/tasks.md new file mode 100644 index 0000000..b72f739 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-preset-order/tasks.md @@ -0,0 +1,16 @@ +## 1. Protocol + +- [x] 1.1 `movePreset{req, name, to}` (0–63) in the browser-to-hub catalogue; `BROWSER_PROTOCOL_VERSION` 8; samples, correlation and rejection tests; verify the golden guard accepts the change with only the browser version raised, then re-bless; version expectations + +## 2. Hub + +- [x] 2.1 Configuration editor `movePreset` with unit tests (front, end, beyond the end, in place, unknown name); session and router handling; `test/process/hub-router.test.ts`: move answered with `done`, new order sent to every session and written + +## 3. Web + +- [x] 3.1 Client and view `movePreset`; `dropPosition` with unit tests; draggable rows with drop markers; Alt+Up/Down keeping the highlight and focus +- [x] 3.2 e2e: drag in the choices reorders every page and `config.json`; Alt+Up in the new-tab picker, then Enter runs the moved preset; two quick Alt+Down presses only move the highlighted preset (fails without the wait) + +## 4. Final gates + +- [x] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint`, `la-typecheck`, `la-arch-check` exit 0; `openspec validate preset-order --strict` passes diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/design.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/design.md new file mode 100644 index 0000000..69dd03c --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/design.md @@ -0,0 +1,47 @@ +## Context + +Follows the archived change `2026-10-07-dev-2054-…` (protocol 5, remote hosts, configuration edits). `architecture/` is normative; principles applied: system 1–8; protocol 2–4; hub 1, 5; web 1, 2, 7. No element or arrow of the model changes: preset edits travel the existing path `web.client → hub.server → hub.router → hub.config`. + +Today: one `PROTOCOL_VERSION` (5) is sent in every `hello`; the hub marks a daemon `outdated` when its `hello.protocol` differs and the page reloads when the hub's differs. Presets come only from `config.json`; the web keeps one global filter and a fixed 240 px sidebar; every pane has a 20 px header. + +## Goals / Non-Goals + +**Goals:** +- A change confined to the browser link never marks a daemon outdated. +- Presets can be added and removed from the page, and every open page sees the result at once. +- Per-repo and per-browser view state survives reloads without touching daemon state. + +**Non-Goals:** +- Editing or reordering an existing preset in place (remove and add instead). +- Sharing the sidebar width or filters across browsers (they are view preferences, like the selection). +- Changing the daemon link. + +## Decisions + +### D1 One version per link +`DAEMON_PROTOCOL_VERSION` = 5 is what daemons and their clients (hub links, one-shot links, `wtd connect`) exchange; `BROWSER_PROTOCOL_VERSION` = 6 is what the hub and the page exchange. The `hello` shape is unchanged and frozen, so both links still identify any peer. The golden snapshot records `protocolVersions {daemon, browser}`; a schema or corpus change of a direction needs its link's bump, a change to the shared constants (frames and flow control, used on both links) needs both. The browser link starts at 6 because it was at 5 and changes now. +Alternative — bumping the shared version to 6 — rejected: every running daemon would show as outdated and its restart would kill its terminals, for a change the daemon never sees; the hub always serves the page it talks to, so the two ends of the browser link cannot drift apart the way a long-lived daemon can. + +### D2 Preset edits are configuration edits +`addPreset` appends; `removePreset` removes by exact name. When `config.json` has no `presets`, the edit starts from the defaults and writes them out with the change, so the result is what every page already showed. The editor's read → change → identity/content check → atomic replace loop, retried up to 3 times and serialized per hub, now runs for any array field; repos and presets share it, and a repo edit and a preset edit cannot lose each other. Refusals are `ConfigError`s answered as `error{code: internal}` naming the reason: a taken name, a 65th preset, removing the last preset, an invalid file. Removing an unknown name answers `done` and changes nothing. + +### D3 Preset edits reach every session +Unlike the rest of a session's snapshot, presets change in open sessions: after a changed edit the hub answers `done` to the requester, then sends `presets` to every open session. Presets are global (not per host), so there is no host filter as for repos. An unchanged edit sends no `presets`. + +### D4 Defaults `shell`, `claude`, `codex` +worktree-term exists to run coding agents; offering the two common agents by default saves every user the same edit. A missing command is the agent's own concern: the terminal shows the shell's error and exits. + +### D5 View preferences per browser +The filter becomes a map from repo key to `checked` in `localStorage` (`wtd.filters`; a repo without an entry shows all; the old global `wtd.filter` is ignored). The sidebar width is `wtd.sidebarWidth`, an integer clamped to 160–640 px, default 260. Stored values are parsed and clamped when read (system principle 5). The filter switch is keyed by repo so that switching repos mounts it in its state instead of transitioning it; only a click animates it. + +### D6 Pane headers only in splits +A single-pane tab's header repeats its tab: label, attention mark and close. Headers are shown only when the tab holds more than one pane; the terminal box then starts at the pane's top. The focused pane's header keeps the accent colour, and the active repo tab and terminal tab now use the same accent, so "where am I" reads the same at every level. + +### D7 Vite for development only +`pnpm dev` runs Vite on `127.0.0.1:5173` (system principle 7) with the hub's port and token read from the running hub's record and token file. It proxies `/ws` with the hub's `Host`, rewriting `Origin` to the hub's own only when it is the dev page's, so another site's WebSocket is still refused by the hub. It serves `/login`, which asks the hub for a one-time code and redirects to `/#code=…`, refusing requests browsers mark as made by another site (`Sec-Fetch-Site: cross-site` or `same-site`); every response forbids framing, so no site can overlay the signed-in page. Nothing of it is in the shipped bundle; `vite build` ignores it. + +## Risks / Trade-offs + +- [A page of the previous release against a new hub] → its `hello.protocol` 5 differs from 6, so it reloads once and runs the new bundle, as for any hub upgrade. +- [A default preset whose program is not installed] → choosing it shows the shell's "command not found" and the terminal exits; removing it is one click. +- [Two people editing presets at once from different pages] → edits are serialized; a taken name is refused with its reason. diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/proposal.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/proposal.md new file mode 100644 index 0000000..9a1e79b --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/proposal.md @@ -0,0 +1,37 @@ +## Why + +Day-to-day use of worktree-term showed friction in the UI: the worktree list has a fixed width, the "checked only" filter applies to every repo at once, the active repo and terminal tabs barely stand out while a bar over every terminal repeats its tab, and the terminal presets can only be changed by hand-editing `config.json`. Adding preset edits to the page needs new browser-to-hub messages; with one protocol version shared by both links, that bump would mark every running daemon outdated and force a restart that kills its terminals, although daemons never see browser messages. + +## What Changes + +- **BREAKING** (wire): the single `PROTOCOL_VERSION` is split into `DAEMON_PROTOCOL_VERSION` (client ↔ daemon, stays 5) and `BROWSER_PROTOCOL_VERSION` (browser ↔ hub, 6). A wire change bumps the version of the link it touches, a shared-constant change both; the golden guard checks each link against its own version. Daemons of this release stay compatible with hubs of the previous one. +- Protocol: browser-to-hub `addPreset{req, preset}` and `removePreset{req, name}`. +- `hub.config`: the default presets are `shell` (login shell), `claude` and `codex`; the configuration editor edits the presets as well as a host's repos, with the same read–check–replace rules. +- `hub.router`: preset edits answer `done` and send `presets` to every open session. +- Web: "+ Add preset" (name and command) and a × per preset in the new-terminal picker and in a worktree's preset choices. +- Web: the "checked only" filter (shown as "Starred") is kept per repo; switching repos does not animate the switch. +- Web: the sidebar's right edge is draggable, and moves with the arrow keys, between 160 and 640 px; the width is kept per browser. +- Web: a single-pane tab shows no pane header — only panes of a split have one; the active repo tab and terminal tab are shown in the accent colour that marks the focused pane. +- CLI: `wtd --version` prints both protocol versions. +- Development: `pnpm dev` serves the page through Vite with hot reload, proxying `/ws` to the running hub; `/login` there signs in with a fresh one-time code, refusing other sites, and no response may be framed. +- Already in the branch, outside the specs: the restyled UI (palette, star toggles, sidebar header) and an e2e test that adding an already-listed repo selects its tab. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `protocol`: two link versions; `addPreset` and `removePreset` in the browser-link catalogue; shared constants. +- `daemon`: the handshake names `DAEMON_PROTOCOL_VERSION`. +- `hub`: the browser handshake names `BROWSER_PROTOCOL_VERSION`; default presets; preset edits and their propagation. +- `web`: handshake version; per-repo checked filter; sidebar width; preset editing; pane headers only in splits; accent-coloured active tabs. +- `cli`: version line with both protocol versions. + +## Impact + +- Code: `src/protocol/**`, `src/daemon/server/**`, `src/hub/config/**`, `src/hub/router/**`, `src/hub/server/listener.ts`, `src/cli/run.ts`, `src/web/**`, `vite.config.ts`, `package.json` (`dev` script), `README.md`. +- Architecture: no element or arrow changes; `protocol.arc42.md` purpose and principle 4 restated for two link versions (still enforced by `wire.golden.test.ts`). +- Wire: `wire.golden.json` re-blessed with `protocolVersions {daemon: 5, browser: 6}`; its daemon-link schemas and corpus verdicts are unchanged. `frozen.golden.json` unchanged. +- Upgrade: replacing a hub of the previous release needs no daemon restart; an open page of the previous release reloads once and then runs the new bundle. +- Tests: unit (protocol, golden guard, config, editor, sidebar, picker form), `test/process` (hub preset edits, defaults, version line), `test/e2e` (preset editing, per-repo filter, switch animation, sidebar width, pane headers, re-adding a repo). +- Dependencies: none added. diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/cli/spec.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/cli/spec.md new file mode 100644 index 0000000..260c4af --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/cli/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Version output +`wtd --version` SHALL print `wtd (daemon protocol , browser protocol )` and a newline to stdout and exit 0. + +#### Scenario: Version +- **WHEN** the user runs `wtd --version` +- **THEN** stdout is `wtd (daemon protocol 5, browser protocol 6)` followed by a newline, stderr is empty, and the exit code is 0 diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/daemon/spec.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/daemon/spec.md new file mode 100644 index 0000000..066e833 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/daemon/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Handshake and version mismatch +On every new connection the daemon SHALL send `hello` with `protocol` equal to `DAEMON_PROTOCOL_VERSION`, its package version, and an `instance` chosen at random once per process start. If the client's first message is not `hello`, the daemon SHALL send `error{req: null, code: bad-message}` and close. If the client's `hello` carries a different `protocol`, the connection SHALL accept only the frozen `shutdown` message; every other frame, including undecodable ones, SHALL get `error{req: null, code: version-mismatch}`. + +#### Scenario: Daemon hello +- **WHEN** a client connects +- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 5 + +#### Scenario: Message before hello +- **WHEN** a client's first message is a `watchRepo` +- **THEN** the daemon replies `error` with code `bad-message` and closes the connection + +#### Scenario: Mismatched client can only shut down +- **WHEN** a client sends `hello` with `protocol` 1, then a `watchRepo`, then `shutdown` +- **THEN** the `watchRepo` gets `error{req: null, code: version-mismatch}` and the `shutdown` stops the daemon diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/hub/spec.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/hub/spec.md new file mode 100644 index 0000000..0def1ab --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/hub/spec.md @@ -0,0 +1,115 @@ +## MODIFIED Requirements + +### Requirement: Browser session handshake +On an accepted upgrade the hub SHALL send `hello` with `protocol` equal to `BROWSER_PROTOCOL_VERSION`, its package version and its `instance`, then, if the session authenticated with a code, `token{token}`. After the browser's `hello` it SHALL send `presets` with the presets of the session's configuration snapshot, then `hosts`. A message before the browser's `hello` SHALL get `error{req: null, host: null, code: bad-message}` and close the session; a browser `hello` with another `protocol` SHALL get `error{req: null, host: null, code: version-mismatch}` and close it. + +#### Scenario: Code session receives the token +- **WHEN** a browser connects with a valid code +- **THEN** it receives `hello`, then `token` with the hub token + +#### Scenario: Token session receives no token +- **WHEN** a browser connects with the token +- **THEN** it receives `hello` and no `token` message + +#### Scenario: Presets before hosts +- **WHEN** a browser completes the handshake +- **THEN** it receives `presets` and then `hosts` + +#### Scenario: Message before hello +- **WHEN** a browser's first message is a `host` envelope +- **THEN** it receives `error` with `bad-message` and the session closes + +#### Scenario: Browser of another protocol refused +- **WHEN** a browser sends `hello` with `protocol` equal to `BROWSER_PROTOCOL_VERSION` + 1 +- **THEN** it receives `error` with `version-mismatch` and the session closes + +### Requirement: Configuration +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}, {name: "claude", command: "claude"}, {name: "codex", command: "codex"}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo or preset edit from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. + +#### Scenario: Defaults without a file +- **WHEN** no `config.json` exists and the hub starts +- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the presets `shell` (command null), `claude` (command `claude`) and `codex` (command `codex`), in that order + +#### Scenario: Unknown key refused +- **WHEN** `config.json` holds the key `theme` and the hub starts +- **THEN** the start fails with a line naming the file and `theme` + +#### Scenario: Configured presets replace the default +- **WHEN** `config.json` lists presets `claude` (command `claude`) and `shell` (command null), in that order +- **THEN** sessions receive exactly those two presets in that order + +#### Scenario: Invalid presets refused +- **WHEN** `config.json` holds an empty `presets` list, two presets named `a`, or a preset whose command is "" +- **THEN** the start fails with a line naming the file and `presets` + +#### Scenario: Remote hosts listed in order +- **WHEN** `config.json` lists hosts `b` and `a`, in that order +- **THEN** sessions receive `hosts` with the local host at index 0, `b` at index 1 and `a` at index 2, both remote + +#### Scenario: Option-like alias refused +- **WHEN** a host's `ssh` is `-oProxyCommand=x` or contains a space +- **THEN** the start fails with a line naming the file and `ssh` + +#### Scenario: Home-relative remote repo refused +- **WHEN** a host's `repos` holds `~/app` +- **THEN** the start fails with a line naming the file and `repos` + +#### Scenario: Duplicate host names refused +- **WHEN** two hosts are named `box` +- **THEN** the start fails with a line naming the file and `hosts` + +#### Scenario: Edit picked up by a new session +- **WHEN** a repo or a preset is added to `config.json` by hand while a session is open, and a new session starts +- **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change + +#### Scenario: Invalid edit keeps the last valid configuration +- **WHEN** `config.json` becomes invalid and a new session starts +- **THEN** that session receives an `internal` error and the repos and presets of the last valid configuration + +### Requirement: Configuration edits +A configuration edit — a repo edit or a preset edit — SHALL read the current `config.json` (an absent file read as `{}`), change only the edited list — for a repo edit the `repos` of the edited host, the top-level `repos` for the local host, otherwise those of the host entry with the session's host name; for a preset edit the top-level `presets`, starting from the default presets when the file has none — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host of a repo edit, the result would hold more than 256 repos for the host, or a preset edit is refused. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits of either kind SHALL be applied one at a time. After a repo edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; after a preset edit that changed the file, every open session SHALL receive `presets` with the edited presets; nothing else in any session's snapshot SHALL change. + +#### Scenario: Other entries kept as written +- **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it +- **THEN** the file still lists `~/a` unexpanded, followed by the added path, and every other key is unchanged + +#### Scenario: Invalid file not overwritten +- **WHEN** `config.json` is invalid and the browser adds a repo +- **THEN** it receives `error` with code `internal` naming the problem and the file is unchanged + +#### Scenario: Concurrent hand edit kept +- **WHEN** `config.json` is changed by another writer after an edit read it and before the edit replaced it +- **THEN** the edit starts over from the changed file, and the result holds both the other writer's change and the added repo + +#### Scenario: Host gone from the file +- **WHEN** a session's host `box` was removed from `config.json` and that session adds a repo for `box` +- **THEN** it receives `error` with code `internal` and the file is unchanged + +#### Scenario: Repo and preset edits do not lose each other +- **WHEN** a repo is added and a preset removed at the same time +- **THEN** the file holds both changes + +## ADDED Requirements + +### Requirement: Editing presets +`addPreset{req, preset}` SHALL append the preset to the presets and `removePreset{req, name}` SHALL remove the preset of exactly that name, each as a configuration edit. The requester SHALL receive `done`, and only then every open session `presets`. `addPreset` SHALL be refused with `error{req, host: null, code: internal}` naming the reason when a preset of that name exists or there would be more than 64 presets; `removePreset` SHALL be refused that way when it would remove the last preset. A `removePreset` naming no preset SHALL answer `done`, leave the file unchanged and send no `presets`. + +#### Scenario: Preset added +- **WHEN** a session adds the preset `htop` (command `htop`) while `config.json` lists `shell` and two sessions are open +- **THEN** the requester receives `done`, both sessions then receive `presets` listing `shell` and `htop`, and `config.json` lists both + +#### Scenario: Preset removed from the defaults +- **WHEN** `config.json` has no `presets` and a session removes `claude` +- **THEN** `config.json` lists `shell` and `codex`, and every open session receives those presets + +#### Scenario: Taken name refused +- **WHEN** a session adds a preset named `shell` while `shell` exists +- **THEN** it receives `error` with code `internal` saying a preset named `shell` exists, and the file is unchanged + +#### Scenario: Last preset kept +- **WHEN** `config.json` lists only `shell` and a session removes it +- **THEN** it receives `error` with code `internal` saying the last preset cannot be removed, and the file is unchanged + +#### Scenario: Unknown name +- **WHEN** a session removes a preset named `nope` that does not exist +- **THEN** it receives `done`, no session receives `presets`, and the file is unchanged diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/protocol/spec.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/protocol/spec.md new file mode 100644 index 0000000..5162589 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/protocol/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 5, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 6, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 7 is decoded +- **THEN** decoding succeeds and yields `protocol` 7 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + +### Requirement: Browser-link message catalogue +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. + +#### Scenario: Envelope cannot carry a handshake message +- **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Every browser-link message round-trips +- **WHEN** a valid instance of each browser-link message is encoded and decoded +- **THEN** each decoded value equals its original + +#### Scenario: Host entry without instance rejected +- **WHEN** a `hosts` message whose entry lacks `instance` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Host entry without reason rejected +- **WHEN** a `hosts` message whose entry lacks `reason` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Outdated host carries its instance +- **WHEN** a `hosts` entry with `status` "outdated" and a non-null `instance` is decoded +- **THEN** decoding succeeds and yields that instance + +#### Scenario: Token message only from the hub +- **WHEN** a `token` message is decoded as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset edits only from the browser +- **WHEN** a `removePreset` message is decoded as a hub-to-browser message, or an `addPreset` as a client-to-daemon message +- **THEN** decoding fails with a protocol error + +#### Scenario: Invalid preset edit rejected +- **WHEN** an `addPreset` whose preset has a blank name or an empty command, or a `removePreset` with an empty name, is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Shared constants +The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 5; `BROWSER_PROTOCOL_VERSION` = 6; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. + +#### Scenario: Constants hold their values +- **WHEN** the exported constants are read +- **THEN** each equals the value stated above diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/web/spec.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/web/spec.md new file mode 100644 index 0000000..26b3240 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/specs/web/spec.md @@ -0,0 +1,126 @@ +## MODIFIED Requirements + +### Requirement: Handshake and stale bundle +The page SHALL send `hello` with its own browser protocol version and the package version it was built from, and SHALL compare both with the hub's `hello`. On a difference it SHALL reload once for that hub `instance`; if the difference remains after that reload it SHALL show a message saying the UI is outdated and to run `wtd ui`, without reloading again. + +#### Scenario: Hub upgraded under an open page +- **WHEN** the hub is replaced by one of another version while the page is open +- **THEN** the page reloads once and then runs the bundle the new hub serves + +### Requirement: Repo tabs +The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. The selected tab SHALL be shown in the accent colour. A repo whose watch fails SHALL show the error and its path in its tab's area. A tab of a host that is not `connected` SHALL show the host's status. + +#### Scenario: Duplicate names disambiguated +- **WHEN** the repos are `/a/x/app` and `/b/y/app` +- **THEN** the tabs are labelled `x/app` and `y/app` + +#### Scenario: Watch failure shown +- **WHEN** a configured repo path is not a repository's main worktree +- **THEN** its tab shows the `not-a-repo` error naming the path + +#### Scenario: Remote tab shows host status +- **WHEN** a remote host `box` with repo `/srv/app` is `reconnecting` +- **THEN** the tab labelled `box:app` shows the status `reconnecting` + +### Requirement: Checked filter +The page SHALL offer, for the selected repo, a toggle between showing all worktrees and only checked ones; each repo SHALL keep its own setting, stored per browser across reloads, and a repo without one SHALL show all. With "checked only", the sidebar SHALL list the checked worktrees and the selected worktree. Selecting another repo SHALL show that repo's setting without animating the toggle; only the user's toggling SHALL animate it. + +#### Scenario: Filter persists +- **WHEN** the filter is set to "checked only" and the page is reloaded +- **THEN** the filter is still "checked only" and only checked worktrees are listed + +#### Scenario: Filter belongs to its repo +- **WHEN** the filter is "checked only" in one repo and another repo is selected +- **THEN** the other repo lists all its worktrees, and turning its filter on and off leaves the first repo's "checked only" + +#### Scenario: Switching repos does not animate the toggle +- **WHEN** two repos have different filter settings and the user switches between them +- **THEN** the toggle shows each repo's setting without a transition + +#### Scenario: Selected worktree stays listed +- **WHEN** the selected worktree is unchecked while the filter is "checked only" +- **THEN** it stays listed and selected + +### Requirement: Terminal tabs +The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown and its tab shown in the accent colour; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. An exited terminal's tab, and its pane header in a split, SHALL show that it exited and keep its screen readable until closed. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. + +#### Scenario: New terminal +- **WHEN** the user picks a preset in a worktree without terminals +- **THEN** that preset runs in that worktree's directory, its tab is active, and it is listed in the layout of every page + +#### Scenario: Terminal without a layout tab still shown +- **WHEN** a live terminal of the worktree is missing from its layout +- **THEN** it is shown as a tab + +#### Scenario: Created terminal survives a lost layout write +- **WHEN** the session closes after a terminal is created and before its `setLayout` is answered +- **THEN** after reconnecting the terminal is shown as a tab, and no second terminal was created + +#### Scenario: Exited terminal +- **WHEN** the shell in a terminal exits +- **THEN** its tab shows that it exited and its screen stays readable until it is closed + +### Requirement: Pane focus +Each tab SHALL have one focused pane, kept per page and not stored: the first pane until the user chooses another, the pane the user clicks, a new pane once created, and, when the focused pane's terminal closes, the first pane of the subtree that took its place. In a split, the focused pane's header SHALL be shown in the accent colour. The focused pane SHALL hold the keyboard focus whenever its tab is shown and no picker or dialog is open. + +#### Scenario: Focus follows a closed pane's sibling +- **WHEN** the focused pane of a two-pane split is closed +- **THEN** the remaining pane is focused and receives typed input + +### Requirement: Attention indicators +Each terminal tab, and each pane of a split, SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (a non-zero code or a signal); exited; done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. + +#### Scenario: Agent waiting in a hidden worktree +- **WHEN** a terminal in a non-selected worktree of a non-selected repo rings the bell +- **THEN** its worktree row and its repo tab show the needs-input mark + +#### Scenario: Viewing clears new output +- **WHEN** a hidden terminal prints output and its worktree is then selected +- **THEN** its new-output mark disappears from the tab, the worktree row and the repo tab + +#### Scenario: Failed exit +- **WHEN** a hidden terminal's process exits with code 1 +- **THEN** its tab shows the failed-exit mark and its worktree row shows it until the terminal is viewed + +## ADDED Requirements + +### Requirement: Pane headers +A pane SHALL have a header, naming its terminal's preset and id and holding its mark and a close control, only while its tab holds more than one pane; a lone pane's terminal SHALL fill the pane. + +#### Scenario: Lone pane without header +- **WHEN** a tab holds one pane +- **THEN** the pane shows no header and its terminal starts at the pane's top + +#### Scenario: Split panes with headers +- **WHEN** a single-pane tab is split right +- **THEN** both panes show a header with a close control, and closing one returns the other to a pane without header + +### Requirement: Sidebar width +The sidebar's right edge SHALL be a separator that the user can drag, and move by 16 px with the left and right arrow keys when focused, to set the sidebar's width between 160 and 640 px; the terminal area SHALL take the remaining width. The width SHALL be stored per browser when a drag ends or a key moves it and restored on reload; without a stored width, or with a malformed one, it SHALL be 260 px. + +#### Scenario: Drag within bounds +- **WHEN** the edge is dragged to 400 px, then to 20 px, then to 5000 px from the sidebar's left +- **THEN** the sidebar is 400 px wide, then 160 px, then 640 px + +#### Scenario: Width restored +- **WHEN** the width is set to 384 px and the page is reloaded +- **THEN** the sidebar is 384 px wide + +### Requirement: Editing presets +The preset picker and a worktree's preset choices SHALL offer "+ Add preset", opening a form for a name and a command; submitting SHALL trim both, treat an empty command as the login shell (`command` null) and send `addPreset`, closing the form on `done` and showing the refusal in the form otherwise. A blank name, a name of more than 64 characters, a name already taken, or a command of more than 4096 characters SHALL be refused in the form without sending anything; Escape SHALL close the form without sending anything and without closing the picker. While more than one preset exists, each SHALL offer a remove control sending `removePreset` for it; a refusal SHALL be shown on the page. Every list SHALL show the presets the hub last sent, and the picker SHALL stay open while presets are edited. + +#### Scenario: Preset added from the choices +- **WHEN** the user adds a preset `echo` with a command in a worktree's preset choices while another page is open +- **THEN** `addPreset` is sent once, both pages list `echo` last, `config.json` lists it, and choosing it runs its command + +#### Scenario: Preset added in the picker +- **WHEN** the user adds a preset in the new-tab picker +- **THEN** the picker stays open, lists the new preset, and choosing it opens a terminal running it + +#### Scenario: Preset removed +- **WHEN** the user removes one of two presets +- **THEN** `removePreset` is sent, every page lists only the other preset, `config.json` lists only it, and no remove control is offered any more + +#### Scenario: Taken name refused in the form +- **WHEN** the user submits a name that a preset already has, or a blank name +- **THEN** the form says why, nothing is sent, and Escape then closes the form diff --git a/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/tasks.md b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/tasks.md new file mode 100644 index 0000000..9b7445f --- /dev/null +++ b/openspec/changes/archive/2026-10-08-sidebar-presets-browser-protocol/tasks.md @@ -0,0 +1,33 @@ +## 1. Architecture + +- [x] 1.1 Restate `protocol.arc42.md` purpose and principle 4 for two link versions (enforced by `wire.golden.test.ts`); verify `la-arch-check`, `likec4 validate architecture` and the architecture tests pass + +## 2. Protocol + +- [x] 2.1 Split `PROTOCOL_VERSION` into `DAEMON_PROTOCOL_VERSION` (5) and `BROWSER_PROTOCOL_VERSION` (6); daemon link (daemon, hub links, one-shot links, restarter, `wtd connect`) uses the first, browser link (hub listener, web client) the second (design D1) +- [x] 2.2 Golden guard per link: `protocolVersions {daemon, browser}`, schema and corpus changes need their link's bump, constants need both; guard unit tests for each case; verify the guard accepts this change against the previous golden with only the browser version raised, then re-bless `wire.golden.json` +- [x] 2.3 `addPreset{req, preset}` and `removePreset{req, name}` in the browser-to-hub catalogue, samples, correlation and rejection tests + +## 3. Hub + +- [x] 3.1 Default presets `shell`, `claude`, `codex` (design D4); config tests +- [x] 3.2 Config editor generalised over an array field; `addPreset` / `removePreset` with the refusals of design D2; editor unit tests incl. concurrent repo and preset edits +- [x] 3.3 Session handling of `addPreset` / `removePreset`, `done` then `presets` to every open session (design D3); `test/process` tests for add, remove from defaults, unknown name, refusals; default-preset expectations in hub-server and hub-router tests + +## 4. Web + +- [x] 4.1 Client `addPreset` / `removePreset`; view methods; add form validation (`presetFromForm`) with unit tests; "+ Add preset" and × in the picker and the choices; e2e: add from the choices (saved, shown in every page, runs), add in the new-tab picker, remove (every page, config, last offers none), refused name and Escape +- [x] 4.2 Per-repo filter (`filterOf`, `withFilter`, `wtd.filters`) with unit tests; e2e: filter belongs to its repo and persists; switching repos does not animate the switch, a click does (fails without the fix) +- [x] 4.3 Sidebar width (`clampSidebarWidth`, `parseSidebarWidth`, `wtd.sidebarWidth`) with unit tests; draggable and arrow-key separator; e2e: drag, bounds, keys, reload +- [x] 4.4 Pane headers only in splits, terminal box at the pane's top otherwise (design D6); accent-coloured active repo and terminal tabs; e2e: lone pane has no header and its terminal fills it, split panes have one each; update the attention and exited-terminal e2e tests to read the tab's mark +- [x] 4.5 e2e: adding an already-listed repo selects its tab and adds none + +## 5. CLI, development, documentation + +- [x] 5.1 `wtd --version` prints both protocol versions; unit and process tests +- [x] 5.2 `pnpm dev`: Vite proxy for `/api` and `/ws` with `Host`/`Origin` rewritten, `/login` redirect with a fresh code (design D7) +- [x] 5.3 README: default presets and editing them from the picker; the daemon protocol in `wtd --version` + +## 6. Final gates + +- [x] 6.1 `pnpm test` green (incl. Playwright); `pnpm lint`, `la-typecheck`, `la-arch-check`, `likec4 validate architecture` exit 0; `pnpm build` succeeds; `openspec validate sidebar-presets-browser-protocol --strict` passes diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/design.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/design.md new file mode 100644 index 0000000..65fc079 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/design.md @@ -0,0 +1,33 @@ +## Context + +Builds on the unarchived change `sidebar-presets-browser-protocol` (two link versions; its MODIFIED text is the base here). `architecture/` is normative; principles applied: system 5–8; protocol 3, 4; daemon 1, 3, 6, 7; web 2, 7. Git runs only in `daemon.worktrees` (already allowed `child_process`), composed by `daemon.server`; no new arrow. + +## Goals / Non-Goals + +**Goals:** +- One right-click deletes a worktree that holds nothing at risk; anything at risk is named before it is lost. +- The check and the removal are one daemon request, so the page never decides from a stale view. + +**Non-Goals:** +- Deleting branches (always kept; a forced removal can lose only uncommitted changes). +- Recognising squash or rebase merges: such branches have commits not in `origin/main` and are asked about. + +## Decisions + +### D1 Check and remove in one request +`removeWorktree{force: false}` computes the risks and, when there are none, removes the worktree in the same request; otherwise it answers `worktreeAtRisk` and keeps it. The page then confirms and sends `force: true`. Alternative — a separate inspection request followed by a removal — rejected: two round trips and a window in which the page acts on an outdated answer. While a removal runs, terminals still starting in the worktree are awaited and count as running, and new ones are refused with `busy`, so no terminal outlives its worktree. + +### D2 What is at risk +At risk: commits of the worktree's head not in the base ref (`git rev-list --count ..`), or no base ref at all; uncommitted changes, untracked files included and ignored files not (`git status --porcelain`); live terminals. The base is the ref `origin/HEAD` names, else `origin/main`, else `origin/master`. Exited terminals are not at risk: their output is kept nowhere else either, but nothing runs. + +### D2a Fetch the base first +Before counting, the daemon fetches the base ref's branch from its remote (`git fetch --quiet --no-tags `, no prompts, 20 s at most). A branch merged since the last fetch would otherwise count as unmerged and be asked about. When the fetch fails (offline, no access) the local ref is used; it can only be behind, so the result can only err towards asking. + +### D3 Removal +`git worktree remove [--force] `, which also removes a worktree whose directory is gone, leaving other worktrees' registrations alone. After removal every terminal of the worktree is closed, broadcasting `termClosed`; the worktree watch reports the new list and state for it is pruned. The main worktree and locked ones are refused with `busy`. + +### D4 Versions +Both lists change, so both versions rise: the daemon link to 6 (new request and reply), the browser link to 7 (its envelopes embed the daemon messages). This is the one restart the earlier split could not avoid: the daemon must learn a new request. + +### D5 Hover text and rows +A row's `title` becomes its path followed by ` : ` per terminal, derived from `exit`, `state` and `unseen` alone (no new wire data). Rows carry `data-path`, which the e2e contract now uses to find them, since `title` no longer equals the path. diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/proposal.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/proposal.md new file mode 100644 index 0000000..5f6155a --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/proposal.md @@ -0,0 +1,30 @@ +## Why + +The sidebar shows a worktree's most urgent mark but not what each of its terminals is doing, and finished worktrees can only be removed from a shell. Removing one by hand is also where work gets lost: unmerged commits, uncommitted files and agents still running in it. + +## What Changes + +- **BREAKING** (wire): `DAEMON_PROTOCOL_VERSION` 6 and `BROWSER_PROTOCOL_VERSION` 7 — the daemon link gains `removeWorktree{req, worktree, force}` and its reply `worktreeAtRisk{req, worktree, base, ahead, changes, running}`; the browser link carries them in its `host` envelopes. A daemon of the previous release shows as `outdated`, and restarting it kills its terminals once. +- `daemon.worktrees`: what deleting a worktree would lose — commits of its head not in `origin/main` (the ref `origin/HEAD` names, else `origin/main`, else `origin/master`, fetched from its remote first; unknown without one), and uncommitted changes including untracked files — and removal (`git worktree remove`, forced after confirmation, also for a worktree whose directory is gone). The branch is always kept. +- `daemon.server`: `removeWorktree` without `force` removes at once only when nothing is at risk; otherwise it answers `worktreeAtRisk` and removes nothing. With `force` (after the user confirmed) it removes the worktree and closes all of its terminals. The main and locked worktrees are refused. +- Web: hovering a worktree shows its path and ` : ` per terminal (waiting for input, running, done, idle, exited, exited with code N, killed by SIG…); right-clicking it opens a menu with "Delete worktree"; when the daemon reports risks, an in-page confirmation lists every reason and says the branch is kept. +- CLI: `wtd --version` shows the new versions. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `protocol`: versions 6 and 7; `removeWorktree` and `worktreeAtRisk`; correlation; constants. +- `daemon`: handshake version; worktree removal. +- `web`: worktree hover text; deleting worktrees. +- `cli`: version line. + +## Impact + +- Code: `src/protocol/**`, `src/daemon/worktrees/git.ts`, `src/daemon/server/daemon.ts`, `src/daemon/main/index.ts`, `src/web/client/**`, `src/web/ui/**`, `src/web/main/styles.css`. +- Architecture: no element or arrow changes; daemon principle 3 now names `removeWorktree` among the ways a PTY ends, enforced also by `test/process/daemon-worktrees.test.ts`. +- Wire: `wire.golden.json` re-blessed for `{daemon: 6, browser: 7}`. +- Upgrade: the running daemon must be restarted ("Restart daemon" on the page), killing its terminals once. +- Tests: unit (`daemon/worktrees/git.test.ts` with real git, protocol, status and reason texts), `test/process/daemon-worktrees.test.ts`, `test/e2e/sidebar.spec.ts`; the e2e contract now finds worktree rows by `data-path`, since their `title` carries the status. +- Depends on the change `sidebar-presets-browser-protocol`, whose requirement text the MODIFIED requirements here build on; archive that one first. diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/cli/spec.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/cli/spec.md new file mode 100644 index 0000000..93faf38 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/cli/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Version output +`wtd --version` SHALL print `wtd (daemon protocol , browser protocol )` and a newline to stdout and exit 0. + +#### Scenario: Version +- **WHEN** the user runs `wtd --version` +- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 7)` followed by a newline, stderr is empty, and the exit code is 0 diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/daemon/spec.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/daemon/spec.md new file mode 100644 index 0000000..7db0892 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/daemon/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements + +### Requirement: Handshake and version mismatch +On every new connection the daemon SHALL send `hello` with `protocol` equal to `DAEMON_PROTOCOL_VERSION`, its package version, and an `instance` chosen at random once per process start. If the client's first message is not `hello`, the daemon SHALL send `error{req: null, code: bad-message}` and close. If the client's `hello` carries a different `protocol`, the connection SHALL accept only the frozen `shutdown` message; every other frame, including undecodable ones, SHALL get `error{req: null, code: version-mismatch}`. + +#### Scenario: Daemon hello +- **WHEN** a client connects +- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 6 + +#### Scenario: Message before hello +- **WHEN** a client's first message is a `watchRepo` +- **THEN** the daemon replies `error` with code `bad-message` and closes the connection + +#### Scenario: Mismatched client can only shut down +- **WHEN** a client sends `hello` with `protocol` 1, then a `watchRepo`, then `shutdown` +- **THEN** the `watchRepo` gets `error{req: null, code: version-mismatch}` and the `shutdown` stops the daemon + +## ADDED Requirements + +### Requirement: Worktree removal +`removeWorktree{req, worktree, force}` SHALL fail with `unknown-worktree` unless the worktree is in the current list of a repo this connection watches, and with `busy` for the main worktree or a locked one. Without `force`, the daemon SHALL determine what removal would put at risk: `base`, the remote-tracking ref `origin/HEAD` names, else `origin/main`, else `origin/master`, shown without `refs/remotes/`, or null when none exists; `ahead`, the number of commits reachable from the worktree's head and not from `base` (0 without a head), counted after fetching `base`'s branch from its remote — from the local ref when that fails — or null without a base; `changes`, the number of uncommitted changes in its directory, untracked files included and ignored files not (0 when its directory is gone); `running`, the ids of its terminals that have not exited. When `ahead` is not 0, `changes` is not 0 or `running` is not empty, it SHALL answer `worktreeAtRisk` with those values and change nothing. Otherwise, and always with `force`, it SHALL remove the worktree — `git worktree remove` of that worktree alone, with `--force` when `force` is set, also when its directory is gone — keeping its branch, then close every terminal of the worktree, sending `termClosed` to the repo's watchers, and answer `done`. A failed removal SHALL answer `error` naming git's message and close no terminal. A removal SHALL first wait for terminals still starting in the worktree, which then count as running; while a removal of a worktree is in progress, `createTerm` and `removeWorktree` for it SHALL fail with `busy`. + +#### Scenario: Nothing at risk +- **WHEN** a worktree whose branch is in `origin/main`, with no changes and only an exited terminal, is removed without `force` +- **THEN** its directory is gone, its branch remains, the exited terminal is closed, watchers receive `worktreesChanged` without it, and the requester receives `done` + +#### Scenario: Every risk reported +- **WHEN** a worktree with one commit not in `origin/main`, one untracked file and a running terminal is removed without `force` +- **THEN** the requester receives `worktreeAtRisk` with `base` "origin/main", `ahead` 1, `changes` 1 and that terminal's id, and the worktree and terminal remain + +#### Scenario: Merged since the last fetch +- **WHEN** a worktree's branch was merged into the remote's `main` after the repo last fetched, and the worktree is removed without `force` +- **THEN** the daemon fetches `origin/main` first, finds nothing ahead, and removes the worktree + +#### Scenario: Remote unreachable +- **WHEN** the remote cannot be reached and the worktree has one commit not in the local `origin/main` +- **THEN** the requester receives `worktreeAtRisk` with `ahead` 1 + +#### Scenario: Terminal starting during a removal +- **WHEN** a terminal of the worktree is still starting when `removeWorktree` without `force` arrives +- **THEN** the removal waits for it and answers `worktreeAtRisk` listing it as running + +#### Scenario: No terminal starts in a worktree being removed +- **WHEN** `createTerm` for a worktree arrives while its removal is in progress +- **THEN** it fails with `busy` and no terminal starts + +#### Scenario: No base to compare with +- **WHEN** a worktree of a repo without an `origin` is removed without `force` +- **THEN** the requester receives `worktreeAtRisk` with `base` and `ahead` null, and the worktree remains + +#### Scenario: Forced removal +- **WHEN** that worktree at risk is removed with `force` +- **THEN** its directory is gone, its branch remains, its running terminal is closed, and the requester receives `done` + +#### Scenario: Main and locked worktrees refused +- **WHEN** the main worktree or a locked worktree is removed, even with `force` +- **THEN** the requester receives `error` with code `busy` and nothing changes diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/protocol/spec.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/protocol/spec.md new file mode 100644 index 0000000..d911120 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/protocol/spec.md @@ -0,0 +1,87 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 7, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 7 is decoded +- **THEN** decoding succeeds and yields `protocol` 7 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + +### Requirement: Daemon-link message catalogue +Client-to-daemon messages SHALL be exactly: `hello`; `shutdown`; `watchRepo{req, repo}`; `unwatchRepo{req, repo}`; `discoverRepos{req, roots, depth}`; `createTerm{req, worktree, preset, command, cols, rows}`; `attach{req, termId}`; `detach{req, termId}`; `resize{termId, cols, rows}`; `closeTerm{req, termId}`; `ack{termId, offset}`; `setVisible{termIds}`; `setChecked{req, worktree, checked}`; `setLayout{req, worktree, layout}`; `removeWorktree{req, worktree, force}` with `force` a boolean. +Daemon-to-client messages SHALL be exactly: `hello`; `done{req}`; `error{req, code, message}`; `repoState{repo, worktrees, terminals, checked, layouts}`; `worktreesChanged{repo, worktrees}`; `termCreated{req, term}` with `req` a request id or null; `termExited{termId, code, signal}`; `termClosed{termId}`; `detached{termId, reason}`; `activity{termId, unseen, state}`; `checkedChanged{worktree, checked}`; `layoutChanged{worktree, layout}`; `reposDiscovered{req, repos}`; `worktreeAtRisk{req, worktree, base, ahead, changes, running}` where `base` is null or at most 4096 characters, `ahead` is null or an integer from 0 to 2^32−1, `changes` is an integer from 0 to 2^32−1 and `running` is at most 1024 terminal ids. +A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main}`; a terminal SHALL be `{termId, worktree, preset, cols, rows, exit, unseen, state}` where `exit` is null or `{code, signal}`; `state` SHALL be one of `working`, `idle`, `input`; a `layouts` entry SHALL be `{worktree, layout}`; `reason` SHALL be `"lagging"`; `detached`, `locked`, `prunable`, `bare`, `main`, `checked`, `unseen` and `remote` SHALL be booleans; `code` SHALL be an integer; `error.req` SHALL be a request id or null. + +#### Scenario: Broadcast termCreated carries a null req +- **WHEN** a `termCreated` with `req` null is decoded as a daemon-to-client message +- **THEN** decoding succeeds and yields `req` null + +#### Scenario: Every daemon-link message round-trips +- **WHEN** a valid instance of each daemon-link message is encoded and decoded +- **THEN** each decoded value equals its original + +#### Scenario: Detached carries a known reason +- **WHEN** a `detached` message with `reason` "closed" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Activity carries an attention state +- **WHEN** an `activity` with `state` "input" is decoded +- **THEN** decoding succeeds and yields `state` "input" + +#### Scenario: Bell flag no longer accepted +- **WHEN** an `activity` carrying `bell` instead of `state`, or a terminal entry carrying `bell`, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Unknown attention state rejected +- **WHEN** an `activity` with `state` "busy" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Worktree removal needs force +- **WHEN** a `removeWorktree` without `force` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Worktree risks are counts +- **WHEN** a `worktreeAtRisk` with `ahead` −1 or `changes` 0.5 is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Request correlation +Every message that asks a peer to act and can fail SHALL carry `req`, an integer from 1 to 2^32−1, and its outcome SHALL be correlated by that `req`: `termCreated` for `createTerm`, `reposDiscovered` for `discoverRepos`, `worktreeAtRisk` or `done` for `removeWorktree`, `done` for every other request, `error` for any failed request. A `termCreated` or `error` with `req` null SHALL correlate with no request. `resize`, `ack`, `setVisible`, input frames, `hello` and `shutdown` SHALL carry no `req`. + +#### Scenario: Request without req rejected +- **WHEN** a `closeTerm` message without `req` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Fire-and-forget with req rejected +- **WHEN** an `ack` message carrying `req` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Uncorrelated error allowed +- **WHEN** a daemon `error` with `req` null is decoded +- **THEN** decoding succeeds + +#### Scenario: Risk report needs its request +- **WHEN** a `worktreeAtRisk` without `req` is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Shared constants +The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 6; `BROWSER_PROTOCOL_VERSION` = 7; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. + +#### Scenario: Constants hold their values +- **WHEN** the exported constants are read +- **THEN** each equals the value stated above diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/web/spec.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/web/spec.md new file mode 100644 index 0000000..1f63c27 --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/specs/web/spec.md @@ -0,0 +1,49 @@ +## MODIFIED Requirements + +### Requirement: Worktree sidebar +The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or ` @ ` when detached, or the directory name when it has no head. On hover a worktree SHALL show its path followed by one line ` : ` per terminal, or "no terminals", where the status is `waiting for input` (`state` `input`), `running` (`state` `working`), `done` (`state` `idle` and `unseen`), `idle` (`state` `idle`), `exited`, `exited with code ` or `killed by `, the exit taking precedence. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. Worktree list changes SHALL show without user action. + +#### Scenario: Labels +- **WHEN** worktrees are on branch `feature/x`, detached at `0123456789…` in directory `wt2`, and bare +- **THEN** the sidebar shows `feature/x` and `wt2 @ 0123456`, and no bare entry + +#### Scenario: Status on hover +- **WHEN** a worktree has a terminal `probe` 3 printing continuously and a terminal `probe` 4 that exited with code 1 +- **THEN** hovering it shows its path, `probe 3: running` and `probe 4: exited with code 1` + +#### Scenario: Vanished worktree keeps its terminals +- **WHEN** a worktree with a running terminal is removed with `git worktree remove --force` +- **THEN** it stays listed as gone, its terminal remains usable, and it disappears after the terminal is closed + +#### Scenario: Checkbox shared +- **WHEN** a worktree is checked in one page +- **THEN** it shows as checked in another page connected to the same hub + +## ADDED Requirements + +### Requirement: Deleting worktrees +Right-clicking a worktree SHALL open a menu offering "Delete worktree", disabled with the reason on hover for the main worktree, a locked or gone worktree, or while its host is not `connected`; Escape or a press outside SHALL close the menu. Choosing it SHALL send `removeWorktree` with `force` false. On `done` nothing more SHALL be asked. On `worktreeAtRisk` the page SHALL show an in-page confirmation naming the worktree, listing every reason that applies — the commits not in the base ref, or that there is no `origin/main` to check against; the uncommitted changes, untracked files included; the running terminals that will be closed, by preset and id — and saying that its branch is kept or, for a detached worktree, that its commits not in the base ref become unreachable, naming its head commit; confirming SHALL send `removeWorktree` with `force` true, cancelling SHALL send nothing. A failure SHALL be shown on the page. + +#### Scenario: Clean worktree deleted without asking +- **WHEN** the user deletes a worktree whose branch is in `origin/main`, without changes or running terminals +- **THEN** it disappears from the sidebar, its directory is gone and no confirmation was shown + +#### Scenario: Every reason listed +- **WHEN** the user deletes a worktree with one commit not in `origin/main`, one untracked file and a running terminal `probe` 5 +- **THEN** the confirmation lists "It has 1 commit not in origin/main.", "It has 1 uncommitted change, untracked files included." and "Running terminals will be closed: probe 5." + +#### Scenario: Detached worktree +- **WHEN** the user deletes a detached worktree with one commit not in `origin/main` +- **THEN** the confirmation lists that commit, says its commits become unreachable and names its head + +#### Scenario: Cancel keeps the worktree +- **WHEN** the user cancels that confirmation +- **THEN** nothing is sent and the worktree and its terminal remain + +#### Scenario: Confirm deletes it +- **WHEN** the user confirms it +- **THEN** the worktree disappears, its terminal is closed and its branch remains + +#### Scenario: Main worktree +- **WHEN** the user right-clicks the main worktree +- **THEN** "Delete worktree" is disabled and says the main worktree cannot be deleted diff --git a/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/tasks.md b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/tasks.md new file mode 100644 index 0000000..0cee84c --- /dev/null +++ b/openspec/changes/archive/2026-10-08-worktree-status-and-deletion/tasks.md @@ -0,0 +1,21 @@ +## 1. Architecture + +- [x] 1.1 Daemon principle 3 names `removeWorktree` among the ways a PTY ends, enforced also by `test/process/daemon-worktrees.test.ts`; verify `la-arch-check` and the architecture tests pass + +## 2. Protocol + +- [x] 2.1 `removeWorktree{req, worktree, force}` and `worktreeAtRisk{req, worktree, base, ahead, changes, running}`; `DAEMON_PROTOCOL_VERSION` 6, `BROWSER_PROTOCOL_VERSION` 7; samples, correlation and rejection tests, envelope test; re-bless `wire.golden.json`; version expectations in unit and process tests + +## 3. Daemon + +- [x] 3.1 `daemon.worktrees`: `worktreeRisks` (base ref fetched first, commits ahead, changes) and `removeWorktree` (remove, force, prune) with real-git unit tests (no origin, origin/HEAD, fallback, merged, merged since the last fetch, remote unreachable, ignored files, gone directory, branch kept) +- [x] 3.2 `daemon.server`: `removeWorktree` per design D1–D3; process tests: clean removal closing exited terminals, every risk reported, no origin, forced removal closing running terminals, main/locked/unknown refused; unit tests: a terminal starting during a removal is awaited and reported, `createTerm` during a removal is refused + +## 4. Web + +- [x] 4.1 `terminalStatus`, `worktreeTitle`, `removalReasons` with unit tests; row `title` and `data-path`; e2e contract by `data-path` +- [x] 4.2 Worktree context menu, "Delete worktree" with its blockers, confirmation listing reasons; e2e: hover text follows terminals, clean worktree deleted without asking, at-risk worktree lists all reasons and is kept on cancel and deleted on confirm, detached worktree says its commits become unreachable and names its head, main worktree disabled, Escape closes the menu + +## 5. Final gates + +- [x] 5.1 `pnpm test` green (incl. Playwright); `pnpm lint`, `la-typecheck`, `la-arch-check` exit 0; `openspec validate worktree-status-and-deletion --strict` passes diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 7a981f5..9a12a52 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -6,11 +6,11 @@ The `wtd` command line surface: how a user discovers its commands and version, a ## Requirements ### Requirement: Version output -`wtd --version` SHALL print `wtd (protocol )` and a newline to stdout and exit 0. +`wtd --version` SHALL print `wtd (daemon protocol , browser protocol )` and a newline to stdout and exit 0. #### Scenario: Version - **WHEN** the user runs `wtd --version` -- **THEN** stdout is `wtd (protocol 5)` followed by a newline, stderr is empty, and the exit code is 0 +- **THEN** stdout is `wtd (daemon protocol 6, browser protocol 8)` followed by a newline, stderr is empty, and the exit code is 0 ### Requirement: Help output `wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. diff --git a/openspec/specs/daemon/spec.md b/openspec/specs/daemon/spec.md index 06dcf76..2fab8fc 100644 --- a/openspec/specs/daemon/spec.md +++ b/openspec/specs/daemon/spec.md @@ -21,11 +21,11 @@ The daemon SHALL listen only on this host's socket (see `platform`), with mode 0 - **THEN** the second waits for the lock and does not remove or bind the socket while the first holds it ### Requirement: Handshake and version mismatch -On every new connection the daemon SHALL send `hello` with `protocol` equal to `PROTOCOL_VERSION`, its package version, and an `instance` chosen at random once per process start. If the client's first message is not `hello`, the daemon SHALL send `error{req: null, code: bad-message}` and close. If the client's `hello` carries a different `protocol`, the connection SHALL accept only the frozen `shutdown` message; every other frame, including undecodable ones, SHALL get `error{req: null, code: version-mismatch}`. +On every new connection the daemon SHALL send `hello` with `protocol` equal to `DAEMON_PROTOCOL_VERSION`, its package version, and an `instance` chosen at random once per process start. If the client's first message is not `hello`, the daemon SHALL send `error{req: null, code: bad-message}` and close. If the client's `hello` carries a different `protocol`, the connection SHALL accept only the frozen `shutdown` message; every other frame, including undecodable ones, SHALL get `error{req: null, code: version-mismatch}`. #### Scenario: Daemon hello - **WHEN** a client connects -- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 5 +- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 6 #### Scenario: Message before hello - **WHEN** a client's first message is a `watchRepo` @@ -333,3 +333,42 @@ Checked state and layouts SHALL be stored in `state.json` and survive daemon res #### Scenario: Shutdown ends terminals - **WHEN** a client sends `shutdown` while terminals run - **THEN** their processes end, the socket file is removed, and the daemon exits 0 + +### Requirement: Worktree removal +`removeWorktree{req, worktree, force}` SHALL fail with `unknown-worktree` unless the worktree is in the current list of a repo this connection watches, and with `busy` for the main worktree or a locked one. Without `force`, the daemon SHALL determine what removal would put at risk: `base`, the remote-tracking ref `origin/HEAD` names, else `origin/main`, else `origin/master`, shown without `refs/remotes/`, or null when none exists; `ahead`, the number of commits reachable from the worktree's head and not from `base` (0 without a head), counted after fetching `base`'s branch from its remote — from the local ref when that fails — or null without a base; `changes`, the number of uncommitted changes in its directory, untracked files included and ignored files not (0 when its directory is gone); `running`, the ids of its terminals that have not exited. When `ahead` is not 0, `changes` is not 0 or `running` is not empty, it SHALL answer `worktreeAtRisk` with those values and change nothing. Otherwise, and always with `force`, it SHALL remove the worktree — `git worktree remove` of that worktree alone, with `--force` when `force` is set, also when its directory is gone — keeping its branch, then close every terminal of the worktree, sending `termClosed` to the repo's watchers, and answer `done`. A failed removal SHALL answer `error` naming git's message and close no terminal. A removal SHALL first wait for terminals still starting in the worktree, which then count as running; while a removal of a worktree is in progress, `createTerm` and `removeWorktree` for it SHALL fail with `busy`. + +#### Scenario: Nothing at risk +- **WHEN** a worktree whose branch is in `origin/main`, with no changes and only an exited terminal, is removed without `force` +- **THEN** its directory is gone, its branch remains, the exited terminal is closed, watchers receive `worktreesChanged` without it, and the requester receives `done` + +#### Scenario: Every risk reported +- **WHEN** a worktree with one commit not in `origin/main`, one untracked file and a running terminal is removed without `force` +- **THEN** the requester receives `worktreeAtRisk` with `base` "origin/main", `ahead` 1, `changes` 1 and that terminal's id, and the worktree and terminal remain + +#### Scenario: Merged since the last fetch +- **WHEN** a worktree's branch was merged into the remote's `main` after the repo last fetched, and the worktree is removed without `force` +- **THEN** the daemon fetches `origin/main` first, finds nothing ahead, and removes the worktree + +#### Scenario: Remote unreachable +- **WHEN** the remote cannot be reached and the worktree has one commit not in the local `origin/main` +- **THEN** the requester receives `worktreeAtRisk` with `ahead` 1 + +#### Scenario: Terminal starting during a removal +- **WHEN** a terminal of the worktree is still starting when `removeWorktree` without `force` arrives +- **THEN** the removal waits for it and answers `worktreeAtRisk` listing it as running + +#### Scenario: No terminal starts in a worktree being removed +- **WHEN** `createTerm` for a worktree arrives while its removal is in progress +- **THEN** it fails with `busy` and no terminal starts + +#### Scenario: No base to compare with +- **WHEN** a worktree of a repo without an `origin` is removed without `force` +- **THEN** the requester receives `worktreeAtRisk` with `base` and `ahead` null, and the worktree remains + +#### Scenario: Forced removal +- **WHEN** that worktree at risk is removed with `force` +- **THEN** its directory is gone, its branch remains, its running terminal is closed, and the requester receives `done` + +#### Scenario: Main and locked worktrees refused +- **WHEN** the main worktree or a locked worktree is removed, even with `force` +- **THEN** the requester receives `error` with code `busy` and nothing changes diff --git a/openspec/specs/hub/spec.md b/openspec/specs/hub/spec.md index 2d6c03e..3f11a53 100644 --- a/openspec/specs/hub/spec.md +++ b/openspec/specs/hub/spec.md @@ -111,7 +111,7 @@ A WebSocket upgrade SHALL be accepted only at `/ws`, with exactly one `Origin` h - **THEN** the session is closed ### Requirement: Browser session handshake -On an accepted upgrade the hub SHALL send `hello` with `protocol` equal to `PROTOCOL_VERSION`, its package version and its `instance`, then, if the session authenticated with a code, `token{token}`. After the browser's `hello` it SHALL send `presets` with the presets of the session's configuration snapshot, then `hosts`. A message before the browser's `hello` SHALL get `error{req: null, host: null, code: bad-message}` and close the session; a browser `hello` with another `protocol` SHALL get `error{req: null, host: null, code: version-mismatch}` and close it. +On an accepted upgrade the hub SHALL send `hello` with `protocol` equal to `BROWSER_PROTOCOL_VERSION`, its package version and its `instance`, then, if the session authenticated with a code, `token{token}`. After the browser's `hello` it SHALL send `presets` with the presets of the session's configuration snapshot, then `hosts`. A message before the browser's `hello` SHALL get `error{req: null, host: null, code: bad-message}` and close the session; a browser `hello` with another `protocol` SHALL get `error{req: null, host: null, code: version-mismatch}` and close it. #### Scenario: Code session receives the token - **WHEN** a browser connects with a valid code @@ -129,6 +129,10 @@ On an accepted upgrade the hub SHALL send `hello` with `protocol` equal to `PROT - **WHEN** a browser's first message is a `host` envelope - **THEN** it receives `error` with `bad-message` and the session closes +#### Scenario: Browser of another protocol refused +- **WHEN** a browser sends `hello` with `protocol` equal to `BROWSER_PROTOCOL_VERSION` + 1 +- **THEN** it receives `error` with `version-mismatch` and the session closes + ### Requirement: Malformed browser traffic After the handshake, a message that fails to decode, a second `hello`, or an output or snapshot frame from the browser SHALL get `error{req: null, host: null, code: bad-message}` and close that session only. @@ -137,11 +141,11 @@ After the handshake, a message that fails to decode, a second `hello`, or an out - **THEN** that session is closed and the other keeps working ### Requirement: Configuration -The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo added from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}, {name: "claude", command: "claude"}, {name: "codex", command: "codex"}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo or preset edit from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. #### Scenario: Defaults without a file - **WHEN** no `config.json` exists and the hub starts -- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the single preset `shell` with command null +- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the presets `shell` (command null), `claude` (command `claude`) and `codex` (command `codex`), in that order #### Scenario: Unknown key refused - **WHEN** `config.json` holds the key `theme` and the hub starts @@ -172,7 +176,7 @@ The configuration SHALL be `config.json` holding a JSON object with optional `po - **THEN** the start fails with a line naming the file and `hosts` #### Scenario: Edit picked up by a new session -- **WHEN** a repo or a preset is added to `config.json` while a session is open, and a new session starts +- **WHEN** a repo or a preset is added to `config.json` by hand while a session is open, and a new session starts - **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change #### Scenario: Invalid edit keeps the last valid configuration @@ -347,7 +351,7 @@ When a session's WebSocket closes, the hub SHALL close that session's links; ter - **THEN** it is removed from `config.json` and the browser receives `done` ### Requirement: Configuration edits -A configuration edit SHALL read the current `config.json` (an absent file read as `{}`), change only the `repos` of the edited host — the top-level `repos` for the local host, otherwise those of the host entry with the session's host name — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host, or the result would hold more than 256 repos for the host. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits SHALL be applied one at a time. After an edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; nothing else in any session's snapshot SHALL change. +A configuration edit — a repo edit or a preset edit — SHALL read the current `config.json` (an absent file read as `{}`), change only the edited list — for a repo edit the `repos` of the edited host, the top-level `repos` for the local host, otherwise those of the host entry with the session's host name; for a preset edit the top-level `presets`, starting from the default presets when the file has none — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host of a repo edit, the result would hold more than 256 repos for the host, or a preset edit is refused. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits of either kind SHALL be applied one at a time. After a repo edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; after a preset edit that changed the file, every open session SHALL receive `presets` with the edited presets; nothing else in any session's snapshot SHALL change. #### Scenario: Other entries kept as written - **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it @@ -364,3 +368,38 @@ A configuration edit SHALL read the current `config.json` (an absent file read a #### Scenario: Host gone from the file - **WHEN** a session's host `box` was removed from `config.json` and that session adds a repo for `box` - **THEN** it receives `error` with code `internal` and the file is unchanged + +#### Scenario: Repo and preset edits do not lose each other +- **WHEN** a repo is added and a preset removed at the same time +- **THEN** the file holds both changes + +### Requirement: Editing presets +`addPreset{req, preset}` SHALL append the preset to the presets, `removePreset{req, name}` SHALL remove the preset of exactly that name and `movePreset{req, name, to}` SHALL move the preset of exactly that name to position `to`, or to the last position when `to` is beyond it, each as a configuration edit. The requester SHALL receive `done`, and only then every open session `presets`. `addPreset` SHALL be refused with `error{req, host: null, code: internal}` naming the reason when a preset of that name exists or there would be more than 64 presets; `removePreset` SHALL be refused that way when it would remove the last preset. A `removePreset` or `movePreset` naming no preset, and a `movePreset` to the preset's own position, SHALL answer `done`, leave the file unchanged and send no `presets`. + +#### Scenario: Preset added +- **WHEN** a session adds the preset `htop` (command `htop`) while `config.json` lists `shell` and two sessions are open +- **THEN** the requester receives `done`, both sessions then receive `presets` listing `shell` and `htop`, and `config.json` lists both + +#### Scenario: Preset removed from the defaults +- **WHEN** `config.json` has no `presets` and a session removes `claude` +- **THEN** `config.json` lists `shell` and `codex`, and every open session receives those presets + +#### Scenario: Taken name refused +- **WHEN** a session adds a preset named `shell` while `shell` exists +- **THEN** it receives `error` with code `internal` saying a preset named `shell` exists, and the file is unchanged + +#### Scenario: Last preset kept +- **WHEN** `config.json` lists only `shell` and a session removes it +- **THEN** it receives `error` with code `internal` saying the last preset cannot be removed, and the file is unchanged + +#### Scenario: Unknown name +- **WHEN** a session removes a preset named `nope` that does not exist +- **THEN** it receives `done`, no session receives `presets`, and the file is unchanged + +#### Scenario: Preset moved +- **WHEN** `config.json` lists `shell`, `claude` and `htop`, two sessions are open and one moves `htop` to position 0 +- **THEN** the requester receives `done`, both sessions then receive `presets` listing `htop`, `shell` and `claude`, and `config.json` lists them in that order + +#### Scenario: Move beyond the end +- **WHEN** a session moves the first of three presets to position 63 +- **THEN** it becomes the last preset diff --git a/openspec/specs/protocol/spec.md b/openspec/specs/protocol/spec.md index 97f0e5e..4ceaba8 100644 --- a/openspec/specs/protocol/spec.md +++ b/openspec/specs/protocol/spec.md @@ -6,7 +6,7 @@ The wire contract between the browser UI, the hub and every daemon: which messag ## Requirements ### Requirement: Protocol version and frozen handshake messages -The protocol SHALL define an integer `PROTOCOL_VERSION`, equal to 5. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every protocol version, so that peers of different versions can always exchange them. +The protocol SHALL define two integer versions: `DAEMON_PROTOCOL_VERSION`, equal to 6, for the daemon link (client ↔ daemon), and `BROWSER_PROTOCOL_VERSION`, equal to 8, for the browser link (browser ↔ hub). A change to the messages of one link SHALL raise that link's version; a change to the shared constants SHALL raise both. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1 carrying the sender's version of the link it is sent on, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every version of either link, so that peers of different versions can always exchange them. #### Scenario: Hello round-trips - **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded @@ -20,6 +20,14 @@ The protocol SHALL define an integer `PROTOCOL_VERSION`, equal to 5. The `hello` - **WHEN** a `hello` whose `instance` contains a space is decoded - **THEN** decoding fails with a protocol error +#### Scenario: Browser-link change leaves the daemon version +- **WHEN** a message is added to the browser-to-hub catalogue and only `BROWSER_PROTOCOL_VERSION` is raised +- **THEN** the wire golden check passes, and it fails if a daemon-link schema changed as well + +#### Scenario: Shared constant needs both versions +- **WHEN** `MAX_FRAME` changes and only one of the two versions is raised +- **THEN** the wire golden check fails + ### Requirement: Strict control-message decoding A control message SHALL be one UTF-8 JSON object whose `t` names a message of the decoding direction and whose fields are exactly those of that message. Decoding SHALL reject, with a protocol error and without coercion or repair: invalid UTF-8, invalid JSON, a non-object, an unknown `t`, a message of the opposite direction, a missing field, an extra field, a value of the wrong type, and a value outside its bounds. Encoding SHALL reject any value that would not decode. @@ -44,8 +52,8 @@ A control message SHALL be one UTF-8 JSON object whose `t` names a message of th - **THEN** encoding fails with a protocol error ### Requirement: Daemon-link message catalogue -Client-to-daemon messages SHALL be exactly: `hello`; `shutdown`; `watchRepo{req, repo}`; `unwatchRepo{req, repo}`; `discoverRepos{req, roots, depth}`; `createTerm{req, worktree, preset, command, cols, rows}`; `attach{req, termId}`; `detach{req, termId}`; `resize{termId, cols, rows}`; `closeTerm{req, termId}`; `ack{termId, offset}`; `setVisible{termIds}`; `setChecked{req, worktree, checked}`; `setLayout{req, worktree, layout}`. -Daemon-to-client messages SHALL be exactly: `hello`; `done{req}`; `error{req, code, message}`; `repoState{repo, worktrees, terminals, checked, layouts}`; `worktreesChanged{repo, worktrees}`; `termCreated{req, term}` with `req` a request id or null; `termExited{termId, code, signal}`; `termClosed{termId}`; `detached{termId, reason}`; `activity{termId, unseen, state}`; `checkedChanged{worktree, checked}`; `layoutChanged{worktree, layout}`; `reposDiscovered{req, repos}`. +Client-to-daemon messages SHALL be exactly: `hello`; `shutdown`; `watchRepo{req, repo}`; `unwatchRepo{req, repo}`; `discoverRepos{req, roots, depth}`; `createTerm{req, worktree, preset, command, cols, rows}`; `attach{req, termId}`; `detach{req, termId}`; `resize{termId, cols, rows}`; `closeTerm{req, termId}`; `ack{termId, offset}`; `setVisible{termIds}`; `setChecked{req, worktree, checked}`; `setLayout{req, worktree, layout}`; `removeWorktree{req, worktree, force}` with `force` a boolean. +Daemon-to-client messages SHALL be exactly: `hello`; `done{req}`; `error{req, code, message}`; `repoState{repo, worktrees, terminals, checked, layouts}`; `worktreesChanged{repo, worktrees}`; `termCreated{req, term}` with `req` a request id or null; `termExited{termId, code, signal}`; `termClosed{termId}`; `detached{termId, reason}`; `activity{termId, unseen, state}`; `checkedChanged{worktree, checked}`; `layoutChanged{worktree, layout}`; `reposDiscovered{req, repos}`; `worktreeAtRisk{req, worktree, base, ahead, changes, running}` where `base` is null or at most 4096 characters, `ahead` is null or an integer from 0 to 2^32−1, `changes` is an integer from 0 to 2^32−1 and `running` is at most 1024 terminal ids. A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main}`; a terminal SHALL be `{termId, worktree, preset, cols, rows, exit, unseen, state}` where `exit` is null or `{code, signal}`; `state` SHALL be one of `working`, `idle`, `input`; a `layouts` entry SHALL be `{worktree, layout}`; `reason` SHALL be `"lagging"`; `detached`, `locked`, `prunable`, `bare`, `main`, `checked`, `unseen` and `remote` SHALL be booleans; `code` SHALL be an integer; `error.req` SHALL be a request id or null. #### Scenario: Broadcast termCreated carries a null req @@ -72,8 +80,16 @@ A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main - **WHEN** an `activity` with `state` "busy" is decoded - **THEN** decoding fails with a protocol error +#### Scenario: Worktree removal needs force +- **WHEN** a `removeWorktree` without `force` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Worktree risks are counts +- **WHEN** a `worktreeAtRisk` with `ahead` −1 or `changes` 0.5 is decoded +- **THEN** decoding fails with a protocol error + ### Requirement: Browser-link message catalogue -Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`. +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`; `addPreset{req, preset}`; `removePreset{req, name}` where `name` is a preset name; `movePreset{req, name, to}` where `name` is a preset name and `to` an integer from 0 to 63. Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. @@ -101,8 +117,20 @@ A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instan - **WHEN** a `token` message is decoded as a browser-to-hub message - **THEN** decoding fails with a protocol error +#### Scenario: Preset edits only from the browser +- **WHEN** a `removePreset` message is decoded as a hub-to-browser message, or an `addPreset` as a client-to-daemon message +- **THEN** decoding fails with a protocol error + +#### Scenario: Invalid preset edit rejected +- **WHEN** an `addPreset` whose preset has a blank name or an empty command, or a `removePreset` with an empty name, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Preset position bounded +- **WHEN** a `movePreset` with `to` −1, 64 or 0.5 is decoded +- **THEN** decoding fails with a protocol error + ### Requirement: Request correlation -Every message that asks a peer to act and can fail SHALL carry `req`, an integer from 1 to 2^32−1, and its outcome SHALL be correlated by that `req`: `termCreated` for `createTerm`, `reposDiscovered` for `discoverRepos`, `done` for every other request, `error` for any failed request. A `termCreated` or `error` with `req` null SHALL correlate with no request. `resize`, `ack`, `setVisible`, input frames, `hello` and `shutdown` SHALL carry no `req`. +Every message that asks a peer to act and can fail SHALL carry `req`, an integer from 1 to 2^32−1, and its outcome SHALL be correlated by that `req`: `termCreated` for `createTerm`, `reposDiscovered` for `discoverRepos`, `worktreeAtRisk` or `done` for `removeWorktree`, `done` for every other request, `error` for any failed request. A `termCreated` or `error` with `req` null SHALL correlate with no request. `resize`, `ack`, `setVisible`, input frames, `hello` and `shutdown` SHALL carry no `req`. #### Scenario: Request without req rejected - **WHEN** a `closeTerm` message without `req` is decoded @@ -116,6 +144,10 @@ Every message that asks a peer to act and can fail SHALL carry `req`, an integer - **WHEN** a daemon `error` with `req` null is decoded - **THEN** decoding succeeds +#### Scenario: Risk report needs its request +- **WHEN** a `worktreeAtRisk` without `req` is decoded +- **THEN** decoding fails with a protocol error + ### Requirement: Value limits Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. @@ -233,7 +265,7 @@ On a stream, an output or snapshot payload SHALL be a 4-byte terminal id, an 8-b - **THEN** decoding fails with a protocol error ### Requirement: Shared constants -The protocol SHALL export: `PROTOCOL_VERSION` = 5; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. +The protocol SHALL export: `DAEMON_PROTOCOL_VERSION` = 6; `BROWSER_PROTOCOL_VERSION` = 8; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. #### Scenario: Constants hold their values - **WHEN** the exported constants are read diff --git a/openspec/specs/web/spec.md b/openspec/specs/web/spec.md index f3be68a..77609b5 100644 --- a/openspec/specs/web/spec.md +++ b/openspec/specs/web/spec.md @@ -17,7 +17,7 @@ On load, a URL fragment `#code=` SHALL be used once to open the session, t - **THEN** it shows the `wtd ui` message and opens no session ### Requirement: Handshake and stale bundle -The page SHALL send `hello` with its own protocol version and the package version it was built from, and SHALL compare both with the hub's `hello`. On a difference it SHALL reload once for that hub `instance`; if the difference remains after that reload it SHALL show a message saying the UI is outdated and to run `wtd ui`, without reloading again. +The page SHALL send `hello` with its own browser protocol version and the package version it was built from, and SHALL compare both with the hub's `hello`. On a difference it SHALL reload once for that hub `instance`; if the difference remains after that reload it SHALL show a message saying the UI is outdated and to run `wtd ui`, without reloading again. #### Scenario: Hub upgraded under an open page - **WHEN** the hub is replaced by one of another version while the page is open @@ -43,7 +43,7 @@ When its WebSocket closes, the page SHALL show that it is reconnecting and recon - **THEN** this page shows the repo's tab with its worktrees ### Requirement: Repo tabs -The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. A repo whose watch fails SHALL show the error and its path in its tab's area. A tab of a host that is not `connected` SHALL show the host's status. +The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. The selected tab SHALL be shown in the accent colour. A repo whose watch fails SHALL show the error and its path in its tab's area. A tab of a host that is not `connected` SHALL show the host's status. #### Scenario: Duplicate names disambiguated - **WHEN** the repos are `/a/x/app` and `/b/y/app` @@ -58,12 +58,16 @@ The page SHALL show one tab per repo of each host, in the order the hub lists th - **THEN** the tab labelled `box:app` shows the status `reconnecting` ### Requirement: Worktree sidebar -The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or ` @ ` when detached, or the directory name when it has no head, with the path on hover. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. Worktree list changes SHALL show without user action. +The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or ` @ ` when detached, or the directory name when it has no head. On hover a worktree SHALL show its path followed by one line ` : ` per terminal, or "no terminals", where the status is `waiting for input` (`state` `input`), `running` (`state` `working`), `done` (`state` `idle` and `unseen`), `idle` (`state` `idle`), `exited`, `exited with code ` or `killed by `, the exit taking precedence. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. Worktree list changes SHALL show without user action. #### Scenario: Labels - **WHEN** worktrees are on branch `feature/x`, detached at `0123456789…` in directory `wt2`, and bare - **THEN** the sidebar shows `feature/x` and `wt2 @ 0123456`, and no bare entry +#### Scenario: Status on hover +- **WHEN** a worktree has a terminal `probe` 3 printing continuously and a terminal `probe` 4 that exited with code 1 +- **THEN** hovering it shows its path, `probe 3: running` and `probe 4: exited with code 1` + #### Scenario: Vanished worktree keeps its terminals - **WHEN** a worktree with a running terminal is removed with `git worktree remove --force` - **THEN** it stays listed as gone, its terminal remains usable, and it disappears after the terminal is closed @@ -73,12 +77,20 @@ The sidebar SHALL list the selected repo's worktrees in the daemon's order witho - **THEN** it shows as checked in another page connected to the same hub ### Requirement: Checked filter -The page SHALL offer a toggle between showing all worktrees and only checked ones, stored per browser across reloads and shared by all repos. With "checked only", the sidebar SHALL list the checked worktrees and the selected worktree. +The page SHALL offer, for the selected repo, a toggle between showing all worktrees and only checked ones; each repo SHALL keep its own setting, stored per browser across reloads, and a repo without one SHALL show all. With "checked only", the sidebar SHALL list the checked worktrees and the selected worktree. Selecting another repo SHALL show that repo's setting without animating the toggle; only the user's toggling SHALL animate it. #### Scenario: Filter persists - **WHEN** the filter is set to "checked only" and the page is reloaded - **THEN** the filter is still "checked only" and only checked worktrees are listed +#### Scenario: Filter belongs to its repo +- **WHEN** the filter is "checked only" in one repo and another repo is selected +- **THEN** the other repo lists all its worktrees, and turning its filter on and off leaves the first repo's "checked only" + +#### Scenario: Switching repos does not animate the toggle +- **WHEN** two repos have different filter settings and the user switches between them +- **THEN** the toggle shows each repo's setting without a transition + #### Scenario: Selected worktree stays listed - **WHEN** the selected worktree is unchecked while the filter is "checked only" - **THEN** it stays listed and selected @@ -91,7 +103,7 @@ The selected repo tab and each repo's selected worktree SHALL be stored per brow - **THEN** the second repo tab and that worktree are selected ### Requirement: Terminal tabs -The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. An exited terminal's tab and pane SHALL show that it exited and keep its screen readable until closed. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. +The terminal area SHALL show the selected worktree's layout tabs, each labelled by its first terminal, with the layout's active tab shown and its tab shown in the accent colour; live terminals of the worktree missing from its layout SHALL be shown as further single-pane tabs. Choosing a tab SHALL show it at once and then store it as active with `setLayout`. The tab bar SHALL offer controls to create a new tab, split the focused pane right and split it down, each naming its keyboard shortcut on hover and each starting a new terminal through the preset picker; the split controls SHALL be disabled when the focused pane's tab cannot be split further, the new-tab control when the layout holds 64 tabs, and all of them while the host is not `connected`, the worktree is prunable or gone, or no presets have been received. A new tab SHALL send `createTerm` with the chosen preset's name and command and the size of the terminal area, then add the terminal as a new active tab with `setLayout`. A terminal SHALL never be created again because a request failed or the session closed. An exited terminal's tab, and its pane header in a split, SHALL show that it exited and keep its screen readable until closed. A worktree without terminals SHALL show the presets as choices in the terminal area; no terminal SHALL be created without the user choosing one. #### Scenario: New terminal - **WHEN** the user picks a preset in a worktree without terminals @@ -139,7 +151,7 @@ On `detached{reason: lagging}` the page SHALL attach the terminal again immediat - **THEN** the terminal is attached again and shows the current screen ### Requirement: Worktree switching -Selecting a worktree SHALL show every pane of its active tab and hide the previous ones without waiting for any reply, fit each shown terminal to its pane, send `setVisible` with the host's shown terminals, and send `resize` for each shown terminal whose size changed. It SHALL send no request that has a `req`. Resizing the window SHALL fit and resize the shown terminals. +Selecting a worktree SHALL show every pane of its active tab and hide the previous ones without waiting for any reply, fit each shown terminal to its pane, send `setVisible` with the host's shown terminals, and, while the page has the focus, send `resize` for each shown terminal whose size differs from the last one the page sent. It SHALL send no request that has a `req`. Resizing the window SHALL fit the shown terminals and, while the page has the focus, resize them. #### Scenario: Switch sends no request - **WHEN** the user switches between two worktrees whose terminals were already viewed @@ -234,7 +246,7 @@ Splitting SHALL start a new terminal through the preset picker, send `createTerm - **THEN** both new terminals stay visible, each in the layout or as a further tab ### Requirement: Pane focus -Each tab SHALL have one focused pane, kept per page and not stored: the first pane until the user chooses another, the pane the user clicks, a new pane once created, and, when the focused pane's terminal closes, the first pane of the subtree that took its place. The focused pane SHALL be marked and SHALL hold the keyboard focus whenever its tab is shown and no picker or dialog is open. +Each tab SHALL have one focused pane, kept per page and not stored: the first pane until the user chooses another, the pane the user clicks, a new pane once created, and, when the focused pane's terminal closes, the first pane of the subtree that took its place. In a split, the focused pane's header SHALL be shown in the accent colour. The focused pane SHALL hold the keyboard focus whenever its tab is shown and no picker or dialog is open. #### Scenario: Focus follows a closed pane's sibling - **WHEN** the focused pane of a two-pane split is closed @@ -314,7 +326,7 @@ Holding a navigation shortcut SHALL repeat it; holding any other shortcut SHALL - **THEN** the selected text is typed into the second terminal ### Requirement: Attention indicators -Each pane and terminal tab SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (a non-zero code or a signal); exited; done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. +Each terminal tab, and each pane of a split, SHALL show one mark for its terminal, the first that applies of: needs input (`state` `input`); exited with failure (a non-zero code or a signal); exited; done (`unseen` with `state` `idle`); new output (`unseen` with `state` `working`); none otherwise. A tab holding several panes SHALL show the first-ranked mark of its terminals. Each worktree row and each repo tab SHALL show the first-ranked mark among the terminals of that worktree or of that repo's worktrees, counting an exited terminal only while it is `unseen`, with the number of terminals per mark on hover. Marks SHALL follow `activity`, `termExited` and terminal list changes without user action. #### Scenario: Agent waiting in a hidden worktree - **WHEN** a terminal in a non-selected worktree of a non-selected repo rings the bell @@ -379,3 +391,98 @@ Each repo tab SHALL offer a "Remove repo" action that asks for confirmation in t #### Scenario: Busy repo kept - **WHEN** the user removes a repo with a terminal and confirms - **THEN** the tab stays and the page says to close the repo's terminals first + +### Requirement: Pane headers +A pane SHALL have a header, naming its terminal's preset and id and holding its mark and a close control, only while its tab holds more than one pane; a lone pane's terminal SHALL fill the pane. + +#### Scenario: Lone pane without header +- **WHEN** a tab holds one pane +- **THEN** the pane shows no header and its terminal starts at the pane's top + +#### Scenario: Split panes with headers +- **WHEN** a single-pane tab is split right +- **THEN** both panes show a header with a close control, and closing one returns the other to a pane without header + +### Requirement: Sidebar width +The sidebar's right edge SHALL be a separator that the user can drag, and move by 16 px with the left and right arrow keys when focused, to set the sidebar's width between 160 and 640 px; the terminal area SHALL take the remaining width. The width SHALL be stored per browser when a drag ends or a key moves it and restored on reload; without a stored width, or with a malformed one, it SHALL be 260 px. + +#### Scenario: Drag within bounds +- **WHEN** the edge is dragged to 400 px, then to 20 px, then to 5000 px from the sidebar's left +- **THEN** the sidebar is 400 px wide, then 160 px, then 640 px + +#### Scenario: Width restored +- **WHEN** the width is set to 384 px and the page is reloaded +- **THEN** the sidebar is 384 px wide + +### Requirement: Editing presets +The preset picker and a worktree's preset choices SHALL offer "+ Add preset", opening a form for a name and a command; submitting SHALL trim both, treat an empty command as the login shell (`command` null) and send `addPreset`, closing the form on `done` and showing the refusal in the form otherwise. A blank name, a name of more than 64 characters, a name already taken, or a command of more than 4096 characters SHALL be refused in the form without sending anything; Escape SHALL close the form without sending anything and without closing the picker. While more than one preset exists, each SHALL offer a remove control sending `removePreset` for it; a refusal SHALL be shown on the page. While more than one preset exists, each SHALL be draggable to another position, the gap it would be dropped into marked while dragging, and dropping it elsewhere SHALL send `movePreset` with its new position; Alt+Up and Alt+Down SHALL move the highlighted preset one position, which stays highlighted and focused; until the new order arrives, or the move fails, further keyboard moves SHALL be ignored, so each moves the preset that is highlighted. Every list SHALL show the presets the hub last sent, and the picker SHALL stay open while presets are edited. + +#### Scenario: Preset added from the choices +- **WHEN** the user adds a preset `echo` with a command in a worktree's preset choices while another page is open +- **THEN** `addPreset` is sent once, both pages list `echo` last, `config.json` lists it, and choosing it runs its command + +#### Scenario: Preset added in the picker +- **WHEN** the user adds a preset in the new-tab picker +- **THEN** the picker stays open, lists the new preset, and choosing it opens a terminal running it + +#### Scenario: Preset removed +- **WHEN** the user removes one of two presets +- **THEN** `removePreset` is sent, every page lists only the other preset, `config.json` lists only it, and no remove control is offered any more + +#### Scenario: Taken name refused in the form +- **WHEN** the user submits a name that a preset already has, or a blank name +- **THEN** the form says why, nothing is sent, and Escape then closes the form + +#### Scenario: Preset dragged to the front +- **WHEN** the user drags the second of two presets onto the upper half of the first in a worktree's choices while another page is open +- **THEN** `movePreset` is sent with position 0, and both pages and `config.json` list the dragged preset first + +#### Scenario: Quick keyboard moves +- **WHEN** the first of three presets is highlighted and the user presses Alt+Down twice in quick succession +- **THEN** only that preset moves, by one or two positions + +#### Scenario: Preset moved with the keyboard +- **WHEN** the second preset is highlighted in the new-tab picker and the user presses Alt+Up and then Enter +- **THEN** it is listed first, still highlighted and focused, and a terminal running it opens + +### Requirement: Deleting worktrees +Right-clicking a worktree SHALL open a menu offering "Delete worktree", disabled with the reason on hover for the main worktree, a locked or gone worktree, or while its host is not `connected`; Escape or a press outside SHALL close the menu. Choosing it SHALL send `removeWorktree` with `force` false. On `done` nothing more SHALL be asked. On `worktreeAtRisk` the page SHALL show an in-page confirmation naming the worktree, listing every reason that applies — the commits not in the base ref, or that there is no `origin/main` to check against; the uncommitted changes, untracked files included; the running terminals that will be closed, by preset and id — and saying that its branch is kept or, for a detached worktree, that its commits not in the base ref become unreachable, naming its head commit; confirming SHALL send `removeWorktree` with `force` true, cancelling SHALL send nothing. A failure SHALL be shown on the page. + +#### Scenario: Clean worktree deleted without asking +- **WHEN** the user deletes a worktree whose branch is in `origin/main`, without changes or running terminals +- **THEN** it disappears from the sidebar, its directory is gone and no confirmation was shown + +#### Scenario: Every reason listed +- **WHEN** the user deletes a worktree with one commit not in `origin/main`, one untracked file and a running terminal `probe` 5 +- **THEN** the confirmation lists "It has 1 commit not in origin/main.", "It has 1 uncommitted change, untracked files included." and "Running terminals will be closed: probe 5." + +#### Scenario: Detached worktree +- **WHEN** the user deletes a detached worktree with one commit not in `origin/main` +- **THEN** the confirmation lists that commit, says its commits become unreachable and names its head + +#### Scenario: Cancel keeps the worktree +- **WHEN** the user cancels that confirmation +- **THEN** nothing is sent and the worktree and its terminal remain + +#### Scenario: Confirm deletes it +- **WHEN** the user confirms it +- **THEN** the worktree disappears, its terminal is closed and its branch remains + +#### Scenario: Main worktree +- **WHEN** the user right-clicks the main worktree +- **THEN** "Delete worktree" is disabled and says the main worktree cannot be deleted + +### Requirement: Terminal size with several pages +Only a page that has the focus SHALL send `resize`. A page that gains the focus SHALL send `resize` with the size of every terminal it shows, and so SHALL a page for the shown terminals of a host whose link connects again; afterwards it SHALL send a terminal's size only when it differs from the last one it sent. A page without the focus SHALL fit its terminals to its own panes without sending `resize`. + +#### Scenario: Background page leaves the size alone +- **WHEN** a second page opens in the background with a smaller window and shows a terminal of the focused page +- **THEN** the PTY keeps the focused page's size + +#### Scenario: Focus takes the size over +- **WHEN** the second page gains the focus +- **THEN** the PTY takes the second page's size, and resizing the first page's window while it is in the background does not change it + +#### Scenario: Refocus takes it back +- **WHEN** the first page gains the focus again +- **THEN** the PTY takes the first page's current size diff --git a/package.json b/package.json index fe7c4e1..38e46b5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "wtd": "dist/wtd.mjs" }, "scripts": { + "dev": "vite", "build": "vite build && esbuild src/cli/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/wtd.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --external:@homebridge/node-pty-prebuilt-multiarch", "typecheck": "tsc -b", "lint": "tsc -b && eslint . --max-warnings 0", diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 2999088..b933828 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -45,8 +45,12 @@ const expectUsageError = async (result: Result, mentions: RegExp): Promise }; describe('wtd --version', () => { - it('prints the package and protocol versions', async () => { - expect(await wtd('--version')).toEqual({ code: 0, stdout: `wtd ${packageVersion()} (protocol 5)\n`, stderr: '' }); + it('prints the package and both protocol versions', async () => { + expect(await wtd('--version')).toEqual({ + code: 0, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`, + stderr: '', + }); }); }); diff --git a/src/cli/run.ts b/src/cli/run.ts index 6d696e6..dfcf609 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -8,7 +8,7 @@ import { HubAlreadyRunningError, openUi, runHub } from '../hub/main/index.js'; import { dial } from '../platform/dialer/index.js'; import { currentHostPaths } from '../platform/files/index.js'; import { installLocal, installRemote } from '../platform/install/index.js'; -import { PROTOCOL_VERSION } from '../protocol/index.js'; +import { BROWSER_PROTOCOL_VERSION, DAEMON_PROTOCOL_VERSION } from '../protocol/index.js'; export interface CliIo { stdout: (text: string) => void; @@ -219,7 +219,9 @@ const dispatch = async (argv: readonly string[], io: CliIo): Promise => return 0; } if (version) { - io.stdout(`wtd ${pkg.version} (protocol ${String(PROTOCOL_VERSION)})\n`); + io.stdout( + `wtd ${pkg.version} (daemon protocol ${String(DAEMON_PROTOCOL_VERSION)}, browser protocol ${String(BROWSER_PROTOCOL_VERSION)})\n`, + ); return 0; } const [verb, ...args] = positionals; diff --git a/src/daemon/main/index.ts b/src/daemon/main/index.ts index ffe1154..9db8e89 100644 --- a/src/daemon/main/index.ts +++ b/src/daemon/main/index.ts @@ -3,7 +3,7 @@ import type { HostPaths } from '../../platform/files/index.js'; import { bindSocket, Daemon } from '../server/index.js'; import { StateStore } from '../state/index.js'; import { TerminalProcess } from '../terminals/index.js'; -import { discoverRepos, listWorktrees, watchWorktrees } from '../worktrees/index.js'; +import { discoverRepos, listWorktrees, removeWorktree, watchWorktrees, worktreeRisks } from '../worktrees/index.js'; export { AlreadyRunningError } from '../server/index.js'; @@ -39,7 +39,14 @@ export const runDaemon = async ({ version, paths, env }: DaemonRun): Promise TerminalProcess.spawn(spec, events) }, + services: { + watchWorktrees, + listWorktrees, + discoverRepos, + worktreeRisks, + removeWorktree, + spawnTerminal: (spec, events) => TerminalProcess.spawn(spec, events), + }, version, instance: randomBytes(12).toString('base64url'), store, diff --git a/src/daemon/server/connection.test.ts b/src/daemon/server/connection.test.ts index 4e0821f..9029860 100644 --- a/src/daemon/server/connection.test.ts +++ b/src/daemon/server/connection.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { encodeStreamData, FrameKind, PROTOCOL_VERSION, StreamDecoder, type Frame } from '../../protocol/index.js'; +import { encodeStreamData, FrameKind, DAEMON_PROTOCOL_VERSION, StreamDecoder, type Frame } from '../../protocol/index.js'; import { ConnectionGate } from './connection.js'; const text = (value: string): Uint8Array => new TextEncoder().encode(value); @@ -34,7 +34,7 @@ beforeEach(() => { describe('before hello', () => { it('accepts a hello of this protocol version', () => { - expect(gate.receive(hello(PROTOCOL_VERSION))).toEqual({ kind: 'hello' }); + expect(gate.receive(hello(DAEMON_PROTOCOL_VERSION))).toEqual({ kind: 'hello' }); expect(gate.state).toBe('ready'); }); @@ -55,7 +55,7 @@ describe('before hello', () => { describe('after a matching hello', () => { beforeEach(() => { - gate.receive(hello(PROTOCOL_VERSION)); + gate.receive(hello(DAEMON_PROTOCOL_VERSION)); }); it('delivers requests decoded', () => { @@ -71,7 +71,7 @@ describe('after a matching hello', () => { }); it.each([ - ['a second hello', hello(PROTOCOL_VERSION)], + ['a second hello', hello(DAEMON_PROTOCOL_VERSION)], ['a control message with an extra field', extraField], ['invalid JSON', badJson], ['a daemon-to-client message', control({ t: 'done', req: 1 })], @@ -88,7 +88,7 @@ describe('after a matching hello', () => { }); describe('after a mismatched hello', () => { - it.each([1, PROTOCOL_VERSION + 5])('enters mismatched mode for protocol %i', (protocol) => { + it.each([1, DAEMON_PROTOCOL_VERSION + 5])('enters mismatched mode for protocol %i', (protocol) => { expect(gate.receive(hello(protocol))).toEqual({ kind: 'hello' }); expect(gate.state).toBe('mismatched'); }); @@ -105,7 +105,7 @@ describe('after a mismatched hello', () => { it.each([ ['a request', watch], ['a second hello', hello(1)], - ['a matching hello', hello(PROTOCOL_VERSION)], + ['a matching hello', hello(DAEMON_PROTOCOL_VERSION)], ['invalid JSON', badJson], ['a control message with an extra field', extraField], ['an input frame', input], diff --git a/src/daemon/server/connection.ts b/src/daemon/server/connection.ts index d533ea4..a0261a0 100644 --- a/src/daemon/server/connection.ts +++ b/src/daemon/server/connection.ts @@ -2,7 +2,7 @@ import { decodeMessage, decodeStreamData, FrameKind, - PROTOCOL_VERSION, + DAEMON_PROTOCOL_VERSION, ProtocolError, type DataFrame, type Frame, @@ -65,7 +65,7 @@ export class ConnectionGate { private awaitingHello(frame: Frame): GateOutcome { const message = frame.kind === FrameKind.control ? decodeControl(frame) : null; if (message?.t !== 'hello') return BAD_MESSAGE; - this.current = message.protocol === PROTOCOL_VERSION ? 'ready' : 'mismatched'; + this.current = message.protocol === DAEMON_PROTOCOL_VERSION ? 'ready' : 'mismatched'; return { kind: 'hello' }; } diff --git a/src/daemon/server/daemon.test.ts b/src/daemon/server/daemon.test.ts index 6121540..3a1910e 100644 --- a/src/daemon/server/daemon.test.ts +++ b/src/daemon/server/daemon.test.ts @@ -8,14 +8,17 @@ import { sleep } from '../../../test/support/daemon-host.js'; import type { Worktree } from '../../protocol/index.js'; import { StateStore } from '../state/index.js'; import { SpawnError, TerminalProcess, type TerminalEvents, type TerminalSpec } from '../terminals/index.js'; -import type { WorktreeHandlers, WorktreeWatch } from '../worktrees/index.js'; +import type { WorktreeHandlers, WorktreeRisks, WorktreeWatch } from '../worktrees/index.js'; import { Daemon, type DaemonServices } from './daemon.js'; const REPO = '/r'; const MAX_TERMINALS = 1024; +const WT = '/r.worktrees/feat'; const worktrees: Worktree[] = [ { path: REPO, head: null, branch: 'main', detached: false, locked: false, prunable: false, bare: false, main: true }, + { path: WT, head: null, branch: 'feat', detached: false, locked: false, prunable: false, bare: false, main: false }, ]; +const NO_RISK: WorktreeRisks = { base: 'origin/main', ahead: 0, changes: 0 }; interface PendingSpawn { spec: TerminalSpec; @@ -35,6 +38,9 @@ let spawns: PendingSpawn[]; /** Watch starts held back until released, when set. */ let heldWatches: (() => void)[] | null; let watchHandlers: WorktreeHandlers[]; +/** What the risk check answers; a held promise keeps a removal in progress. */ +let risks: Promise; +let removed: string[]; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'wtd-daemon-')); @@ -43,6 +49,8 @@ beforeEach(async () => { spawns = []; heldWatches = null; watchHandlers = []; + risks = Promise.resolve(NO_RISK); + removed = []; const services: DaemonServices = { watchWorktrees: async (_repo, handlers) => { watchHandlers.push(handlers); @@ -55,6 +63,11 @@ beforeEach(async () => { }, listWorktrees: () => Promise.resolve(worktrees), discoverRepos: () => Promise.resolve([]), + worktreeRisks: () => risks, + removeWorktree: (_repo, worktree) => { + removed.push(worktree.path); + return Promise.resolve(); + }, spawnTerminal: (spec, events) => new Promise((resolve, reject) => { spawns.push({ spec, events, resolve, reject }); @@ -165,3 +178,40 @@ it('prunes state of vanished worktrees before the first repoState', async () => const client = await connected(); expect((await client.watch(REPO)).checked).toEqual([REPO]); }); + +it('waits for a terminal still starting in a worktree being removed, and reports it as running', async () => { + const client = await connected(); + await client.watch(REPO); + client.send({ t: 'createTerm', req: 1, worktree: WT, preset: 'shell', command: null, cols: 80, rows: 24 }); + await vi.waitFor(() => { + expect(spawns).toHaveLength(1); + }); + const from = client.mark(); + client.send({ t: 'removeWorktree', req: 2, worktree: WT, force: false }); + await client.expectNone('worktreeAtRisk', () => true, 200); + const pending = spawns[0]; + if (pending === undefined) throw new Error('no spawn'); + const process = await TerminalProcess.spawn({ ...pending.spec, cwd: dir, command: 'exec sleep 60' }, pending.events); + pending.resolve(process); + const created = await client.waitFor('termCreated', (m) => m.req === 1, { from }); + expect(await client.waitFor('worktreeAtRisk', (m) => m.req === 2, { from })).toMatchObject({ running: [created.term.termId] }); + expect(removed).toEqual([]); + await process.close(); +}); + +it('refuses to start a terminal in a worktree while it is being removed', async () => { + const client = await connected(); + await client.watch(REPO); + let release: (value: WorktreeRisks) => void = () => undefined; + risks = new Promise((resolve) => { + release = resolve; + }); + const from = client.mark(); + client.send({ t: 'removeWorktree', req: 1, worktree: WT, force: false }); + await sleep(50); + expect(await client.fails({ t: 'createTerm', worktree: WT, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('busy'); + expect(spawns).toHaveLength(0); + release(NO_RISK); + await client.waitFor('done', (m) => m.req === 1, { from }); + expect(removed).toEqual([WT]); +}); diff --git a/src/daemon/server/daemon.ts b/src/daemon/server/daemon.ts index c66dee1..945f702 100644 --- a/src/daemon/server/daemon.ts +++ b/src/daemon/server/daemon.ts @@ -1,8 +1,16 @@ import type { Socket } from 'node:net'; -import { FLOW_HIGH, PROTOCOL_VERSION, type Layout, type Terminal, type Worktree } from '../../protocol/index.js'; +import { FLOW_HIGH, DAEMON_PROTOCOL_VERSION, type Layout, type Terminal, type Worktree } from '../../protocol/index.js'; import { pruneLayout, type StateStore } from '../state/index.js'; import { SpawnError, type OutputSink, type TerminalEvents, type TerminalProcess, type TerminalSpec } from '../terminals/index.js'; -import { NotARepoError, type discoverRepos, type listWorktrees, type watchWorktrees, type WorktreeWatch } from '../worktrees/index.js'; +import { + NotARepoError, + type discoverRepos, + type listWorktrees, + type removeWorktree, + type watchWorktrees, + type worktreeRisks, + type WorktreeWatch, +} from '../worktrees/index.js'; import type { GateOutcome, Request } from './connection.js'; import { Peer, type Event } from './peer.js'; @@ -42,6 +50,8 @@ export interface DaemonServices { watchWorktrees: typeof watchWorktrees; listWorktrees: typeof listWorktrees; discoverRepos: typeof discoverRepos; + worktreeRisks: typeof worktreeRisks; + removeWorktree: typeof removeWorktree; spawnTerminal: (spec: TerminalSpec, events: TerminalEvents) => Promise; } @@ -69,6 +79,10 @@ export class Daemon { /** Terminals being started per repo; they count against MAX_TERMINALS. */ private readonly spawning = new Map(); private readonly spawns = new Set>(); + /** Terminal starts in flight per worktree, which a removal of the worktree waits for. */ + private readonly spawnsIn = new Map>>(); + /** Worktrees being removed; no terminal starts in them meanwhile. */ + private readonly removing = new Set(); /** Terminals closed but not yet reaped. */ private readonly reaping = new Set>(); private stopping = false; @@ -87,7 +101,7 @@ export class Daemon { }, }); this.peers.add(peer); - peer.send({ t: 'hello', protocol: PROTOCOL_VERSION, version: this.options.version, instance: this.options.instance }); + peer.send({ t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: this.options.version, instance: this.options.instance }); } /** @@ -148,6 +162,7 @@ export class Daemon { case 'closeTerm': case 'setChecked': case 'setLayout': + case 'removeWorktree': void this.correlated(peer, message); } } @@ -206,6 +221,9 @@ export class Daemon { case 'setLayout': await this.setLayout(peer, m.worktree, m.layout); break; + case 'removeWorktree': + if (!(await this.removeWorktree(peer, m))) return; + break; } peer.send({ t: 'done', req: m.req }); } @@ -337,6 +355,7 @@ export class Daemon { private async createTerm(peer: Peer, m: Extract): Promise { const repo = this.repoOfWorktree(peer, m.worktree); + if (this.removing.has(m.worktree)) throw new RequestError('busy', `${m.worktree} is being deleted`); if (this.terminalsOf(repo).length + (this.spawning.get(repo) ?? 0) >= MAX_TERMINALS) { throw new RequestError('busy', `${repo} has ${String(MAX_TERMINALS)} terminals`); } @@ -354,6 +373,8 @@ export class Daemon { ); increment(this.spawning, repo); this.spawns.add(spawn); + const inWorktree = this.spawnsIn.get(m.worktree) ?? new Set(); + this.spawnsIn.set(m.worktree, inWorktree.add(spawn)); let spawned: TerminalProcess; try { spawned = await spawn; @@ -363,6 +384,8 @@ export class Daemon { } finally { decrement(this.spawning, repo); this.spawns.delete(spawn); + inWorktree.delete(spawn); + if (inWorktree.size === 0) this.spawnsIn.delete(m.worktree); } termId = this.nextTermId++; const term: TermEntry = { termId, repo, worktree: m.worktree, preset: m.preset, process: spawned }; @@ -427,6 +450,47 @@ export class Daemon { if (failure !== null) throw new RequestError('internal', `terminal closed, but its layout could not be saved: ${failure.message}`); } + /** + * Removes a worktree and closes its terminals; without `force`, first answers `worktreeAtRisk` and keeps it + * when that would lose commits not in the base ref, uncommitted changes or running terminals. True when removed. + */ + private async removeWorktree(peer: Peer, m: Extract): Promise { + if (this.removing.has(m.worktree)) throw new RequestError('busy', `${m.worktree} is being deleted`); + this.removing.add(m.worktree); + try { + // Terminals still starting there count as running, and are closed with the rest. + await Promise.allSettled([...(this.spawnsIn.get(m.worktree) ?? [])]); // NOSONAR(S7747) — await-thenable only recognises promise arrays + return await this.removeWorktreeNow(peer, m); + } finally { + this.removing.delete(m.worktree); + } + } + + private async removeWorktreeNow(peer: Peer, m: Extract): Promise { + const repo = this.repoOfWorktree(peer, m.worktree); + const worktree = this.repos.get(repo)?.watch.worktrees.find((w) => w.path === m.worktree); + if (worktree === undefined) throw new RequestError('unknown-worktree', `${m.worktree} is not a worktree of a watched repo`); + if (worktree.main) throw new RequestError('busy', 'the main worktree cannot be deleted'); + if (worktree.locked) throw new RequestError('busy', `${m.worktree} is locked`); + if (!m.force) { + const risks = await this.options.services.worktreeRisks(repo, worktree); + const running = this.terminalsOf(repo) + .filter((t) => t.worktree === m.worktree && t.process.exit === null) + .map((t) => t.termId); + if (risks.ahead !== 0 || risks.changes > 0 || running.length > 0) { + peer.send({ t: 'worktreeAtRisk', req: m.req, worktree: m.worktree, ...risks, running }); + return false; + } + } + await this.options.services.removeWorktree(repo, worktree, m.force); + for (const term of this.terminalsOf(repo).filter((t) => t.worktree === m.worktree)) { + this.closeTerminal(term); + this.broadcast(repo, { t: 'termClosed', termId: term.termId }); + } + void this.prune(repo); + return true; + } + /** Removes `term` and ends its process. */ private closeTerminal(term: TermEntry): void { this.terminals.delete(term.termId); diff --git a/src/daemon/worktrees/git.test.ts b/src/daemon/worktrees/git.test.ts new file mode 100644 index 0000000..1a47f2e --- /dev/null +++ b/src/daemon/worktrees/git.test.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { addWorktree, git, makeRepo } from '../../../test/support/daemon-host.js'; +import type { Worktree } from '../../protocol/index.js'; +import { listWorktrees, removeWorktree, worktreeRisks } from './index.js'; + +let root: string; +let repo: string; + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-git-'))); + repo = makeRepo(join(root, 'repo')); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** Gives `repo` a bare `origin` holding its `main`, with `origin/HEAD` pointing at it. */ +const withOrigin = (): void => { + const origin = join(root, 'origin.git'); + git(root, 'init', '-q', '--bare', '-b', 'main', origin); + git(repo, 'remote', 'add', 'origin', origin); + git(repo, 'push', '-q', 'origin', 'main'); + git(repo, 'remote', 'set-head', 'origin', 'main'); +}; + +const commit = (cwd: string, file: string): void => { + writeFileSync(join(cwd, file), file); + git(cwd, 'add', file); + git(cwd, 'commit', '-q', '-m', file); +}; + +/** The listed worktree at `path`. */ +const listed = async (path: string): Promise => { + const found = (await listWorktrees(repo)).find((w) => w.path === path); + if (found === undefined) throw new Error(`${path} is not listed`); + return found; +}; + +describe('worktreeRisks', () => { + it('reports no base and unknown commits without an origin', async () => { + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: null, ahead: null, changes: 0 }); + }); + + it('counts the commits not in origin/main, which origin/HEAD names', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: 'origin/main', ahead: 0, changes: 0 }); + commit(wt, 'a'); + commit(wt, 'b'); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: 'origin/main', ahead: 2, changes: 0 }); + }); + + it('falls back to origin/main without origin/HEAD', async () => { + withOrigin(); + git(repo, 'remote', 'set-head', 'origin', '--delete'); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'a'); + expect((await worktreeRisks(repo, await listed(wt))).base).toBe('origin/main'); + expect((await worktreeRisks(repo, await listed(wt))).ahead).toBe(1); + }); + + it('falls back to origin/main when origin/HEAD names a missing ref', async () => { + withOrigin(); + git(repo, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/gone'); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'a'); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: 'origin/main', ahead: 1, changes: 0 }); + }); + + it('finds nothing ahead once the branch is merged into origin/main', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'a'); + git(wt, 'push', '-q', 'origin', 'feat:main'); + expect((await worktreeRisks(repo, await listed(wt))).ahead).toBe(0); + }); + + it('fetches origin/main first, so a branch merged on the remote since the last fetch counts as merged', async () => { + withOrigin(); + const stale = git(repo, 'rev-parse', 'refs/remotes/origin/main'); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'a'); + git(wt, 'push', '-q', 'origin', 'feat:main'); + // As if the merge happened elsewhere: the local origin/main has not seen it. + git(repo, 'update-ref', 'refs/remotes/origin/main', stale); + expect((await worktreeRisks(repo, await listed(wt))).ahead).toBe(0); + expect(git(repo, 'rev-parse', 'refs/remotes/origin/main')).toBe(git(wt, 'rev-parse', 'HEAD')); + }); + + it('compares with the local origin/main when the remote cannot be reached', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'a'); + rmSync(join(root, 'origin.git'), { recursive: true }); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: 'origin/main', ahead: 1, changes: 0 }); + }); + + it('counts modified and untracked files but not ignored ones', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + commit(wt, 'tracked'); + writeFileSync(join(wt, '.gitignore'), 'ignored\n'); + git(wt, 'add', '.gitignore'); + git(wt, 'commit', '-q', '-m', 'ignore'); + writeFileSync(join(wt, 'tracked'), 'changed'); + writeFileSync(join(wt, 'untracked'), 'new'); + writeFileSync(join(wt, 'ignored'), 'skip'); + expect((await worktreeRisks(repo, await listed(wt))).changes).toBe(2); + }); + + it('counts commits made after the worktree was listed', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + const cached = await listed(wt); + commit(wt, 'a'); + expect((await worktreeRisks(repo, cached)).ahead).toBe(1); + }); + + it('reports no changes for a worktree whose directory is gone', async () => { + withOrigin(); + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + rmSync(wt, { recursive: true }); + expect(await worktreeRisks(repo, await listed(wt))).toEqual({ base: 'origin/main', ahead: 0, changes: 0 }); + }); +}); + +describe('removeWorktree', () => { + it('removes a clean worktree and keeps its branch', async () => { + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + await removeWorktree(repo, await listed(wt), false); + expect(existsSync(wt)).toBe(false); + expect((await listWorktrees(repo)).map((w) => w.path)).toEqual([repo]); + expect(git(repo, 'branch', '--list', 'feat')).toContain('feat'); + }); + + it('refuses a worktree with changes unless forced', async () => { + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + writeFileSync(join(wt, 'untracked'), 'new'); + const changed = await listed(wt); + await expect(removeWorktree(repo, changed, false)).rejects.toThrow(); + expect(existsSync(wt)).toBe(true); + await removeWorktree(repo, await listed(wt), true); + expect(existsSync(wt)).toBe(false); + }); + + it('removes a worktree whose directory is gone, leaving other missing worktrees registered', async () => { + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + const other = addWorktree(repo, join(root, 'other'), 'other'); + rmSync(wt, { recursive: true }); + rmSync(other, { recursive: true }); + const gone = await listed(wt); + expect(gone.prunable).toBe(true); + await removeWorktree(repo, gone, false); + expect((await listWorktrees(repo)).map((w) => w.path)).toEqual([repo, other]); + }); + + it('removes a worktree whose directory went after it was listed', async () => { + const wt = addWorktree(repo, join(root, 'wt'), 'feat'); + const cached = await listed(wt); + rmSync(wt, { recursive: true }); + expect(cached.prunable).toBe(false); + await removeWorktree(repo, cached, false); + expect((await listWorktrees(repo)).map((w) => w.path)).toEqual([repo]); + }); +}); diff --git a/src/daemon/worktrees/git.ts b/src/daemon/worktrees/git.ts index a60b868..1070ce6 100644 --- a/src/daemon/worktrees/git.ts +++ b/src/daemon/worktrees/git.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { realpath } from 'node:fs/promises'; +import { realpath, stat } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { promisify } from 'node:util'; import type { Worktree } from '../../protocol/index.js'; @@ -58,3 +58,89 @@ export const checkRepo = async (repo: string): Promise => { if (main?.path !== repo) throw new NotARepoError(`${repo} is not a repository's main worktree path`); return common; }; + +/** Remote-tracking refs a worktree's head is compared with, in order of preference after `origin/HEAD`. */ +const BASE_CANDIDATES = ['refs/remotes/origin/main', 'refs/remotes/origin/master']; +const REMOTES_PREFIX = 'refs/remotes/'; + +/** What deleting a worktree would lose, apart from its terminals. */ +export interface WorktreeRisks { + /** The ref compared with, e.g. `origin/main`; null when the repo has none. */ + base: string | null; + /** Commits of the worktree's head not in `base`; null without a base. */ + ahead: number | null; + /** Uncommitted changes, untracked files included; 0 when its directory is gone. */ + changes: number; +} + +/** The output of a `--quiet` git query, or null when it exits with 1 for a missing ref. */ +const gitOrNull = async (cwd: string, args: readonly string[]): Promise => { + try { + return (await git(cwd, args)).trim(); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 1) return null; + throw error; + } +}; + +/** The full name of the existing remote-tracking ref heads are compared with: `origin/HEAD`'s target, else origin's main or master. */ +const baseRef = async (repo: string): Promise => { + const head = await gitOrNull(repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']); + for (const candidate of head === null || head === '' ? BASE_CANDIDATES : [head, ...BASE_CANDIDATES]) { + if ((await gitOrNull(repo, ['rev-parse', '--verify', '--quiet', candidate])) !== null) return candidate; + } + return null; +}; + +/** How long fetching the base ref may take. */ +const FETCH_MS = 20_000; + +/** + * Fetches the remote branch behind the remote-tracking `ref`, so a branch merged since the last fetch counts + * as merged; false when that failed (offline, no access), leaving the local ref, which can only err towards asking. + */ +const fetchBase = async (repo: string, ref: string): Promise => { + const [remote, ...branch] = ref.slice(REMOTES_PREFIX.length).split('/'); + if (remote === undefined || branch.length === 0) return false; + try { + await run('git', ['fetch', '--quiet', '--no-tags', '--', remote, branch.join('/')], { + cwd: repo, + timeout: FETCH_MS, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', SSH_ASKPASS_REQUIRE: 'never' }, + }); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error) return false; + throw error; + } +}; + +const isDirectory = async (path: string): Promise => { + try { + return (await stat(path)).isDirectory(); + } catch (error) { + if (error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return false; + throw error; + } +}; + +/** What deleting `worktree` of `repo` would lose: commits not in the base ref and uncommitted changes. */ +export const worktreeRisks = async (repo: string, worktree: Worktree): Promise => { + // `worktree` may predate its latest commit. + const head = (await listWorktrees(repo)).find((w) => w.path === worktree.path)?.head ?? worktree.head; + const ref = await baseRef(repo); + if (ref !== null) await fetchBase(repo, ref); + let base: string | null = null; + let ahead: number | null = null; + if (ref !== null) { + base = ref.startsWith(REMOTES_PREFIX) ? ref.slice(REMOTES_PREFIX.length) : ref; + ahead = head === null ? 0 : Number(await git(repo, ['rev-list', '--count', `${ref}..${head}`])); + } + const status = (await isDirectory(worktree.path)) ? await git(worktree.path, ['status', '--porcelain', '--untracked-files=normal']) : ''; + return { base, ahead, changes: status.split('\n').filter((line) => line !== '').length }; +}; + +/** Removes `worktree` of `repo`, also when its directory is gone, `force` also discarding its changes. */ +export const removeWorktree = async (repo: string, worktree: Worktree, force: boolean): Promise => { + await git(repo, ['worktree', 'remove', ...(force ? ['--force'] : []), worktree.path]); +}; diff --git a/src/daemon/worktrees/index.ts b/src/daemon/worktrees/index.ts index 99fd00b..aca0307 100644 --- a/src/daemon/worktrees/index.ts +++ b/src/daemon/worktrees/index.ts @@ -1,3 +1,3 @@ export { discoverRepos } from './discover.js'; -export { listWorktrees, MAX_WORKTREES, NotARepoError } from './git.js'; +export { listWorktrees, MAX_WORKTREES, NotARepoError, removeWorktree, worktreeRisks, type WorktreeRisks } from './git.js'; export { watchWorktrees, type WorktreeHandlers, type WorktreeWatch } from './watch.js'; diff --git a/src/hub/config/config.test.ts b/src/hub/config/config.test.ts index 8370643..dae6785 100644 --- a/src/hub/config/config.test.ts +++ b/src/hub/config/config.test.ts @@ -33,18 +33,20 @@ const paths = (n: number): string[] => Array.from({ length: n }, (_, i) => `/r/$ const SHELL = [{ name: 'shell', command: null }]; const CLAUDE = { name: 'claude', command: 'claude' }; +/** The presets of a configuration without `presets`. */ +const DEFAULT_PRESETS = [...SHELL, CLAUDE, { name: 'codex', command: 'codex' }]; const presets = (n: number): { name: string; command: string | null }[] => Array.from({ length: n }, (_, i) => ({ name: `p${String(i)}`, command: null })); describe('parseConfig', () => { - it('uses port 7417, no repos and the shell preset for a missing file', () => { + it('uses port 7417, no repos and the shell, claude and codex presets for a missing file', () => { expect(DEFAULT_PORT).toBe(7417); - expect(parseConfig(null, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); + expect(parseConfig(null, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }); }); it('uses the defaults for an empty object', () => { - expect(parse({})).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); + expect(parse({})).toEqual({ port: 7417, repos: [], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }); }); it.each([1024, 8080, 65535])('accepts port %d', (port) => { @@ -277,7 +279,7 @@ describe('parseConfig', () => { describe('loadConfig', () => { it('uses the defaults for a missing file without creating it or its directory', async () => { const path = join(dir, 'worktree-term', 'config.json'); - expect(await loadConfig(path, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); + expect(await loadConfig(path, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }); expect(existsSync(join(dir, 'worktree-term'))).toBe(false); }); @@ -327,7 +329,7 @@ describe('ConfigSource', () => { const source = new ConfigSource(path, HOME, await loadConfig(path, HOME)); write(path, { repos: ['/a', '/b'] }); expect(await source.snapshot()).toEqual({ - config: { port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: SHELL, hosts: [] }, + config: { port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }, problem: null, }); }); @@ -340,13 +342,13 @@ describe('ConfigSource', () => { await source.snapshot(); write(path, { repos: ['relative'] }); const snapshot = await source.snapshot(); - expect(snapshot.config).toEqual({ port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: SHELL, hosts: [] }); + expect(snapshot.config).toEqual({ port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }); expect(snapshot.problem).toContain('repos'); write(path, '{'); expect((await source.snapshot()).config.repos).toEqual(['/a', '/b']); write(path, { repos: ['/c'] }); expect(await source.snapshot()).toEqual({ - config: { port: 7417, repos: ['/c'], roots: ['~'], presets: SHELL, hosts: [] }, + config: { port: 7417, repos: ['/c'], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }, problem: null, }); }); @@ -371,7 +373,10 @@ describe('ConfigSource', () => { write(path, { port: 9000, repos: ['/a'], roots: ['~'], presets: [CLAUDE], hosts: [] }); const source = new ConfigSource(path, HOME, await loadConfig(path, HOME)); rmSync(path); - expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }, problem: null }); + expect(await source.snapshot()).toEqual({ + config: { port: 7417, repos: [], roots: ['~'], presets: DEFAULT_PRESETS, hosts: [] }, + problem: null, + }); }); }); diff --git a/src/hub/config/config.ts b/src/hub/config/config.ts index 8b375f5..0264488 100644 --- a/src/hub/config/config.ts +++ b/src/hub/config/config.ts @@ -7,10 +7,14 @@ export const DEFAULT_PORT = 7417; export const MAX_REPOS = 256; const MAX_PATH = 4096; -const MAX_PRESETS = 64; +export const MAX_PRESETS = 64; const MAX_ROOTS = 32; const MAX_REMOTE_HOSTS = 63; -const DEFAULT_PRESETS: readonly Preset[] = [{ name: 'shell', command: null }]; +export const DEFAULT_PRESETS: readonly Preset[] = [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + { name: 'codex', command: 'codex' }, +]; const DEFAULT_ROOTS: readonly string[] = ['~']; /** A configured remote host. */ diff --git a/src/hub/config/editor.test.ts b/src/hub/config/editor.test.ts index 3303454..a6b2ee0 100644 --- a/src/hub/config/editor.test.ts +++ b/src/hub/config/editor.test.ts @@ -145,6 +145,101 @@ describe('ConfigEditor removing repos', () => { }); }); +const SHELL = { name: 'shell', command: null }; +const CLAUDE = { name: 'claude', command: 'claude' }; +const CODEX = { name: 'codex', command: 'codex' }; +const presetList = (n: number): { name: string; command: string | null }[] => + Array.from({ length: n }, (_, i) => ({ name: `p${String(i)}`, command: null })); + +describe('ConfigEditor editing presets', () => { + it('appends to the defaults when the file has no presets, creating it owner-only', async () => { + const added = { name: 'htop', command: 'htop' }; + expect(await editor().addPreset(added)).toEqual({ changed: true, presets: [SHELL, CLAUDE, CODEX, added] }); + expect(read()).toEqual({ presets: [SHELL, CLAUDE, CODEX, added] }); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('appends to the listed presets, keeping every other value as written', async () => { + write(OTHERS); + const added = { name: 'plain', command: null }; + expect(await editor().addPreset(added)).toEqual({ changed: true, presets: [CLAUDE, added] }); + const expected = { ...OTHERS, presets: [CLAUDE, added] }; + expect(read()).toEqual(expected); + expect(keysOf(read())).toEqual(Object.keys(expected)); + }); + + it('refuses a name that is taken, leaving the file unchanged', async () => { + write({ presets: [CLAUDE] }); + const before = text(); + const edit = editor(); + await expect(edit.addPreset({ name: 'claude', command: 'other' })).rejects.toThrow('a preset named claude exists'); + expect(text()).toBe(before); + }); + + it('refuses a 65th preset', async () => { + write({ presets: presetList(64) }); + const edit = editor(); + await expect(edit.addPreset({ name: 'one more', command: null })).rejects.toBeInstanceOf(ConfigError); + expect(z.object({ presets: z.array(z.unknown()) }).parse(read()).presets).toHaveLength(64); + }); + + it('removes a preset by name, from the defaults when the file has none', async () => { + write({ repos: ['/r/a'] }); + expect(await editor().removePreset('claude')).toEqual({ changed: true, presets: [SHELL, CODEX] }); + expect(read()).toEqual({ repos: ['/r/a'], presets: [SHELL, CODEX] }); + }); + + it('leaves the file unchanged for a name it does not list', async () => { + write({ presets: [CLAUDE, SHELL] }); + const before = text(); + expect(await editor().removePreset('nope')).toEqual({ changed: false, presets: [CLAUDE, SHELL] }); + expect(text()).toBe(before); + }); + + it('creates no file when removing an unknown name from an absent one', async () => { + expect(await editor().removePreset('nope')).toEqual({ changed: false, presets: [SHELL, CLAUDE, CODEX] }); + expect(existsSync(path)).toBe(false); + }); + + it('refuses to remove the last preset', async () => { + write({ presets: [CLAUDE] }); + const edit = editor(); + await expect(edit.removePreset('claude')).rejects.toThrow('the last preset cannot be removed'); + expect(read()).toEqual({ presets: [CLAUDE] }); + }); + + it('refuses to edit an invalid file', async () => { + write({ presets: [] }); + const edit = editor(); + await expect(edit.addPreset(SHELL)).rejects.toBeInstanceOf(ConfigError); + }); + + it.each([ + ['to the front', 'codex', 0, [CODEX, SHELL, CLAUDE]], + ['to the end', 'shell', 2, [CLAUDE, CODEX, SHELL]], + ['to the end from beyond it', 'shell', 63, [CLAUDE, CODEX, SHELL]], + ['one down', 'shell', 1, [CLAUDE, SHELL, CODEX]], + ])('moves a preset %s, starting from the defaults', async (_name, preset, to, expected) => { + expect(await editor().movePreset(preset, to)).toEqual({ changed: true, presets: expected }); + expect(read()).toEqual({ presets: expected }); + }); + + it('leaves the file unchanged for a move in place or an unknown name', async () => { + write({ presets: [CLAUDE, SHELL] }); + const before = text(); + expect(await editor().movePreset('claude', 0)).toEqual({ changed: false, presets: [CLAUDE, SHELL] }); + expect(await editor().movePreset('shell', 5)).toEqual({ changed: false, presets: [CLAUDE, SHELL] }); + expect(await editor().movePreset('nope', 0)).toEqual({ changed: false, presets: [CLAUDE, SHELL] }); + expect(text()).toBe(before); + }); + + it('applies concurrent preset and repo edits one at a time, losing none', async () => { + const shared = editor(); + await Promise.all([shared.addPreset({ name: 'a', command: null }), shared.addRepo(null, '/r/1'), shared.removePreset('codex')]); + expect(read()).toEqual({ presets: [SHELL, CLAUDE, { name: 'a', command: null }], repos: ['/r/1'] }); + }); +}); + describe('ConfigEditor with other writers', () => { it('starts over from a file changed between its read and its replacement, keeping both changes', async () => { write({ repos: ['/r/a'] }); diff --git a/src/hub/config/editor.ts b/src/hub/config/editor.ts index 53e5ad9..f55b292 100644 --- a/src/hub/config/editor.ts +++ b/src/hub/config/editor.ts @@ -1,6 +1,7 @@ import { dirname } from 'node:path'; import { fileIdentity, makePrivateDirs, readFileIfExists, writeFileAtomic } from '../../platform/files/index.js'; -import { ConfigError, expandHome, MAX_REPOS, parseConfig } from './config.js'; +import type { Preset } from '../../protocol/index.js'; +import { ConfigError, expandHome, MAX_PRESETS, MAX_REPOS, parseConfig, type HubConfig } from './config.js'; const ATTEMPTS = 3; @@ -10,6 +11,12 @@ export interface RepoEdit { repos: string[]; } +/** The outcome of a preset edit: whether the file changed, and the presets afterwards. */ +export interface PresetEdit { + changed: boolean; + presets: Preset[]; +} + export interface EditorHooks { /** Runs after an attempt computed its result and before it checks the file and replaces it. */ beforeReplace?: (attempt: number) => void | Promise; @@ -17,11 +24,22 @@ export interface EditorHooks { type JsonObject = Record; +/** A change to one array in the file. */ +interface FieldEdit { + /** The object holding the array, and its key there. */ + owner: (raw: JsonObject) => JsonObject; + key: string; + /** The array's current value, from the parsed configuration or the object as written. */ + current: (config: HubConfig, owner: JsonObject) => T[]; + /** The new value, null to leave the file alone; throws ConfigError to refuse the edit. */ + change: (current: T[]) => T[] | null; +} + const isObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); const stringsOf = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []); -/** Edits the repos of one host in `config.json`, keeping everything else as written; one edit at a time. */ +/** Edits the repos of one host, or the presets, in `config.json`, keeping everything else as written; one edit at a time. */ export class ConfigEditor { private queue: Promise = Promise.resolve(); @@ -33,7 +51,11 @@ export class ConfigEditor { /** Adds `repo` to the local host (`host` null) or the named remote host unless it is listed already. */ addRepo(host: string | null, repo: string): Promise { - return this.edit(host, (repos) => (repos.some((entry) => this.names(host, entry, repo)) ? null : [...repos, repo])); + return this.editRepos(host, (repos) => { + if (repos.some((entry) => this.names(host, entry, repo))) return null; + if (repos.length >= MAX_REPOS) throw new ConfigError(`${host ?? 'the local host'} would list more than ${String(MAX_REPOS)} repos`); + return [...repos, repo]; + }); } /** Whether the host's repos name `repo`, as given or by a `~/` form; throws ConfigError for an unusable file. */ @@ -47,46 +69,87 @@ export class ConfigEditor { /** Removes every entry naming `repo`, as given or by a `~/` form, from the host's repos. */ removeRepo(host: string | null, repo: string): Promise { - return this.edit(host, (repos) => { + return this.editRepos(host, (repos) => { const kept = repos.filter((entry) => !this.names(host, entry, repo)); return kept.length === repos.length ? null : kept; }); } + /** Appends `preset` to the presets, the defaults when the file has none; refuses a taken name. */ + addPreset(preset: Preset): Promise { + return this.editPresets((presets) => { + if (presets.some((p) => p.name === preset.name)) throw new ConfigError(`a preset named ${preset.name} exists`); + if (presets.length >= MAX_PRESETS) throw new ConfigError(`there would be more than ${String(MAX_PRESETS)} presets`); + return [...presets, preset]; + }); + } + + /** Removes the preset named `name`; refuses to remove the last one. */ + removePreset(name: string): Promise { + return this.editPresets((presets) => { + const kept = presets.filter((p) => p.name !== name); + if (kept.length === presets.length) return null; + if (kept.length === 0) throw new ConfigError('the last preset cannot be removed'); + return kept; + }); + } + + /** Moves the preset named `name` to position `to`, the last position when `to` is beyond it. */ + movePreset(name: string, to: number): Promise { + return this.editPresets((presets) => { + const from = presets.findIndex((p) => p.name === name); + const moved = presets[from]; + if (moved === undefined) return null; + const rest = presets.filter((_, i) => i !== from); + const at = Math.min(to, rest.length); + return at === from ? null : [...rest.slice(0, at), moved, ...rest.slice(at)]; + }); + } + private names(host: string | null, entry: string, repo: string): boolean { return entry === repo || (host === null && expandHome(entry, this.home) === repo); } - private expanded(host: string | null, repos: readonly string[]): string[] { - return host === null ? repos.map((repo) => expandHome(repo, this.home)) : [...repos]; + private async editRepos(host: string | null, change: (repos: string[]) => string[] | null): Promise { + const { changed, value } = await this.edit({ + owner: (raw) => this.ownerOf(raw, host), + key: 'repos', + current: (_config, owner) => stringsOf(owner['repos']), + change, + }); + return { changed, repos: host === null ? value.map((repo) => expandHome(repo, this.home)) : value }; + } + + private async editPresets(change: (presets: Preset[]) => Preset[] | null): Promise { + const { changed, value } = await this.edit({ owner: (raw) => raw, key: 'presets', current: (config) => config.presets, change }); + return { changed, presets: value }; } - private edit(host: string | null, change: (repos: string[]) => string[] | null): Promise { - const run = this.queue.then(() => this.attempt(host, change, 1)); + private edit(field: FieldEdit): Promise<{ changed: boolean; value: T[] }> { + const run = this.queue.then(() => this.attempt(field, 1)); this.queue = run.catch(() => undefined); return run; } - private async attempt(host: string | null, change: (repos: string[]) => string[] | null, attempt: number): Promise { + private async attempt(field: FieldEdit, attempt: number): Promise<{ changed: boolean; value: T[] }> { const identity = await fileIdentity(this.path); const text = await readFileIfExists(this.path); - parseConfig(text, this.home); + const config = parseConfig(text, this.home); const raw: unknown = text === null ? {} : JSON.parse(text); if (!isObject(raw)) throw new ConfigError('the configuration is not an object'); - const owner = this.ownerOf(raw, host); - const current = stringsOf(owner['repos']); - const next = change(current); - if (next === null) return { changed: false, repos: this.expanded(host, current) }; - if (next.length > MAX_REPOS) throw new ConfigError(`${host ?? 'the local host'} would list more than ${String(MAX_REPOS)} repos`); - owner['repos'] = next; + const owner = field.owner(raw); + const current = field.current(config, owner); + const next = field.change(current); + if (next === null) return { changed: false, value: current }; + owner[field.key] = next; await this.hooks.beforeReplace?.(attempt); if ((await fileIdentity(this.path)) !== identity || (await readFileIfExists(this.path)) !== text) { if (attempt >= ATTEMPTS) throw new ConfigError(`${this.path} kept changing while being edited`); - return this.attempt(host, change, attempt + 1); + return this.attempt(field, attempt + 1); } if (text === null) await makePrivateDirs(dirname(this.path)); await writeFileAtomic(this.path, `${JSON.stringify(raw, null, 2)}\n`); - return { changed: true, repos: this.expanded(host, next) }; + return { changed: true, value: next }; } /** The object holding the edited host's `repos`: the configuration itself, or the named host's entry. */ diff --git a/src/hub/config/index.ts b/src/hub/config/index.ts index 93aba2a..9d3b375 100644 --- a/src/hub/config/index.ts +++ b/src/hub/config/index.ts @@ -9,4 +9,4 @@ export { type HubConfig, type RemoteHostConfig, } from './config.js'; -export { ConfigEditor, type EditorHooks, type RepoEdit } from './editor.js'; +export { ConfigEditor, type EditorHooks, type PresetEdit, type RepoEdit } from './editor.js'; diff --git a/src/hub/router/hosts.test.ts b/src/hub/router/hosts.test.ts index 5f9b4a8..10587d0 100644 --- a/src/hub/router/hosts.test.ts +++ b/src/hub/router/hosts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PROTOCOL_VERSION } from '../../protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION } from '../../protocol/index.js'; import { hostTransition, initialHostState, localHostName, retryDelay, type HostEvent, type HostState } from './hosts.js'; const hello = (protocol: number, instance = 'd_1'): HostEvent => ({ kind: 'hello', protocol, version: '9.9.9', instance }); @@ -14,7 +14,7 @@ describe('host status', () => { }); it('is connected to a daemon speaking our protocol', () => { - expect(run(hello(PROTOCOL_VERSION))).toEqual({ + expect(run(hello(DAEMON_PROTOCOL_VERSION))).toEqual({ status: 'connected', reason: null, daemonVersion: '9.9.9', @@ -24,7 +24,7 @@ describe('host status', () => { }); it('is outdated for a daemon speaking another protocol, keeping its instance', () => { - expect(run(hello(PROTOCOL_VERSION + 1))).toEqual({ + expect(run(hello(DAEMON_PROTOCOL_VERSION + 1))).toEqual({ status: 'outdated', reason: null, daemonVersion: '9.9.9', @@ -44,7 +44,7 @@ describe('host status', () => { }); it('clears the reason on a hello', () => { - expect(run(failed, hello(PROTOCOL_VERSION)).reason).toBeNull(); + expect(run(failed, hello(DAEMON_PROTOCOL_VERSION)).reason).toBeNull(); expect(run(failed, hello(1)).reason).toBeNull(); }); @@ -64,8 +64,8 @@ describe('host status', () => { }); it.each([ - ['connected', hello(PROTOCOL_VERSION)], - ['outdated', hello(PROTOCOL_VERSION + 1)], + ['connected', hello(DAEMON_PROTOCOL_VERSION)], + ['outdated', hello(DAEMON_PROTOCOL_VERSION + 1)], ])('drops the instance when a %s link is lost', (_name, event) => { const lost = run(event, failed); expect(lost.status).toBe('reconnecting'); @@ -73,7 +73,7 @@ describe('host status', () => { }); it('resets the failure count on a hello', () => { - const recovered = run(failed, failed, failed, hello(PROTOCOL_VERSION, 'd_2')); + const recovered = run(failed, failed, failed, hello(DAEMON_PROTOCOL_VERSION, 'd_2')); expect(recovered).toEqual({ status: 'connected', reason: null, daemonVersion: '9.9.9', instance: 'd_2', failures: 0 }); expect(hostTransition(recovered, failed)).toMatchObject({ status: 'reconnecting', failures: 1 }); }); diff --git a/src/hub/router/hosts.ts b/src/hub/router/hosts.ts index 1d94d1b..a56b934 100644 --- a/src/hub/router/hosts.ts +++ b/src/hub/router/hosts.ts @@ -1,4 +1,4 @@ -import { PROTOCOL_VERSION, type HostEntry } from '../../protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION, type HostEntry } from '../../protocol/index.js'; /** The status of one host as one session sees it. */ export interface HostState { @@ -23,7 +23,7 @@ export const initialHostState: HostState = { status: 'connecting', reason: null, export const hostTransition = (state: HostState, event: HostEvent): HostState => { if (event.kind === 'hello') { - const status = event.protocol === PROTOCOL_VERSION ? 'connected' : 'outdated'; + const status = event.protocol === DAEMON_PROTOCOL_VERSION ? 'connected' : 'outdated'; return { status, reason: null, daemonVersion: event.version, instance: event.instance, failures: 0 }; } const failures = state.failures + 1; diff --git a/src/hub/router/index.ts b/src/hub/router/index.ts index 640e8fa..f9a3b89 100644 --- a/src/hub/router/index.ts +++ b/src/hub/router/index.ts @@ -1,4 +1,4 @@ -import { PROTOCOL_VERSION, type DataFrame, type MessageOf } from '../../protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION, type DataFrame, type MessageOf } from '../../protocol/index.js'; import { ConfigEditor, ConfigSource, type ConfigSnapshot, type HubConfig } from '../config/index.js'; import { installOnRemote, LocalDaemon, RemoteDaemon, type DaemonEndpoint, type DaemonPaths } from '../links/index.js'; import { HostCoordinator } from './coordinator.js'; @@ -56,7 +56,7 @@ export class Router { private readonly coordinators = new Map(); constructor(private readonly options: RouterOptions) { - this.hello = { t: 'hello', protocol: PROTOCOL_VERSION, version: options.version, instance: options.instance }; + this.hello = { t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: options.version, instance: options.instance }; this.local = new LocalDaemon(options.paths, options.daemonCommand); this.config = new ConfigSource(options.configPath, options.home, options.config); this.editor = new ConfigEditor(options.configPath, options.home); @@ -72,6 +72,13 @@ export class Router { reposEdited: (host, repos) => { for (const session of this.sessions) session.showRepos(host, repos); }, + editPresets: (edit) => { + if (edit.t === 'add') return this.editor.addPreset(edit.preset); + return edit.t === 'remove' ? this.editor.removePreset(edit.name) : this.editor.movePreset(edit.name, edit.to); + }, + presetsEdited: (presets) => { + for (const session of this.sessions) session.showPresets(presets); + }, }; } diff --git a/src/hub/router/oneshot.ts b/src/hub/router/oneshot.ts index a6d3b36..2afbcbf 100644 --- a/src/hub/router/oneshot.ts +++ b/src/hub/router/oneshot.ts @@ -1,4 +1,4 @@ -import { decodeMessage, FrameKind, PROTOCOL_VERSION, ProtocolError, type Frame, type MessageOf } from '../../protocol/index.js'; +import { decodeMessage, FrameKind, DAEMON_PROTOCOL_VERSION, ProtocolError, type Frame, type MessageOf } from '../../protocol/index.js'; import type { DaemonEndpoint, Link } from '../links/index.js'; import { controlFrame } from './restart.js'; @@ -69,7 +69,7 @@ export const requestOnce = (daemon: DaemonEndpoint, hello: Hello, request: Reque greeted = true; if (message?.t !== 'hello') { fail('internal', 'the daemon did not greet'); - } else if (message.protocol === PROTOCOL_VERSION) { + } else if (message.protocol === DAEMON_PROTOCOL_VERSION) { link?.send(controlFrame(hello)); link?.send(controlFrame(request)); } else { diff --git a/src/hub/router/restart.ts b/src/hub/router/restart.ts index c6c74ad..9ceeb80 100644 --- a/src/hub/router/restart.ts +++ b/src/hub/router/restart.ts @@ -3,7 +3,7 @@ import { encodeFrame, encodeMessage, FrameKind, - PROTOCOL_VERSION, + DAEMON_PROTOCOL_VERSION, ProtocolError, type Frame, type MessageOf, @@ -63,7 +63,7 @@ export class DaemonRestarter { } for (;;) { const fresh = await this.probe({ start: true, shutdown: never }, deadline); // NOSONAR(S9382) — polls until a new daemon serves - if (fresh !== null && fresh.instance !== old?.instance && fresh.protocol === PROTOCOL_VERSION) return; + if (fresh !== null && fresh.instance !== old?.instance && fresh.protocol === DAEMON_PROTOCOL_VERSION) return; if (Date.now() >= deadline) throw new Error('no new daemon served within 10 s'); await sleep(POLL_MS); // NOSONAR(S9382) — polling loop } @@ -72,7 +72,7 @@ export class DaemonRestarter { /** Dials until a daemon of `version` and our protocol answers, shutting down any other; rejects after 10 s. */ async replaceOther(version: string): Promise { const deadline = Date.now() + RESTART_MS; - const other: ShutdownIf = (hello) => hello.version !== version || hello.protocol !== PROTOCOL_VERSION; + const other: ShutdownIf = (hello) => hello.version !== version || hello.protocol !== DAEMON_PROTOCOL_VERSION; for (;;) { const hello = await this.probe({ start: true, shutdown: other }, deadline); // NOSONAR(S9382) — polls until the new daemon serves if (hello !== null && !other(hello)) return; diff --git a/src/hub/router/session.ts b/src/hub/router/session.ts index 470019a..3a27254 100644 --- a/src/hub/router/session.ts +++ b/src/hub/router/session.ts @@ -9,8 +9,9 @@ import { type Frame, type HostEntry, type MessageOf, + type Preset, } from '../../protocol/index.js'; -import { ConfigError, type ConfigSnapshot, type RepoEdit } from '../config/index.js'; +import { ConfigError, type ConfigSnapshot, type PresetEdit, type RepoEdit } from '../config/index.js'; import type { DaemonEndpoint, Link } from '../links/index.js'; import type { HostCoordinator } from './coordinator.js'; import { hostTransition, initialHostState, retryDelay, type HostEvent, type HostState } from './hosts.js'; @@ -52,6 +53,12 @@ export interface SessionContext { editRepos: (host: SessionHost, change: 'add' | 'remove', repo: string) => Promise; /** Shows the host's repos after an edit to every session listing it. */ reposEdited: (host: SessionHost, repos: string[]) => void; + /** Adds or removes a preset in the configuration. */ + editPresets: ( + edit: { t: 'add'; preset: Preset } | { t: 'remove'; name: string } | { t: 'move'; name: string; to: number }, + ) => Promise; + /** Shows the presets after an edit to every session. */ + presetsEdited: (presets: Preset[]) => void; } /** Depth of hub-level repo discovery. */ @@ -255,9 +262,23 @@ export class Session { case 'removeRepo': this.removeRepo(message.req, message.host, message.repo); return; + case 'addPreset': + this.editPresets(message.req, { t: 'add', preset: message.preset }); + return; + case 'removePreset': + this.editPresets(message.req, { t: 'remove', name: message.name }); + return; + case 'movePreset': + this.editPresets(message.req, { t: 'move', name: message.name, to: message.to }); + return; } } + /** Sends presets changed by an edit. */ + showPresets(presets: Preset[]): void { + if (!this.closed) this.message({ t: 'presets', presets }); + } + /** Replaces the repos of the session's matching host after an edit and sends `hosts`. */ showRepos(host: SessionHost, repos: string[]): void { const target = this.hosts.find((h) => (host.ssh === null ? h.host.ssh === null : h.host.ssh !== null && h.host.name === host.name)); @@ -406,6 +427,20 @@ export class Session { }); } + /** Edits the presets, answering `done` and showing the result to every session, or `internal` naming the refusal. */ + private editPresets(req: number, edit: Parameters[0]): void { + this.context.editPresets(edit).then( + (result) => { + this.message({ t: 'done', req }); + if (result.changed) this.context.presetsEdited(result.presets); + }, + (error: unknown) => { + const text = error instanceof Error ? error.message : String(error); + this.error(req, null, 'internal', error instanceof ConfigError ? `cannot edit the configuration: ${text}` : text); + }, + ); + } + /** Answers `done` for an edit, then shows its repos to every session listing the host. */ private edited(req: number, host: SessionHost, edit: RepoEdit): void { this.message({ t: 'done', req }); diff --git a/src/hub/server/listener.ts b/src/hub/server/listener.ts index d9165a2..432a26b 100644 --- a/src/hub/server/listener.ts +++ b/src/hub/server/listener.ts @@ -8,7 +8,7 @@ import { encodeCodeResponse, encodeMessage, MAX_FRAME, - PROTOCOL_VERSION, + BROWSER_PROTOCOL_VERSION, ProtocolError, type MessageOf, } from '../../protocol/index.js'; @@ -323,7 +323,7 @@ export class Listener { send(hubError(null, null, code, message)); ws.close(POLICY_VIOLATION, code); }; - send({ t: 'hello', protocol: PROTOCOL_VERSION, version: this.options.version, instance: this.options.instance }); + send({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, version: this.options.version, instance: this.options.instance }); if (kind === 'code') send({ t: 'token', token: this.options.token }); /** Routes one browser message; returns the failure that ends the session, if any. */ const handle = (bytes: Uint8Array, isBinary: boolean): { code: ErrorCode; message: string } | null => { @@ -339,8 +339,8 @@ export class Listener { routed.receive(message); return null; } - if (message.protocol !== PROTOCOL_VERSION) { - return { code: 'version-mismatch', message: `the hub speaks protocol ${String(PROTOCOL_VERSION)}` }; + if (message.protocol !== BROWSER_PROTOCOL_VERSION) { + return { code: 'version-mismatch', message: `the hub speaks protocol ${String(BROWSER_PROTOCOL_VERSION)}` }; } greeted = true; routed.hello(); diff --git a/src/protocol/constants.test.ts b/src/protocol/constants.test.ts index 5a256d6..6500b32 100644 --- a/src/protocol/constants.test.ts +++ b/src/protocol/constants.test.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { ACK_EVERY, FLOW_HIGH, FLOW_LOW, LAG_EVICT_MS, MAX_FRAME, MAX_INPUT, PROTOCOL_VERSION } from './index.js'; +import { + ACK_EVERY, + BROWSER_PROTOCOL_VERSION, + DAEMON_PROTOCOL_VERSION, + FLOW_HIGH, + FLOW_LOW, + LAG_EVICT_MS, + MAX_FRAME, + MAX_INPUT, +} from './index.js'; describe('shared constants', () => { it('hold their specified values', () => { - expect({ PROTOCOL_VERSION, MAX_FRAME, MAX_INPUT, FLOW_HIGH, FLOW_LOW, ACK_EVERY, LAG_EVICT_MS }).toEqual({ - PROTOCOL_VERSION: 5, + expect({ + DAEMON_PROTOCOL_VERSION, + BROWSER_PROTOCOL_VERSION, + MAX_FRAME, + MAX_INPUT, + FLOW_HIGH, + FLOW_LOW, + ACK_EVERY, + LAG_EVICT_MS, + }).toEqual({ + DAEMON_PROTOCOL_VERSION: 6, + BROWSER_PROTOCOL_VERSION: 8, MAX_FRAME: 16 * 1024 * 1024, MAX_INPUT: 64 * 1024, FLOW_HIGH: 512 * 1024, diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index 47fdd53..bbd327f 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -1,7 +1,10 @@ const KiB = 1024; const MiB = 1024 * KiB; -export const PROTOCOL_VERSION = 5; +/** Version of the daemon link (client ↔ daemon), whose two ends can run different releases. */ +export const DAEMON_PROTOCOL_VERSION = 6; +/** Version of the browser link (browser ↔ hub); the hub serves the page, so a bump needs no daemon restart. */ +export const BROWSER_PROTOCOL_VERSION = 8; /** Largest stream frame payload. */ export const MAX_FRAME = 16 * MiB; diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 476e22c..c6aa96f 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -1,4 +1,13 @@ -export { ACK_EVERY, FLOW_HIGH, FLOW_LOW, LAG_EVICT_MS, MAX_FRAME, MAX_INPUT, PROTOCOL_VERSION } from './constants.js'; +export { + ACK_EVERY, + FLOW_HIGH, + FLOW_LOW, + LAG_EVICT_MS, + MAX_FRAME, + MAX_INPUT, + DAEMON_PROTOCOL_VERSION, + BROWSER_PROTOCOL_VERSION, +} from './constants.js'; export { ProtocolError } from './errors.js'; export { decodeStreamData, diff --git a/src/protocol/messages.test.ts b/src/protocol/messages.test.ts index 0091d95..0167066 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -253,11 +253,15 @@ describe('request correlation', () => { ['clientToDaemon', { t: 'closeTerm', req: 1, termId: 1 }], ['clientToDaemon', { t: 'setChecked', req: 1, worktree: WT, checked: true }], ['clientToDaemon', { t: 'setLayout', req: 1, worktree: WT, layout: { tabs: [], active: 0 } }], + ['clientToDaemon', { t: 'removeWorktree', req: 1, worktree: WT, force: false }], ['browserToHub', { t: 'addRepo', req: 1, host: 0, repo: REPO }], ['browserToHub', { t: 'removeRepo', req: 1, host: 0, repo: REPO }], ['browserToHub', { t: 'discoverRepos', req: 1, host: 0 }], ['browserToHub', { t: 'restartDaemon', req: 1, host: 0 }], ['browserToHub', { t: 'reinstallDaemon', req: 1, host: 0 }], + ['browserToHub', { t: 'addPreset', req: 1, preset: { name: 'p', command: null } }], + ['browserToHub', { t: 'removePreset', req: 1, name: 'p' }], + ['browserToHub', { t: 'movePreset', req: 1, name: 'p', to: 0 }], ]; const fireAndForget: readonly (readonly [Direction, Record])[] = [ @@ -507,6 +511,32 @@ describe('value limits', () => { expect(decodes('hubToBrowser', { t: 'error', req: 1, host: 0, code: 'host-unavailable', message: 'x' })).toBe(true); }); + it.each([ + ['without force', 'clientToDaemon', { t: 'removeWorktree', req: 1, worktree: WT }], + ['for a relative path', 'clientToDaemon', { t: 'removeWorktree', req: 1, worktree: 'wt', force: false }], + ['from the daemon', 'daemonToClient', { t: 'removeWorktree', req: 1, worktree: WT, force: false }], + [ + 'with negative commits', + 'daemonToClient', + { t: 'worktreeAtRisk', req: 1, worktree: WT, base: null, ahead: -1, changes: 0, running: [] }, + ], + [ + 'with fractional changes', + 'daemonToClient', + { t: 'worktreeAtRisk', req: 1, worktree: WT, base: null, ahead: 0, changes: 0.5, running: [] }, + ], + ['with terminal id 0', 'daemonToClient', { t: 'worktreeAtRisk', req: 1, worktree: WT, base: null, ahead: 0, changes: 0, running: [0] }], + ['without req', 'daemonToClient', { t: 'worktreeAtRisk', worktree: WT, base: null, ahead: 0, changes: 0, running: [] }], + ] as const)('rejects a worktree removal message %s', (_name, dir, message) => { + expectRejected(dir, raw(message)); + }); + + it('carries worktree removal through the browser envelopes', () => { + expect(decodes('browserToHub', { t: 'host', host: 0, m: { t: 'removeWorktree', req: 1, worktree: WT, force: true } })).toBe(true); + const risk = { t: 'worktreeAtRisk', req: 1, worktree: WT, base: 'origin/main', ahead: 1, changes: 0, running: [2] }; + expect(decodes('hubToBrowser', { t: 'host', host: 0, m: risk })).toBe(true); + }); + it('rejects a blank preset name', () => { expectRejected('hubToBrowser', raw({ t: 'presets', presets: [{ name: ' ', command: null }] })); }); @@ -514,6 +544,26 @@ describe('value limits', () => { it('rejects an empty preset command', () => { expectRejected('hubToBrowser', raw({ t: 'presets', presets: [{ name: 'p', command: '' }] })); }); + + it.each([ + ['a blank name', { t: 'addPreset', req: 1, preset: { name: ' ', command: null } }], + ['an empty command', { t: 'addPreset', req: 1, preset: { name: 'p', command: '' } }], + ['an extra preset field', { t: 'addPreset', req: 1, preset: { name: 'p', command: null, x: 1 } }], + ['a missing preset', { t: 'addPreset', req: 1 }], + ['an empty name to remove', { t: 'removePreset', req: 1, name: '' }], + ['a 65-char name to remove', { t: 'removePreset', req: 1, name: 'p'.repeat(65) }], + ['a negative position', { t: 'movePreset', req: 1, name: 'p', to: -1 }], + ['a position past the last preset', { t: 'movePreset', req: 1, name: 'p', to: 64 }], + ['a fractional position', { t: 'movePreset', req: 1, name: 'p', to: 0.5 }], + ['a blank name to move', { t: 'movePreset', req: 1, name: ' ', to: 0 }], + ])('rejects a preset edit with %s', (_name, message) => { + expectRejected('browserToHub', raw(message)); + }); + + it('accepts preset edits only from the browser', () => { + expectRejected('hubToBrowser', raw({ t: 'removePreset', req: 1, name: 'p' })); + expectRejected('clientToDaemon', raw({ t: 'addPreset', req: 1, preset: { name: 'p', command: null } })); + }); }); describe('exported preset schema', () => { diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index f1281e3..5210f07 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ProtocolError } from './errors.js'; import { layout } from './layout.js'; import { + count, depth, errorCode, errorMessage, @@ -78,6 +79,7 @@ const daemonRequests = [ message('setVisible', { termIds: z.array(termId).max(MAX_VISIBLE) }), message('setChecked', { req, worktree: path, checked: z.boolean() }), message('setLayout', { req, worktree: path, layout }), + message('removeWorktree', { req, worktree: path, force: z.boolean() }), ] as const; const daemonEvents = [ @@ -99,6 +101,14 @@ const daemonEvents = [ message('checkedChanged', { worktree: path, checked: z.boolean() }), message('layoutChanged', { worktree: path, layout }), message('reposDiscovered', { req, repos }), + message('worktreeAtRisk', { + req, + worktree: path, + base: longText, + ahead: count.nullable(), + changes: count, + running: z.array(termId).max(MAX_ENTRIES), + }), ] as const; const hostEntry = z.strictObject({ @@ -123,6 +133,16 @@ export const messageSchemas = { message('discoverRepos', { req, host: hostIdx }), message('restartDaemon', { req, host: hostIdx }), message('reinstallDaemon', { req, host: hostIdx }), + message('addPreset', { req, preset }), + message('removePreset', { req, name: presetName }), + message('movePreset', { + req, + name: presetName, + to: z + .int() + .min(0) + .max(MAX_PRESETS - 1), + }), ]), hubToBrowser: z.discriminatedUnion('t', [ hello, diff --git a/src/protocol/test-samples.ts b/src/protocol/test-samples.ts index bb49b05..9ff7048 100644 --- a/src/protocol/test-samples.ts +++ b/src/protocol/test-samples.ts @@ -135,6 +135,8 @@ export const clientToDaemonSamples: MessageOf<'clientToDaemon'>[] = [ { t: 'setVisible', termIds: [] }, { t: 'setChecked', req: 9, worktree: WT, checked: true }, { t: 'setLayout', req: 10, worktree: WT, layout }, + { t: 'removeWorktree', req: 11, worktree: WT, force: false }, + { t: 'removeWorktree', req: 12, worktree: WT, force: true }, ]; export const daemonToClientSamples: MessageOf<'daemonToClient'>[] = [ @@ -165,6 +167,8 @@ export const daemonToClientSamples: MessageOf<'daemonToClient'>[] = [ { t: 'layoutChanged', worktree: WT, layout: { tabs: [], active: 0 } }, { t: 'layoutChanged', worktree: WT, layout: fullTabLayout }, { t: 'reposDiscovered', req: 4, repos: [REPO, WT] }, + { t: 'worktreeAtRisk', req: 11, worktree: WT, base: 'origin/main', ahead: 3, changes: 2, running: [1, 7] }, + { t: 'worktreeAtRisk', req: 12, worktree: WT, base: null, ahead: null, changes: 0, running: [] }, ]; type DaemonRequest = Exclude, { t: 'hello' | 'shutdown' }>; @@ -181,6 +185,10 @@ export const browserToHubSamples: MessageOf<'browserToHub'>[] = [ { t: 'discoverRepos', req: 3, host: 65535 }, { t: 'restartDaemon', req: 4, host: 1 }, { t: 'reinstallDaemon', req: 5, host: 1 }, + { t: 'addPreset', req: 6, preset: { name: 'codex', command: 'codex' } }, + { t: 'addPreset', req: 7, preset: { name: 'login shell', command: null } }, + { t: 'removePreset', req: 8, name: 'codex' }, + { t: 'movePreset', req: 9, name: 'codex', to: 0 }, ]; export const hubToBrowserSamples: MessageOf<'hubToBrowser'>[] = [ @@ -228,6 +236,7 @@ export const MESSAGE_TYPES: Readonly> = { 'setVisible', 'setChecked', 'setLayout', + 'removeWorktree', ], daemonToClient: [ 'hello', @@ -243,8 +252,20 @@ export const MESSAGE_TYPES: Readonly> = { 'checkedChanged', 'layoutChanged', 'reposDiscovered', + 'worktreeAtRisk', + ], + browserToHub: [ + 'hello', + 'host', + 'addRepo', + 'removeRepo', + 'discoverRepos', + 'restartDaemon', + 'reinstallDaemon', + 'addPreset', + 'removePreset', + 'movePreset', ], - browserToHub: ['hello', 'host', 'addRepo', 'removeRepo', 'discoverRepos', 'restartDaemon', 'reinstallDaemon'], hubToBrowser: ['hello', 'token', 'host', 'hosts', 'presets', 'done', 'error', 'reposDiscovered'], }; diff --git a/src/protocol/values.ts b/src/protocol/values.ts index 009ff0e..018a0eb 100644 --- a/src/protocol/values.ts +++ b/src/protocol/values.ts @@ -4,6 +4,7 @@ const U32_MAX = 2 ** 32 - 1; export const req = z.int().min(1).max(U32_MAX); export const termId = z.int().min(1).max(U32_MAX); +export const count = z.int().min(0).max(U32_MAX); export const offset = z.int().min(0).max(Number.MAX_SAFE_INTEGER); export const size = z.int().min(1).max(1000); export const hostIdx = z.int().min(0).max(65535); diff --git a/src/protocol/wire.golden.json b/src/protocol/wire.golden.json index 59bfb3e..c96c870 100644 --- a/src/protocol/wire.golden.json +++ b/src/protocol/wire.golden.json @@ -1,5 +1,8 @@ { - "protocolVersion": 5, + "protocolVersions": { + "daemon": 6, + "browser": 8 + }, "constants": { "MAX_FRAME": 16777216, "MAX_INPUT": 65536, @@ -441,6 +444,35 @@ "layout" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "removeWorktree" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "worktree": { + "type": "string", + "maxLength": 4096, + "pattern": "^\\/[^\\u0000]*$" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "t", + "req", + "worktree", + "force" + ], + "additionalProperties": false } ], "$defs": { @@ -1247,6 +1279,72 @@ "repos" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "worktreeAtRisk" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "worktree": { + "type": "string", + "maxLength": 4096, + "pattern": "^\\/[^\\u0000]*$" + }, + "base": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "ahead": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "null" + } + ] + }, + "changes": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "running": { + "maxItems": 1024, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + } + } + }, + "required": [ + "t", + "req", + "worktree", + "base", + "ahead", + "changes", + "running" + ], + "additionalProperties": false } ], "$defs": { @@ -1733,6 +1831,35 @@ "layout" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "removeWorktree" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "worktree": { + "type": "string", + "maxLength": 4096, + "pattern": "^\\/[^\\u0000]*$" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "t", + "req", + "worktree", + "force" + ], + "additionalProperties": false } ] } @@ -1880,6 +2007,112 @@ "host" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "addPreset" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "preset": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "\\S" + }, + "command": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "command" + ], + "additionalProperties": false + } + }, + "required": [ + "t", + "req", + "preset" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "removePreset" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "\\S" + } + }, + "required": [ + "t", + "req", + "name" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "movePreset" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "\\S" + }, + "to": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "required": [ + "t", + "req", + "name", + "to" + ], + "additionalProperties": false } ], "$defs": { @@ -2718,6 +2951,72 @@ "repos" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "t": { + "type": "string", + "const": "worktreeAtRisk" + }, + "req": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "worktree": { + "type": "string", + "maxLength": 4096, + "pattern": "^\\/[^\\u0000]*$" + }, + "base": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "ahead": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "null" + } + ] + }, + "changes": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "running": { + "maxItems": 1024, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + } + } + }, + "required": [ + "t", + "req", + "worktree", + "base", + "ahead", + "changes", + "running" + ], + "additionalProperties": false } ] } @@ -3131,13 +3430,23 @@ }, { "dir": "clientToDaemon", - "json": "{\"t\":\"done\",\"req\":1}", - "valid": false + "json": "{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}", + "valid": true }, { "dir": "clientToDaemon", - "json": "{\"t\":\"error\",\"req\":2,\"code\":\"unknown-term\",\"message\":\"no such terminal\"}", - "valid": false + "json": "{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}", + "valid": true + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"done\",\"req\":1}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"error\",\"req\":2,\"code\":\"unknown-term\",\"message\":\"no such terminal\"}", + "valid": false }, { "dir": "clientToDaemon", @@ -3224,6 +3533,16 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"/home/u/repo\"}}", @@ -3299,6 +3618,16 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"/home/u/repo\"}", @@ -3324,6 +3653,26 @@ "json": "{\"t\":\"reinstallDaemon\",\"req\":5,\"host\":1}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"addPreset\",\"req\":6,\"preset\":{\"name\":\"codex\",\"command\":\"codex\"}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"addPreset\",\"req\":7,\"preset\":{\"name\":\"login shell\",\"command\":null}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"removePreset\",\"req\":8,\"name\":\"codex\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -3424,6 +3773,16 @@ "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -3694,6 +4053,36 @@ "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\" \",\"command\":null}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\"p\",\"command\":\"\"}}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"removePreset\",\"req\":1,\"name\":\"\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"movePreset\",\"req\":1,\"name\":\"p\",\"to\":64}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"removeWorktree\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":-1,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", @@ -3789,6 +4178,16 @@ "json": "{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"done\",\"req\":1}", @@ -3884,6 +4283,16 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}", "valid": true }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}", + "valid": true + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}", + "valid": true + }, { "dir": "daemonToClient", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"/home/u/repo\"}}", @@ -3959,6 +4368,16 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"/home/u/repo\"}", @@ -3984,6 +4403,26 @@ "json": "{\"t\":\"reinstallDaemon\",\"req\":5,\"host\":1}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"addPreset\",\"req\":6,\"preset\":{\"name\":\"codex\",\"command\":\"codex\"}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"addPreset\",\"req\":7,\"preset\":{\"name\":\"login shell\",\"command\":null}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"removePreset\",\"req\":8,\"name\":\"codex\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -4084,6 +4523,16 @@ "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -4354,6 +4803,36 @@ "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\" \",\"command\":null}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\"p\",\"command\":\"\"}}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"removePreset\",\"req\":1,\"name\":\"\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"movePreset\",\"req\":1,\"name\":\"p\",\"to\":64}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"removeWorktree\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":-1,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", @@ -4449,6 +4928,16 @@ "json": "{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"done\",\"req\":1}", @@ -4544,6 +5033,16 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"/home/u/repo\"}}", @@ -4619,6 +5118,16 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}}", "valid": true }, + { + "dir": "browserToHub", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}}", + "valid": true + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}}", + "valid": true + }, { "dir": "browserToHub", "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"/home/u/repo\"}", @@ -4644,6 +5153,26 @@ "json": "{\"t\":\"reinstallDaemon\",\"req\":5,\"host\":1}", "valid": true }, + { + "dir": "browserToHub", + "json": "{\"t\":\"addPreset\",\"req\":6,\"preset\":{\"name\":\"codex\",\"command\":\"codex\"}}", + "valid": true + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"addPreset\",\"req\":7,\"preset\":{\"name\":\"login shell\",\"command\":null}}", + "valid": true + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"removePreset\",\"req\":8,\"name\":\"codex\"}", + "valid": true + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", + "valid": true + }, { "dir": "browserToHub", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -4744,6 +5273,16 @@ "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -5014,6 +5553,36 @@ "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\" \",\"command\":null}}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\"p\",\"command\":\"\"}}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"removePreset\",\"req\":1,\"name\":\"\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"movePreset\",\"req\":1,\"name\":\"p\",\"to\":64}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"removeWorktree\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":-1,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", @@ -5109,6 +5678,16 @@ "json": "{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"done\",\"req\":1}", @@ -5204,6 +5783,16 @@ "json": "{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"/home/u/repo\"}}", @@ -5279,6 +5868,16 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"setLayout\",\"req\":10,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"main\",\"root\":{\"split\":\"right\",\"ratio\":0.5,\"a\":{\"term\":1},\"b\":{\"split\":\"down\",\"ratio\":0.25,\"a\":{\"term\":2},\"b\":{\"term\":3}}}},{\"id\":\"logs\",\"root\":{\"term\":4}}],\"active\":1}}}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":false}}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"removeWorktree\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"force\":true}}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"/home/u/repo\"}", @@ -5304,6 +5903,26 @@ "json": "{\"t\":\"reinstallDaemon\",\"req\":5,\"host\":1}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"addPreset\",\"req\":6,\"preset\":{\"name\":\"codex\",\"command\":\"codex\"}}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"addPreset\",\"req\":7,\"preset\":{\"name\":\"login shell\",\"command\":null}}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"removePreset\",\"req\":8,\"name\":\"codex\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"movePreset\",\"req\":9,\"name\":\"codex\",\"to\":0}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"token\",\"token\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"}", @@ -5404,6 +6023,16 @@ "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"reposDiscovered\",\"req\":4,\"repos\":[\"/home/u/repo\",\"/home/u/repo.worktrees/feat\"]}}", "valid": true }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":11,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":\"origin/main\",\"ahead\":3,\"changes\":2,\"running\":[1,7]}}", + "valid": true + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"host\",\"host\":0,\"m\":{\"t\":\"worktreeAtRisk\",\"req\":12,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":null,\"changes\":0,\"running\":[]}}", + "valid": true + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/repo\"]},{\"idx\":1,\"name\":\"devbox\",\"remote\":true,\"status\":\"outdated\",\"reason\":null,\"daemonVersion\":\"0.0.9\",\"instance\":\"old_D\",\"repos\":[]},{\"idx\":2,\"name\":\"gpu\",\"remote\":true,\"status\":\"connecting\",\"reason\":null,\"daemonVersion\":null,\"instance\":null,\"repos\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"reason\":\"ssh: connect to host old port 22: Connection refused\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", @@ -5674,6 +6303,36 @@ "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\" \",\"command\":null}}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"addPreset\",\"req\":1,\"preset\":{\"name\":\"p\",\"command\":\"\"}}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"removePreset\",\"req\":1,\"name\":\"\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"movePreset\",\"req\":1,\"name\":\"p\",\"to\":64}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"removeWorktree\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"worktreeAtRisk\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"base\":null,\"ahead\":-1,\"changes\":0,\"running\":[]}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 120f532..371de67 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -12,7 +12,8 @@ import { MAX_FRAME, MAX_INPUT, messageSchemas, - PROTOCOL_VERSION, + BROWSER_PROTOCOL_VERSION, + DAEMON_PROTOCOL_VERSION, ProtocolError, StreamDecoder, type Direction, @@ -22,8 +23,18 @@ import { DIRECTIONS, featWorktree, hello, layout, liveTerminal, raw, REPO, sampl const directionSchema = z.enum(['clientToDaemon', 'daemonToClient', 'browserToHub', 'hubToBrowser']); +type Link = 'daemon' | 'browser'; +const LINKS: readonly Link[] = ['daemon', 'browser']; +/** The link, and so the version, each direction belongs to. */ +const LINK_OF: Readonly> = { + clientToDaemon: 'daemon', + daemonToClient: 'daemon', + browserToHub: 'browser', + hubToBrowser: 'browser', +}; + const snapshotSchema = z.object({ - protocolVersion: z.number(), + protocolVersions: z.object({ daemon: z.number(), browser: z.number() }), constants: z.record(z.string(), z.number()), schemas: z.record(z.string(), z.unknown()), corpus: z.array(z.object({ dir: directionSchema, json: z.string(), valid: z.boolean() })), @@ -36,19 +47,24 @@ type PreviousSnapshot = z.infer; const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); const key = (entry: { dir: string; json: string }): string => `${entry.dir} ${entry.json}`; -/** Wire changes between two goldens that a missing PROTOCOL_VERSION bump makes illegal. */ +/** + * Wire changes between two goldens that missing version bumps make illegal: a direction's schema and corpus + * verdicts need its link's bump; the constants, which both links use, need both. + */ const wireViolations = (previous: PreviousSnapshot, current: WireSnapshot): string[] => { - if (previous.protocolVersion === undefined) return []; - if (current.protocolVersion < previous.protocolVersion) return ['PROTOCOL_VERSION decreased']; - if (current.protocolVersion > previous.protocolVersion) return []; - const violations: string[] = []; - if (!same(previous.constants, current.constants)) violations.push('constants changed'); - const schemaDirs = new Set([...Object.keys(previous.schemas ?? {}), ...Object.keys(current.schemas)]); - for (const dir of schemaDirs) { - if (!same(previous.schemas?.[dir], current.schemas[dir])) violations.push(`schema ${dir} changed`); + const before = previous.protocolVersions; + if (before === undefined) return []; + const violations = LINKS.filter((link) => current.protocolVersions[link] < before[link]).map( + (link) => `${link} protocol version decreased`, + ); + const bumped = (link: Link): boolean => current.protocolVersions[link] > before[link]; + if (!same(previous.constants, current.constants) && !LINKS.every(bumped)) violations.push('constants changed'); + for (const dir of DIRECTIONS) { + if (!bumped(LINK_OF[dir]) && !same(previous.schemas?.[dir], current.schemas[dir])) violations.push(`schema ${dir} changed`); } const now = new Map(current.corpus.map((entry) => [key(entry), entry.valid])); for (const entry of previous.corpus ?? []) { + if (bumped(LINK_OF[entry.dir])) continue; const valid = now.get(key(entry)); if (valid === undefined) violations.push(`corpus entry removed: ${key(entry)}`); else if (valid !== entry.valid) violations.push(`verdict changed: ${key(entry)}`); @@ -132,6 +148,12 @@ const EDGE_CASES: readonly unknown[] = [ { t: 'discoverRepos', req: 1, roots: ['~/'], depth: 3 }, { t: 'watchRepo', req: 1, repo: '~/x' }, { t: 'addRepo', req: 1, host: 0, repo: '~/x' }, + { t: 'addPreset', req: 1, preset: { name: ' ', command: null } }, + { t: 'addPreset', req: 1, preset: { name: 'p', command: '' } }, + { t: 'removePreset', req: 1, name: '' }, + { t: 'movePreset', req: 1, name: 'p', to: 64 }, + { t: 'removeWorktree', req: 1, worktree: WT }, + { t: 'worktreeAtRisk', req: 1, worktree: WT, base: null, ahead: -1, changes: 0, running: [] }, { t: 'hosts', hosts: [{ idx: 0, name: 'local', remote: false, status: 'connected', daemonVersion: '0.1.0', instance: 'd_1-A', repos: [] }], @@ -147,7 +169,7 @@ const EDGE_CASES: readonly unknown[] = [ const allJson = [...new Set([...DIRECTIONS.flatMap((dir) => samples[dir].map((m) => raw(m))), ...EDGE_CASES.map((m) => raw(m))])]; const currentSnapshot = (): WireSnapshot => ({ - protocolVersion: PROTOCOL_VERSION, + protocolVersions: { daemon: DAEMON_PROTOCOL_VERSION, browser: BROWSER_PROTOCOL_VERSION }, constants: { MAX_FRAME, MAX_INPUT, FLOW_HIGH, FLOW_LOW, ACK_EVERY, LAG_EVICT_MS }, schemas: Object.fromEntries(DIRECTIONS.map((dir) => [dir, z.toJSONSchema(messageSchemas[dir])])), corpus: DIRECTIONS.flatMap((dir) => allJson.map((json) => ({ dir, json, valid: decodes(dir, json) }))), @@ -155,11 +177,15 @@ const currentSnapshot = (): WireSnapshot => ({ describe('wire golden guard', () => { const base: WireSnapshot = { - protocolVersion: 3, + protocolVersions: { daemon: 3, browser: 3 }, constants: { MAX_FRAME: 1 }, - schemas: { clientToDaemon: { type: 'object' } }, - corpus: [{ dir: 'clientToDaemon', json: '{"t":"shutdown"}', valid: true }], + schemas: { clientToDaemon: { type: 'object' }, browserToHub: { type: 'object' } }, + corpus: [ + { dir: 'clientToDaemon', json: '{"t":"shutdown"}', valid: true }, + { dir: 'browserToHub', json: '{"t":"shutdown"}', valid: false }, + ], }; + const versions = (daemon: number, browser: number): WireSnapshot['protocolVersions'] => ({ daemon, browser }); it('allows an identical golden', () => { expect(wireViolations(base, base)).toEqual([]); @@ -170,25 +196,56 @@ describe('wire golden guard', () => { }); it('flags a schema change without a version bump', () => { - expect(wireViolations(base, { ...base, schemas: { clientToDaemon: { type: 'array' } } })).toEqual(['schema clientToDaemon changed']); + const schemas = { ...base.schemas, clientToDaemon: { type: 'array' } }; + expect(wireViolations(base, { ...base, schemas })).toEqual(['schema clientToDaemon changed']); }); it('flags a new message union without a version bump', () => { expect(wireViolations(base, { ...base, schemas: { ...base.schemas, hubToBrowser: {} } })).toEqual(['schema hubToBrowser changed']); }); - it('flags a constant change without a version bump', () => { - expect(wireViolations(base, { ...base, constants: { MAX_FRAME: 2 } })).toEqual(['constants changed']); + it('allows a browser schema change with only the browser version bumped', () => { + const schemas = { ...base.schemas, browserToHub: { type: 'array' }, hubToBrowser: {} }; + expect(wireViolations(base, { ...base, protocolVersions: versions(3, 4), schemas })).toEqual([]); + }); + + it('flags a daemon schema change with only the browser version bumped', () => { + const schemas = { ...base.schemas, clientToDaemon: { type: 'array' } }; + expect(wireViolations(base, { ...base, protocolVersions: versions(3, 4), schemas })).toEqual(['schema clientToDaemon changed']); + }); + + it('flags a browser schema change with only the daemon version bumped', () => { + const schemas = { ...base.schemas, browserToHub: { type: 'array' } }; + expect(wireViolations(base, { ...base, protocolVersions: versions(4, 3), schemas })).toEqual(['schema browserToHub changed']); }); - it('flags a flipped verdict without a version bump', () => { - expect(wireViolations(base, { ...base, corpus: [{ dir: 'clientToDaemon', json: '{"t":"shutdown"}', valid: false }] })).toEqual([ + it('flags a constant change unless both versions are bumped', () => { + const constants = { MAX_FRAME: 2 }; + expect(wireViolations(base, { ...base, constants })).toEqual(['constants changed']); + expect(wireViolations(base, { ...base, protocolVersions: versions(4, 3), constants })).toEqual(['constants changed']); + expect(wireViolations(base, { ...base, protocolVersions: versions(3, 4), constants })).toEqual(['constants changed']); + expect(wireViolations(base, { ...base, protocolVersions: versions(4, 4), constants })).toEqual([]); + }); + + it('flags a flipped verdict without its link bumped', () => { + const corpus = base.corpus.map((entry) => ({ ...entry, valid: !entry.valid })); + expect(wireViolations(base, { ...base, corpus })).toEqual([ + 'verdict changed: clientToDaemon {"t":"shutdown"}', + 'verdict changed: browserToHub {"t":"shutdown"}', + ]); + expect(wireViolations(base, { ...base, protocolVersions: versions(3, 4), corpus })).toEqual([ 'verdict changed: clientToDaemon {"t":"shutdown"}', ]); }); - it('flags a removed corpus entry', () => { - expect(wireViolations(base, { ...base, corpus: [] })).toEqual(['corpus entry removed: clientToDaemon {"t":"shutdown"}']); + it('flags a removed corpus entry without its link bumped', () => { + expect(wireViolations(base, { ...base, corpus: [] })).toEqual([ + 'corpus entry removed: clientToDaemon {"t":"shutdown"}', + 'corpus entry removed: browserToHub {"t":"shutdown"}', + ]); + expect(wireViolations(base, { ...base, protocolVersions: versions(4, 3), corpus: [] })).toEqual([ + 'corpus entry removed: browserToHub {"t":"shutdown"}', + ]); }); it('allows an added corpus entry', () => { @@ -196,25 +253,26 @@ describe('wire golden guard', () => { expect(wireViolations(base, { ...base, corpus: [...base.corpus, added] })).toEqual([]); }); - it('allows any change with a version bump', () => { - expect(wireViolations(base, { protocolVersion: 4, constants: {}, schemas: {}, corpus: [] })).toEqual([]); + it('allows any change with both versions bumped', () => { + expect(wireViolations(base, { protocolVersions: versions(4, 4), constants: {}, schemas: {}, corpus: [] })).toEqual([]); }); - it('flags a version decrease', () => { - expect(wireViolations(base, { ...base, protocolVersion: 2 })).toEqual(['PROTOCOL_VERSION decreased']); + it('flags a decrease of either version', () => { + expect(wireViolations(base, { ...base, protocolVersions: versions(2, 3) })).toEqual(['daemon protocol version decreased']); + expect(wireViolations(base, { ...base, protocolVersions: versions(3, 2) })).toEqual(['browser protocol version decreased']); }); }); describe('wire golden', () => { - // Regenerate with `vitest run -u` only together with a PROTOCOL_VERSION bump. - it('matches src/protocol/wire.golden.json unless PROTOCOL_VERSION was bumped', async () => { + // Regenerate with `vitest run -u` only together with the bumps wireViolations asks for. + it('matches src/protocol/wire.golden.json unless the changed links were bumped', async () => { const current = currentSnapshot(); expect(wireViolations(previousSchema.parse(wireJson), current)).toEqual([]); await expect(JSON.stringify(current, null, 2) + '\n').toMatchFileSnapshot('./wire.golden.json'); }); - it('is blessed for protocol version 5', () => { - expect(previousSchema.parse(wireJson).protocolVersion).toBe(5); + it('is blessed for daemon protocol 6 and browser protocol 8', () => { + expect(previousSchema.parse(wireJson).protocolVersions).toEqual({ daemon: 6, browser: 8 }); }); it('records every sample as valid in its own direction', () => { diff --git a/src/web/client/connection.ts b/src/web/client/connection.ts index 18b5610..f511bf3 100644 --- a/src/web/client/connection.ts +++ b/src/web/client/connection.ts @@ -4,12 +4,13 @@ import { decodeWsData, encodeMessage, encodeWsData, + BROWSER_PROTOCOL_VERSION, MAX_INPUT, - PROTOCOL_VERSION, ProtocolError, type DataFrame, type HostEntry, type MessageOf, + type Preset, } from '../../protocol/index.js'; import { reconnectDelay } from './backoff.js'; import { hostKey, TerminalMemory, type TerminalStorage } from './memory.js'; @@ -28,7 +29,7 @@ export type DaemonRequestBody = Unnumbered; /** A successful reply to a daemon request. */ -export type DaemonReply = Extract; +export type DaemonReply = Extract; type Reply = { ok: true; m: DaemonReply | Extract } | { ok: false; code: ErrorCode; message: string }; @@ -167,6 +168,21 @@ export class HubClient { await this.exchange(null, (req) => ({ t: 'removeRepo', req, host, repo })); } + /** Asks the hub to add `preset` to the configured presets; every page then receives them. */ + async addPreset(preset: Preset): Promise { + await this.exchange(null, (req) => ({ t: 'addPreset', req, preset })); + } + + /** Asks the hub to move the preset named `name` to position `to`; every page then receives the presets. */ + async movePreset(name: string, to: number): Promise { + await this.exchange(null, (req) => ({ t: 'movePreset', req, name, to })); + } + + /** Asks the hub to remove the preset named `name`; every page then receives the presets. */ + async removePreset(name: string): Promise { + await this.exchange(null, (req) => ({ t: 'removePreset', req, name })); + } + private async exchange(host: number | null, message: (req: number) => BrowserMessage): Promise['m']> { if (this.ws?.readyState !== WebSocket.OPEN) throw new Error('not connected to the hub'); const { req, reply } = this.pending.add(host); @@ -308,7 +324,7 @@ export class HubClient { } private hello(m: Extract): void { - const check = checkBundle({ protocol: PROTOCOL_VERSION, version: this.version }, m, sessionStorage.getItem(RELOADED_KEY)); + const check = checkBundle({ protocol: BROWSER_PROTOCOL_VERSION, version: this.version }, m, sessionStorage.getItem(RELOADED_KEY)); if (check === 'reload') { sessionStorage.setItem(RELOADED_KEY, m.instance); // A code session's token follows `hello`; the reloaded page needs it. @@ -320,7 +336,7 @@ export class HubClient { this.stop('outdated'); return; } - this.sendMessage({ t: 'hello', protocol: PROTOCOL_VERSION, version: this.version, instance: this.instance }); + this.sendMessage({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, version: this.version, instance: this.instance }); this.store.status.value = 'open'; } @@ -393,6 +409,7 @@ export class HubClient { switch (m.t) { case 'done': case 'reposDiscovered': + case 'worktreeAtRisk': this.pending.resolve(m.req, { ok: true, m }); break; case 'termCreated': diff --git a/src/web/client/index.ts b/src/web/client/index.ts index 0c280ad..5d62ad7 100644 --- a/src/web/client/index.ts +++ b/src/web/client/index.ts @@ -2,4 +2,4 @@ export { ACK_EVERY } from '../../protocol/index.js'; export { HubClient, RequestError, type DaemonEvent, type DaemonNotice, type DaemonReply, type DaemonRequestBody } from './connection.js'; export { Restorer, type RestoreDeps } from './restore.js'; export { hostKey, TerminalMemory, type Recalled, type RememberedTerminal, type TerminalStorage } from './memory.js'; -export { repoKey, worktreeLabel, type ConnectionStatus, type HubStore, type RepoState } from './store.js'; +export { repoKey, SHORT_HEAD, worktreeLabel, type ConnectionStatus, type HubStore, type RepoState } from './store.js'; diff --git a/src/web/client/store.ts b/src/web/client/store.ts index c4cb7fe..7d49154 100644 --- a/src/web/client/store.ts +++ b/src/web/client/store.ts @@ -16,7 +16,7 @@ export interface RepoState { error: { code: string; message: string } | null; } -const SHORT_HEAD = 7; +export const SHORT_HEAD = 7; const directoryName = (path: string): string => path.slice(path.lastIndexOf('/') + 1); @@ -173,6 +173,7 @@ export class HubStore { case 'error': case 'detached': case 'reposDiscovered': + case 'worktreeAtRisk': return; } } diff --git a/src/web/main/styles.css b/src/web/main/styles.css index f03bd35..1839816 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -1,12 +1,23 @@ :root { color-scheme: dark; - --bg: #1e1e1e; - --panel: #252526; - --border: #3c3c3c; - --text: #d4d4d4; - --muted: #858585; - --accent: #0e639c; - font-family: system-ui, sans-serif; + --bg: #101215; + --panel: #171a1f; + --panel-raised: #1d2128; + --hover: #242932; + --border: #2b3039; + --text: #e7e9ed; + --muted: #9299a6; + --accent: #4c8dff; + --accent-soft: #233b61; + --star: #f5c451; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; font-size: 13px; } @@ -35,11 +46,11 @@ body { .term-tabs { display: flex; align-items: center; - gap: 2px; + gap: 4px; background: var(--panel); border-bottom: 1px solid var(--border); - min-height: 30px; - padding: 0 4px; + min-height: 38px; + padding: 0 8px; } .repo-tabs .spacer, @@ -52,14 +63,33 @@ button { color: inherit; background: transparent; border: 1px solid transparent; - padding: 3px 8px; + padding: 5px 9px; cursor: pointer; + border-radius: 5px; + transition: + background-color 100ms ease, + border-color 100ms ease, + color 100ms ease; +} + +button:hover:not(:disabled) { + background: var(--hover); +} + +button:focus-visible, +input:focus-visible, +[role='tab']:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .repo-tab.active, -.term-tab.active { - background: var(--bg); - border-color: var(--border); +.term-tab.active, +.repo-tab.active:hover, +.term-tab.active:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; } .reconnecting { @@ -85,22 +115,127 @@ button { } .sidebar { - width: 240px; - overflow: auto; + flex: none; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; background: var(--panel); border-right: 1px solid var(--border); } +/* Over the sidebar's right border, so the terminal layer never covers it. */ +.sidebar-resizer { + flex: none; + position: relative; + z-index: 3; + width: 5px; + margin-left: -5px; + cursor: col-resize; + touch-action: none; + transition: background-color 100ms ease; +} + +.sidebar-resizer:hover, +.sidebar-resizer:focus-visible, +.sidebar-resizer:active { + background: var(--accent); + outline: none; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 38px; + padding: 0 10px 0 13px; + border-bottom: 1px solid var(--border); +} + +.sidebar-title { + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.worktree-list { + padding: 5px; + overflow: auto; +} + +.star-filter { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 11px; + cursor: pointer; + user-select: none; +} + +.star-filter:has(input:checked) { + color: var(--text); +} + +.star-filter input { + appearance: none; + position: relative; + width: 27px; + height: 15px; + margin: 0; + border: 1px solid #454c58; + border-radius: 999px; + background: #252a32; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.star-filter input::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 9px; + height: 9px; + border-radius: 50%; + background: #9da4b0; + transition: + transform 120ms ease, + background-color 120ms ease; +} + +.star-filter input:checked { + border-color: #a87c1f; + background: #6b501b; +} + +.star-filter input:checked::after { + background: var(--star); + transform: translateX(12px); +} + .worktree { display: flex; align-items: center; - gap: 4px; - padding: 3px 6px; + gap: 7px; + min-height: 29px; + padding: 1px 7px; + border-radius: 5px; white-space: nowrap; } +.worktree:hover { + background: var(--hover); +} + .worktree[aria-selected='true'] { - background: var(--accent); + background: var(--accent-soft); + color: #f4f7ff; + box-shadow: inset 2px 0 var(--accent); } .worktree.prunable, @@ -122,6 +257,38 @@ button { cursor: pointer; } +.worktree-star { + appearance: none; + display: grid; + place-items: center; + flex: none; + width: 18px; + height: 18px; + margin: 0; + border: 0; + background: transparent; + color: #626a76; + cursor: pointer; +} + +.worktree-star::before { + content: '☆'; + font-size: 17px; + line-height: 1; +} + +.worktree-star:hover { + color: var(--star); +} + +.worktree-star:checked { + color: var(--star); +} + +.worktree-star:checked::before { + content: '★'; +} + .terminal-pane { flex: 1; display: flex; @@ -138,7 +305,8 @@ button { display: flex; align-items: center; gap: 4px; - padding: 3px 4px 3px 8px; + padding: 4px 4px 4px 9px; + border-radius: 5px; border: 1px solid transparent; cursor: pointer; } @@ -226,10 +394,11 @@ button { .confirm-dialog, .add-repo-dialog { background: var(--panel); - border: 1px solid var(--border); - padding: 8px; + border: 1px solid #3a414c; + border-radius: 8px; + padding: 10px; min-width: 260px; - box-shadow: 0 4px 16px rgb(0 0 0 / 50%); + box-shadow: 0 12px 36px rgb(0 0 0 / 55%); } /* Undo the user agent's centring and colours of an open dialog. */ @@ -290,6 +459,72 @@ dialog.add-repo-dialog { color: var(--muted); } +.preset-row { + display: flex; + align-items: center; + gap: 2px; +} + +.preset-row .preset-option { + flex: 1; +} + +.preset-row[draggable='true'] { + cursor: grab; +} + +.preset-row.drop-before { + box-shadow: inset 0 2px 0 var(--accent); +} + +.preset-row.drop-after { + box-shadow: inset 0 -2px 0 var(--accent); +} + +.preset-remove { + padding: 2px 7px; + color: var(--muted); + opacity: 0.5; +} + +.preset-row:hover .preset-remove { + opacity: 1; +} + +.preset-remove:hover { + color: var(--text); +} + +.preset-add { + margin-top: 4px; + width: 100%; + color: var(--muted); + text-align: left; +} + +.preset-form { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.preset-form .error { + color: #f38ba8; +} + +.preset-form .buttons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.preset-form .buttons button { + border-color: var(--border); +} + .close-dialog .buttons, .confirm-dialog .buttons, .add-repo-dialog .buttons { @@ -342,7 +577,8 @@ dialog.add-repo-dialog { display: flex; align-items: center; gap: 4px; - padding: 3px 8px; + padding: 4px 8px; + border-radius: 5px; border: 1px solid transparent; cursor: pointer; } @@ -382,3 +618,25 @@ dialog.add-repo-dialog { .add-repo-dialog .error { color: #f38ba8; } + +.context-menu { + position: fixed; + z-index: 30; + display: flex; + flex-direction: column; + min-width: 160px; + padding: 4px; + background: var(--panel-raised); + border: 1px solid #3a414c; + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 50%); +} + +.context-menu button { + text-align: left; +} + +.context-menu button:disabled { + color: var(--muted); + cursor: default; +} diff --git a/src/web/terminals/manager.ts b/src/web/terminals/manager.ts index 6193bf9..cadb38c 100644 --- a/src/web/terminals/manager.ts +++ b/src/web/terminals/manager.ts @@ -50,6 +50,8 @@ interface Entry { needsAttach: boolean; /** Output arrived out of order; waiting for the snapshot of a new attach. */ resyncing: boolean; + /** The size last sent for the terminal since the page last gained the focus or connected; null for none. */ + sent: string | null; } const SCROLLBACK = 10_000; @@ -154,6 +156,13 @@ export class TerminalManager { this.unloaded = false; this.syncVisible(); }); + // Another page may have sized the PTYs meanwhile: the page in focus sets them to its own sizes. + window.addEventListener('focus', () => { + this.claimSizes(() => true); + }); + client.onHostConnected((host) => { + this.claimSizes((entry) => entry.host === host); + }); window.__wtdInspect = { screen: (host, termId) => this.screen(host, termId) }; } @@ -267,7 +276,7 @@ export class TerminalManager { const acks = new AckTracker((offset) => { this.client.notify(host, { t: 'ack', termId, offset }); }); - const entry: Entry = { host, termId, repo, term, container, fit, acks, shown: false, needsAttach: false, resyncing: false }; + const entry: Entry = { host, termId, repo, term, container, fit, acks, shown: false, needsAttach: false, resyncing: false, sent: null }; this.entries.set(key, entry); this.attach(host, termId); } @@ -393,15 +402,34 @@ export class TerminalManager { this.fitAll([entry]); } - /** Fits `entries` to their containers: every size is read before any terminal resizes, so layout runs once. */ + /** + * Fits `entries` to their containers, every size read before any terminal resizes so layout runs once, + * then, while the page has the focus, gives their PTYs those sizes. + */ private fitAll(entries: readonly Entry[]): void { const sizes = entries.map((entry) => ({ entry, size: entry.fit.proposeDimensions() })); for (const { entry, size } of sizes) { const { cols, rows } = entry.term; if (size === undefined || !(size.cols > 0 && size.rows > 0) || (size.cols === cols && size.rows === rows)) continue; entry.term.resize(size.cols, size.rows); - this.client.notify(entry.host, { t: 'resize', termId: entry.termId, cols: size.cols, rows: size.rows }); } + for (const entry of entries) this.sendSize(entry); + } + + /** Forgets the sizes sent for the entries `which` selects, then sends those of the shown ones. */ + private claimSizes(which: (entry: Entry) => boolean): void { + for (const entry of this.entries.values()) if (which(entry)) entry.sent = null; + for (const entry of this.shown) if (which(entry)) this.sendSize(entry); + } + + /** Gives the PTY of `entry` the terminal's size, only from the page in focus and only when it differs from the last one sent. */ + private sendSize(entry: Entry): void { + if (!document.hasFocus()) return; + const { cols, rows } = entry.term; + const size = `${String(cols)}x${String(rows)}`; + if (entry.sent === size) return; + entry.sent = size; + this.client.notify(entry.host, { t: 'resize', termId: entry.termId, cols, rows }); } private createWebgl(key: string): WebglAddon { diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index 86f8035..ff443f2 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -5,11 +5,21 @@ import { repoKey, type HubClient } from '../client/index.js'; import { termsOf, type Divider } from '../layout/index.js'; import { followArea, type TerminalManager } from '../terminals/index.js'; import { hasBanner, hostAction } from './hosts.js'; -import { aggregate, markTitle, type Aggregate } from './marks.js'; -import { pickerKey } from './picker.js'; -import type { SidebarEntry } from './sidebar.js'; +import { aggregate, markTitle, worktreeTitle, type Aggregate } from './marks.js'; +import { dropPosition, pickerKey, presetFromForm } from './picker.js'; +import { SIDEBAR_WIDTH, type SidebarEntry } from './sidebar.js'; import type { RepoTab } from './repo-tabs.js'; -import { PANE_HEADER, type AddRepoDialog, type CloseRequest, type Confirmation, type ShownPane, type ShownTab, type View } from './view.js'; +import { + hasPaneHeaders, + PANE_HEADER, + type AddRepoDialog, + type CloseRequest, + type Confirmation, + type ShownPane, + type ShownTab, + type View, + type WorktreeMenu, +} from './view.js'; export interface AppProps { client: HubClient; @@ -78,6 +88,21 @@ const ConfirmBody = ({ confirmation }: { confirmation: Confirmation }) => { if (confirmation.t === 'removeRepo') { return
Remove {confirmation.label} from the repo tabs?
; } + if (confirmation.t === 'removeWorktree') { + return ( + <> +
Delete the worktree {confirmation.label}?
+
    + {confirmation.reasons.map((reason) => ( +
  • + {reason} +
  • + ))} +
+

{confirmation.note}

+ + ); + } const { host, action, recalled } = confirmation; const title = action.kind === 'restart' ? `Restart the daemon on ${host.name}?` : `${action.label} wtd on ${host.name}?`; if (recalled === null) { @@ -107,7 +132,13 @@ const ConfirmBody = ({ confirmation }: { confirmation: Confirmation }) => { ); }; -/** The in-page confirmation of a host action or a repo removal; nothing is sent before it is confirmed. */ +/** The confirm button's label. */ +const confirmLabel = (confirmation: Confirmation): string => { + if (confirmation.t === 'removeRepo') return 'Remove'; + return confirmation.t === 'removeWorktree' ? 'Delete' : confirmation.action.label; +}; + +/** The in-page confirmation of a host action, a repo removal or a worktree deletion; nothing is sent before it is confirmed. */ const ConfirmDialog = ({ view, confirmation }: { view: View; confirmation: Confirmation }) => { const dialog = useRef(null); useLayoutEffect(() => { @@ -132,7 +163,7 @@ const ConfirmDialog = ({ view, confirmation }: { view: View; confirmation: Confi
+
+ ); +}; + const TermTab = ({ tab, active, view }: { tab: ShownTab; active: boolean; view: View }) => (
( ); -const PaneFrame = ({ pane, focused, view }: { pane: ShownPane; focused: boolean; view: View }) => ( +/** A pane of the shown tab; only in a split does it have a header naming it, with its mark and close button. */ +const PaneFrame = ({ pane, focused, header, view }: { pane: ShownPane; focused: boolean; header: boolean; view: View }) => (
-
{ - view.focusPane(pane.termId); - }} - > - - {pane.terminal?.preset ?? 'terminal'} {pane.termId} - {pane.terminal?.exit != null ? ' (exited)' : ''} - - - -
+ + {pane.terminal?.preset ?? 'terminal'} {pane.termId} + {pane.terminal?.exit != null ? ' (exited)' : ''} + + + +
+ )}
); @@ -449,6 +536,8 @@ const PresetList = ({ move, choose, cancel, + remove, + reorder, focusFirst, }: { presets: readonly Preset[]; @@ -456,17 +545,40 @@ const PresetList = ({ move: (index: number) => void; choose: (index: number) => void; cancel: () => void; + /** Removes the named preset; offered while more than one is left. */ + remove: (name: string) => void; + /** Moves the named preset to position `to`, by dragging or Alt+Up/Down; resolves with whether the hub did. */ + reorder: (name: string, to: number) => Promise; /** Whether the list takes the keyboard focus when shown; it follows the highlighted option once it has it. */ focusFirst: boolean; }) => { const list = useRef(null); const shown = useRef(false); - // Before the browser handles the next key, so no key meant for the list reaches a terminal. + /** The dragged preset's position and the gap it would be dropped into (0 = before the first). */ + const [drag, setDrag] = useState<{ from: number; slot: number | null } | null>(null); + const order = presets.map((p) => p.name).join('\n'); + // Before the browser handles the next key, so no key meant for the list reaches a terminal; again after a reorder. useLayoutEffect(() => { const options = list.current?.querySelectorAll('[data-testid="preset-option"]'); if (focusFirst || shown.current) options?.[index]?.focus(); shown.current = true; - }, [index, focusFirst]); + }, [index, focusFirst, order]); + /** The order a keyboard move was sent from; further moves wait until the new order arrives or the move fails. */ + const movingFrom = useRef(null); + const moveBy = (step: number): void => { + const preset = presets[index]; + const to = index + step; + if (preset === undefined || to < 0 || to >= presets.length || movingFrom.current === order) return; + movingFrom.current = order; + move(to); + void reorder(preset.name, to).then((moved) => { + if (!moved) movingFrom.current = null; + }); + }; + const dropClass = (i: number): string => { + if (drag?.slot === i) return ' drop-before'; + return drag?.slot === presets.length && i === presets.length - 1 ? ' drop-after' : ''; + }; return (
{ + if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + event.preventDefault(); + event.stopPropagation(); + moveBy(event.key === 'ArrowUp' ? -1 : 1); + return; + } const key = pickerKey(event.key, index, presets.length); if (key === null) return; event.preventDefault(); @@ -484,29 +602,171 @@ const PresetList = ({ }} > {presets.map((preset, i) => ( - + + {presets.length > 1 && ( + + )} +
))} ); }; +/** "+ Add preset", which opens a form for a new preset's name and command; Escape closes the form. */ +const PresetAdder = ({ view, presets }: { view: View; presets: readonly Preset[] }) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [command, setCommand] = useState(''); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const nameInput = useRef(null); + useLayoutEffect(() => { + if (open) nameInput.current?.focus(); + }, [open]); + const close = (): void => { + setOpen(false); + setName(''); + setCommand(''); + setError(null); + }; + if (!open) { + return ( + + ); + } + return ( +
{ + event.preventDefault(); + const result = presetFromForm(name, command, presets); + if ('error' in result) { + setError(result.error); + return; + } + setSending(true); + view + .addPreset(result.preset) + .then(close, (error_: unknown) => { + setError(error_ instanceof Error ? error_.message : String(error_)); + }) + .finally(() => { + setSending(false); + }); + }} + onKeyDown={(event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + close(); + }} + > + { + setName(event.currentTarget.value); + }} + /> + { + setCommand(event.currentTarget.value); + }} + /> + {error !== null && ( +
+ {error} +
+ )} +
+ + +
+
+ ); +}; + /** The picker for a new terminal; any pointer press outside it cancels it. */ const PresetPicker = ({ view, presets }: { view: View; presets: readonly Preset[] }) => { const box = useRef(null); @@ -528,7 +788,7 @@ const PresetPicker = ({ view, presets }: { view: View; presets: readonly Preset[
{picker.context.op.t === 'newTab' ? 'New tab' : `Split ${picker.context.op.dir}`}
{ view.movePicker(i); @@ -539,7 +799,12 @@ const PresetPicker = ({ view, presets }: { view: View; presets: readonly Preset[ cancel={() => { view.cancelPicker(); }} + remove={(name) => { + view.removePreset(name); + }} + reorder={(name, to) => view.movePreset(name, to)} /> + ); }; @@ -560,7 +825,12 @@ const PresetChoices = ({ view, presets }: { view: View; presets: readonly Preset if (preset !== undefined) view.choose(preset); }} cancel={() => undefined} + remove={(name) => { + view.removePreset(name); + }} + reorder={(name, to) => view.movePreset(name, to)} /> + ); }; @@ -639,7 +909,13 @@ const TerminalArea = ({ client, manager, view }: AppProps) => { return (
{view.panes.value.map((pane) => ( - + ))} {view.dividers.value.map((divider) => ( @@ -650,6 +926,46 @@ const TerminalArea = ({ client, manager, view }: AppProps) => { ); }; +/** Sidebar width change per arrow key, in pixels. */ +const SIDEBAR_KEY_STEPS: Readonly> = { ArrowLeft: -16, ArrowRight: 16 }; + +/** The draggable edge between the sidebar and the terminals; arrow keys move it too. */ +const SidebarResizer = ({ view }: { view: View }) => { + const width = view.sidebarWidth.value; + return ( + {client.store.hosts.value.filter(hasBanner).map((h) => ( diff --git a/src/web/ui/marks.test.ts b/src/web/ui/marks.test.ts index 59285e6..33bfb74 100644 --- a/src/web/ui/marks.test.ts +++ b/src/web/ui/marks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Terminal } from '../../protocol/index.js'; -import { aggregate, mark, MARK_ORDER, markTitle, type Mark } from './marks.js'; +import { aggregate, mark, MARK_ORDER, markTitle, terminalStatus, worktreeTitle, type Mark } from './marks.js'; let nextId = 1; @@ -88,3 +88,30 @@ describe('markTitle', () => { expect(markTitle({})).toBe(''); }); }); + +describe('terminalStatus', () => { + it.each([ + ['waiting for input', { state: 'input' }], + ['running', { state: 'working' }], + ['running', { state: 'working', unseen: true }], + ['done', { state: 'idle', unseen: true }], + ['idle', { state: 'idle', unseen: false }], + ['exited', { exit: clean }], + ['exited with code 1', { exit: failedCode }], + ['killed by SIGKILL', { exit: failedSignal }], + ] as const)('says %s', (status, fields) => { + expect(terminalStatus(terminal(fields))).toBe(status); + }); +}); + +describe('worktreeTitle', () => { + it('lists each terminal with its preset, id and status under the path', () => { + const agent = terminal({ termId: 3, preset: 'claude', state: 'input' }); + const shell = terminal({ termId: 4, preset: 'shell', state: 'working' }); + expect(worktreeTitle('/r/wt', [agent, shell])).toBe('/r/wt\nclaude 3: waiting for input\nshell 4: running'); + }); + + it('says so when the worktree has no terminals', () => { + expect(worktreeTitle('/r/wt', [])).toBe('/r/wt\nno terminals'); + }); +}); diff --git a/src/web/ui/marks.ts b/src/web/ui/marks.ts index 9748ad7..72730e0 100644 --- a/src/web/ui/marks.ts +++ b/src/web/ui/marks.ts @@ -35,3 +35,21 @@ export const aggregate = (terminals: readonly Terminal[], level: MarkLevel): Agg /** One line `: ` per mark present, in rank order. */ export const markTitle = (counts: Aggregate['counts']): string => MARK_ORDER.flatMap((m) => (counts[m] === undefined ? [] : [`${m}: ${String(counts[m])}`])).join('\n'); + +/** What a terminal is doing, in words: waiting for input, running, done, idle, or how it exited. */ +export const terminalStatus = (t: Terminal): string => { + if (t.exit !== null) { + if (t.exit.signal !== null) return `killed by ${t.exit.signal}`; + return t.exit.code === 0 ? 'exited' : `exited with code ${String(t.exit.code)}`; + } + if (t.state === 'input') return 'waiting for input'; + if (t.state === 'working') return 'running'; + return t.unseen ? 'done' : 'idle'; +}; + +/** A worktree's hover text: its path, then one line ` : ` per terminal. */ +export const worktreeTitle = (path: string, terminals: readonly Terminal[]): string => + [ + path, + ...(terminals.length === 0 ? ['no terminals'] : terminals.map((t) => `${t.preset} ${String(t.termId)}: ${terminalStatus(t)}`)), + ].join('\n'); diff --git a/src/web/ui/picker.test.ts b/src/web/ui/picker.test.ts index 3bf5310..58f68f9 100644 --- a/src/web/ui/picker.test.ts +++ b/src/web/ui/picker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Layout } from '../../protocol/index.js'; -import { pickerKey, pickerValid, type PickerContext, type PickerWorld } from './picker.js'; +import { dropPosition, pickerKey, pickerValid, presetFromForm, type PickerContext, type PickerWorld } from './picker.js'; type Pane = Layout['tabs'][number]['root']; @@ -108,3 +108,46 @@ describe('pickerKey', () => { expect(pickerKey(key, 0, 3)).toBeNull(); }); }); + +describe('presetFromForm', () => { + const presets = [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + ]; + + it('trims the name and the command', () => { + expect(presetFromForm(' htop ', ' htop -d 5 ', presets)).toEqual({ preset: { name: 'htop', command: 'htop -d 5' } }); + }); + + it('makes an empty or blank command the login shell', () => { + expect(presetFromForm('zsh', '', presets)).toEqual({ preset: { name: 'zsh', command: null } }); + expect(presetFromForm('zsh', ' ', presets)).toEqual({ preset: { name: 'zsh', command: null } }); + }); + + it.each([ + ['an empty name', '', 'x', 'Give the preset a name'], + ['a blank name', ' \t', 'x', 'Give the preset a name'], + ['a taken name', ' claude ', 'x', 'A preset named claude exists'], + ['a 65-character name', 'n'.repeat(65), 'x', 'Names are at most 64 characters'], + ['a 4097-character command', 'n', 'c'.repeat(4097), 'Commands are at most 4096 characters'], + ])('refuses %s', (_case, name, command, error) => { + expect(presetFromForm(name, command, presets)).toEqual({ error }); + }); + + it('accepts a name differing from a taken one by case', () => { + expect(presetFromForm('Claude', 'claude', presets)).toEqual({ preset: { name: 'Claude', command: 'claude' } }); + }); +}); + +describe('dropPosition', () => { + it.each([ + ['before the first preset', 2, 0, 0], + ['into its own gap above', 2, 2, 2], + ['into its own gap below', 2, 3, 2], + ['after the last of four', 0, 4, 3], + ['between two later presets', 0, 2, 1], + ['between two earlier presets', 3, 1, 1], + ])('places a preset dropped %s', (_case, from, slot, position) => { + expect(dropPosition(from, slot)).toBe(position); + }); +}); diff --git a/src/web/ui/picker.ts b/src/web/ui/picker.ts index 1be97b4..485f0b1 100644 --- a/src/web/ui/picker.ts +++ b/src/web/ui/picker.ts @@ -1,4 +1,4 @@ -import type { HostEntry, Layout } from '../../protocol/index.js'; +import type { HostEntry, Layout, Preset } from '../../protocol/index.js'; import { canAddTab, canSplit, type SplitDir } from '../layout/index.js'; export type PickerOp = { t: 'newTab' } | { t: 'split'; dir: SplitDir; target: number }; @@ -52,3 +52,20 @@ export const pickerKey = (key: string, index: number, count: number): PickerKey return null; } }; + +const MAX_PRESET_NAME = 64; +const MAX_PRESET_COMMAND = 4096; + +/** The preset the add form describes, trimmed, an empty command meaning the login shell; or why it cannot be added. */ +export const presetFromForm = (name: string, command: string, presets: readonly Preset[]): { preset: Preset } | { error: string } => { + const trimmedName = name.trim(); + const trimmedCommand = command.trim(); + if (trimmedName === '') return { error: 'Give the preset a name' }; + if (trimmedName.length > MAX_PRESET_NAME) return { error: `Names are at most ${String(MAX_PRESET_NAME)} characters` }; + if (presets.some((p) => p.name === trimmedName)) return { error: `A preset named ${trimmedName} exists` }; + if (trimmedCommand.length > MAX_PRESET_COMMAND) return { error: `Commands are at most ${String(MAX_PRESET_COMMAND)} characters` }; + return { preset: { name: trimmedName, command: trimmedCommand === '' ? null : trimmedCommand } }; +}; + +/** The position a preset dragged from `from` takes when dropped into gap `slot` (0 = before the first preset). */ +export const dropPosition = (from: number, slot: number): number => (slot > from ? slot - 1 : slot); diff --git a/src/web/ui/sidebar.test.ts b/src/web/ui/sidebar.test.ts index bc627c8..2d75ded 100644 --- a/src/web/ui/sidebar.test.ts +++ b/src/web/ui/sidebar.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from 'vitest'; import type { Terminal, Worktree } from '../../protocol/index.js'; -import { defaultWorktree, sidebarEntries, visibleWorktrees, worktreeLabel, type SidebarEntry } from './sidebar.js'; +import { + clampSidebarWidth, + defaultWorktree, + filterOf, + keptNote, + parseSidebarWidth, + removalReasons, + SIDEBAR_WIDTH, + sidebarEntries, + visibleWorktrees, + withFilter, + worktreeLabel, + type SidebarEntry, +} from './sidebar.js'; const REPO = '/home/u/repo'; const HEAD = '0123456789abcdef0123456789abcdef01234567'; @@ -140,3 +153,90 @@ describe('defaultWorktree', () => { expect(defaultWorktree([], 'checked')).toBeNull(); }); }); + +describe('clampSidebarWidth', () => { + it.each([ + ['keeps a width within bounds', 300, 300], + ['rounds to a whole pixel', 300.6, 301], + ['raises a width below the minimum', 20, SIDEBAR_WIDTH.min], + ['lowers a width above the maximum', 5000, SIDEBAR_WIDTH.max], + ['raises a negative width', -50, SIDEBAR_WIDTH.min], + ])('%s', (_name, width, expected) => { + expect(clampSidebarWidth(width)).toBe(expected); + }); +}); + +describe('parseSidebarWidth', () => { + it.each([ + ['a stored width', '320', 320], + ['the default when nothing is stored', null, SIDEBAR_WIDTH.initial], + ['the default for an empty value', '', SIDEBAR_WIDTH.initial], + ['the default for a non-number', 'wide', SIDEBAR_WIDTH.initial], + ['the default for a fraction', '300.5', SIDEBAR_WIDTH.initial], + ['the default for a negative number', '-300', SIDEBAR_WIDTH.initial], + ['the default for an overlong number', '1e999', SIDEBAR_WIDTH.initial], + ['a stored width held within bounds', '9999', SIDEBAR_WIDTH.max], + ])('reads %s', (_name, stored, expected) => { + expect(parseSidebarWidth(stored)).toBe(expected); + }); +}); + +describe('repo filters', () => { + it('shows all in a repo without an entry or with an unknown value', () => { + expect(filterOf({}, '0:/a')).toBe('all'); + expect(filterOf({ '0:/a': 'odd' }, '0:/a')).toBe('all'); + }); + + it('sets one repo to checked without touching the others', () => { + const filters = withFilter({ '0:/b': 'checked' }, '0:/a', 'checked'); + expect(filters).toEqual({ '0:/a': 'checked', '0:/b': 'checked' }); + expect(filterOf(filters, '0:/a')).toBe('checked'); + expect(filterOf(filters, '1:/a')).toBe('all'); + }); + + it('drops the entry of a repo set back to all', () => { + expect(withFilter({ '0:/a': 'checked', '0:/b': 'checked' }, '0:/a', 'all')).toEqual({ '0:/b': 'checked' }); + }); +}); + +describe('removalReasons', () => { + const safe = { base: 'origin/main', ahead: 0, changes: 0, running: [] }; + + it('gives no reason for a merged, clean worktree without running terminals', () => { + expect(removalReasons(safe)).toEqual([]); + }); + + it('names every reason that applies, in order', () => { + expect(removalReasons({ base: 'origin/main', ahead: 3, changes: 2, running: ['claude 3', 'shell 4'] })).toEqual([ + 'It has 3 commits not in origin/main.', + 'It has 2 uncommitted changes, untracked files included.', + 'Running terminals will be closed: claude 3, shell 4.', + ]); + }); + + it('uses the singular for one commit or change', () => { + expect(removalReasons({ ...safe, ahead: 1, changes: 1 })).toEqual([ + 'It has 1 commit not in origin/main.', + 'It has 1 uncommitted change, untracked files included.', + ]); + }); + + it('says when there is no origin/main to compare with', () => { + expect(removalReasons({ ...safe, base: null, ahead: null })).toEqual(['There is no origin/main to check that its commits are merged.']); + }); +}); + +describe('keptNote', () => { + const HEAD = '0123456789abcdef0123456789abcdef01234567'; + + it('says the branch is kept, or that a detached worktree leaves its commits unreachable, naming its head', () => { + expect(keptNote({ detached: false, head: HEAD }, 'origin/main')).toBe('Its branch is kept.'); + expect(keptNote({ detached: true, head: HEAD }, 'origin/main')).toBe( + 'It has no branch: once it is deleted, its commits not in origin/main become unreachable; its head is 0123456.', + ); + }); + + it('names the base the daemon compared with', () => { + expect(keptNote({ detached: true, head: HEAD }, 'origin/master')).toContain('its commits not in origin/master become'); + }); +}); diff --git a/src/web/ui/sidebar.ts b/src/web/ui/sidebar.ts index 3e23081..3010a2e 100644 --- a/src/web/ui/sidebar.ts +++ b/src/web/ui/sidebar.ts @@ -1,5 +1,5 @@ import type { Terminal, Worktree } from '../../protocol/index.js'; -import { worktreeLabel } from '../client/index.js'; +import { SHORT_HEAD, worktreeLabel } from '../client/index.js'; export { worktreeLabel }; @@ -15,6 +15,28 @@ export interface SidebarEntry { export type WorktreeFilter = 'all' | 'checked'; +/** Each repo's filter by repo key; a repo without an entry shows all. */ +export type RepoFilters = Readonly>; + +/** The filter of the repo `key`. */ +export const filterOf = (filters: RepoFilters, key: string): WorktreeFilter => (filters[key] === 'checked' ? 'checked' : 'all'); + +/** `filters` with the repo `key` set to `filter`; showing all drops its entry. */ +export const withFilter = (filters: RepoFilters, key: string, filter: WorktreeFilter): RepoFilters => { + const rest = Object.fromEntries(Object.entries(filters).filter(([k]) => k !== key)); + return filter === 'all' ? rest : { ...rest, [key]: filter }; +}; + +/** Sidebar width bounds and default, in pixels. */ +export const SIDEBAR_WIDTH = { min: 160, max: 640, initial: 260 } as const; + +/** `width` rounded and held within the sidebar's bounds. */ +export const clampSidebarWidth = (width: number): number => Math.round(Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min, width))); + +/** A stored sidebar width, or the default when missing or malformed. */ +export const parseSidebarWidth = (stored: string | null): number => + stored !== null && /^\d{1,5}$/.test(stored) ? clampSidebarWidth(Number(stored)) : SIDEBAR_WIDTH.initial; + const directoryName = (path: string): string => path.slice(path.lastIndexOf('/') + 1); /** Listed worktrees in the daemon's order without bare ones, then vanished worktrees that still have terminals. */ @@ -43,3 +65,32 @@ export const visibleWorktrees = (entries: readonly SidebarEntry[], filter: Workt /** The worktree selected without a stored selection: the first the filter shows, else the first entry. */ export const defaultWorktree = (entries: readonly SidebarEntry[], filter: WorktreeFilter): string | null => (visibleWorktrees(entries, filter, null)[0] ?? entries[0])?.path ?? null; + +/** What the daemon reported deleting a worktree would lose. */ +export interface RemovalRisks { + base: string | null; + ahead: number | null; + changes: number; + /** Labels of the running terminals that would be closed. */ + running: readonly string[]; +} + +const plural = (n: number, one: string, many: string): string => `${String(n)} ${n === 1 ? one : many}`; + +/** Every reason to confirm deleting a worktree, one sentence each. */ +export const removalReasons = (risks: RemovalRisks): string[] => { + const reasons: string[] = []; + if (risks.ahead === null) reasons.push('There is no origin/main to check that its commits are merged.'); + else if (risks.ahead > 0) reasons.push(`It has ${plural(risks.ahead, 'commit', 'commits')} not in ${risks.base ?? 'origin/main'}.`); + if (risks.changes > 0) + reasons.push(`It has ${plural(risks.changes, 'uncommitted change', 'uncommitted changes')}, untracked files included.`); + if (risks.running.length > 0) reasons.push(`Running terminals will be closed: ${risks.running.join(', ')}.`); + return reasons; +}; + +/** What happens to a deleted worktree's commits: kept on its branch, or, detached, unreachable. */ +export const keptNote = (worktree: Pick, base: string | null): string => { + if (!worktree.detached) return 'Its branch is kept.'; + const lost = `It has no branch: once it is deleted, its commits not in ${base ?? 'origin/main'} become unreachable`; + return worktree.head === null ? `${lost}.` : `${lost}; its head is ${worktree.head.slice(0, SHORT_HEAD)}.`; +}; diff --git a/src/web/ui/view.ts b/src/web/ui/view.ts index 603bdda..cbc3ba5 100644 --- a/src/web/ui/view.ts +++ b/src/web/ui/view.ts @@ -28,7 +28,20 @@ import { hostAction, Latest, offeredRepos, type HostAction } from './hosts.js'; import { keymap, type Action } from './keymap.js'; import { pickerValid, type PickerContext, type PickerOp, type PickerWorld } from './picker.js'; import { repoTabs, type RepoTab } from './repo-tabs.js'; -import { defaultWorktree, sidebarEntries, visibleWorktrees, type SidebarEntry, type WorktreeFilter } from './sidebar.js'; +import { + clampSidebarWidth, + defaultWorktree, + filterOf, + keptNote, + parseSidebarWidth, + removalReasons, + sidebarEntries, + visibleWorktrees, + withFilter, + type RepoFilters, + type SidebarEntry, + type WorktreeFilter, +} from './sidebar.js'; /** Performance marks around a worktree switch (design D13). */ export const SWITCH_START = 'wtd:switch-start'; @@ -42,13 +55,15 @@ export const PANE_HEADER = 20; const REPO_KEY = 'wtd.repo'; const WORKTREES_KEY = 'wtd.worktrees'; -const FILTER_KEY = 'wtd.filter'; +const FILTERS_KEY = 'wtd.filters'; +const SIDEBAR_WIDTH_KEY = 'wtd.sidebarWidth'; const load = (key: string): string | null => localStorage.getItem(key); -const loadSelections = (): Readonly> => { +/** The string-valued entries of the object stored at `key`. */ +const loadRecord = (key: string): Readonly> => { try { - const value: unknown = JSON.parse(load(WORKTREES_KEY) ?? '{}'); + const value: unknown = JSON.parse(load(key) ?? '{}'); if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); } catch (error) { @@ -74,13 +89,14 @@ const message = (error: unknown): string => (error instanceof Error ? error.mess /** An error as the add-repo dialog shows it: the code of a refused request, then its message. */ const describe = (error: unknown): string => (error instanceof RequestError ? `${error.code}: ${error.message}` : message(error)); -/** The box of a pane's terminal: the pane below its header. */ -export const terminalBoxOf = (rect: Rect): Box => ({ - left: rect.left, - top: rect.top + PANE_HEADER, - width: rect.width, - height: Math.max(0, rect.height - PANE_HEADER), -}); +/** Whether panes show headers: only in a split, where they tell the panes apart. */ +export const hasPaneHeaders = (paneCount: number): boolean => paneCount > 1; + +/** The box of a pane's terminal: the pane below its header, if it has one. */ +export const terminalBoxOf = (rect: Rect, header: boolean): Box => { + const height = header ? PANE_HEADER : 0; + return { left: rect.left, top: rect.top + height, width: rect.width, height: Math.max(0, rect.height - height) }; +}; /** * The focused pane of `tab`: the one last chosen, else the one that took its place, else the first. @@ -142,7 +158,15 @@ interface Picker { /** What the in-page confirmation asks about. */ export type Confirmation = | { t: 'host'; host: HostEntry; action: HostAction; recalled: Recalled | null } - | { t: 'removeRepo'; host: number; repo: string; label: string }; + | { t: 'removeRepo'; host: number; repo: string; label: string } + | { t: 'removeWorktree'; host: number; worktree: string; label: string; reasons: readonly string[]; note: string }; + +/** The worktree context menu, at its viewport position. */ +export interface WorktreeMenu { + worktree: string; + x: number; + y: number; +} /** The add-repo dialog. */ export interface AddRepoDialog { @@ -159,8 +183,9 @@ export interface AddRepoDialog { /** The page's selection, stored per browser, and what it shows; keeps the terminal manager in step. */ export class View { readonly selectedRepo = signal(load(REPO_KEY)); - readonly selections = signal>>(loadSelections()); - readonly filter = signal(load(FILTER_KEY) === 'checked' ? 'checked' : 'all'); + readonly selections = signal>>(loadRecord(WORKTREES_KEY)); + readonly filters = signal(loadRecord(FILTERS_KEY)); + readonly sidebarWidth = signal(parseSidebarWidth(load(SIDEBAR_WIDTH_KEY))); /** The terminal area in client coordinates. */ readonly area = signal({ left: 0, top: 0, width: 0, height: 0 }); readonly picker = signal(null); @@ -168,6 +193,7 @@ export class View { /** A problem of the page itself, such as a failed copy. */ readonly notice = signal(null); readonly confirmation = signal(null); + readonly worktreeMenu = signal(null); readonly addRepo = signal(null); private readonly drag = signal(null); /** The focused pane per tab key, with the tab's panes when it was last resolved; replaced when the user chooses a pane. */ @@ -203,6 +229,11 @@ export class View { const tab = this.tab.value; return tab === null ? null : (this.client.store.repos.value.get(repoKey(tab.host, tab.repo)) ?? null); }); + /** The selected repo's filter. */ + readonly filter = computed(() => { + const tab = this.tab.value; + return tab === null ? 'all' : filterOf(this.filters.value, repoKey(tab.host, tab.repo)); + }); readonly entries = computed(() => { const repo = this.repo.value; return repo === null ? [] : sidebarEntries(repo); @@ -297,7 +328,12 @@ export class View { }); /** Whether a picker or dialog owns the keys. */ readonly modal = computed( - () => this.picker.value !== null || this.closing.value !== null || this.confirmation.value !== null || this.addRepo.value !== null, + () => + this.picker.value !== null || + this.closing.value !== null || + this.confirmation.value !== null || + this.addRepo.value !== null || + this.worktreeMenu.value !== null, ); private shownKey = ''; @@ -348,9 +384,23 @@ export class View { markAfterPaint(SWITCH_END); } + /** Sets the selected repo's filter. */ setFilter(filter: WorktreeFilter): void { - this.filter.value = filter; - localStorage.setItem(FILTER_KEY, filter); + const tab = this.tab.value; + if (tab === null) return; + const filters = withFilter(this.filters.value, repoKey(tab.host, tab.repo), filter); + this.filters.value = filters; + localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); + } + + /** Sets the sidebar width, within its bounds, without storing it. */ + resizeSidebar(width: number): void { + this.sidebarWidth.value = clampSidebarWidth(width); + } + + /** Stores the sidebar width for later sessions. */ + storeSidebarWidth(): void { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(this.sidebarWidth.value)); } /** Shows the tab of `termId` at once, then stores it as the worktree's active tab. */ @@ -418,6 +468,29 @@ export class View { this.picker.value = null; } + /** Adds a preset; rejects with the hub's refusal. */ + addPreset(preset: Preset): Promise { + return this.client.addPreset(preset); + } + + /** Moves the preset named `name` to position `to`, reporting a failure on the page; resolves with whether the hub did. */ + movePreset(name: string, to: number): Promise { + return this.client.movePreset(name, to).then( + () => true, + (error: unknown) => { + this.notice.value = `Moving preset ${name} failed: ${message(error)}`; + return false; + }, + ); + } + + /** Removes the preset named `name`, reporting a refusal on the page. */ + removePreset(name: string): void { + this.client.removePreset(name).catch((error: unknown) => { + this.notice.value = `Removing preset ${name} failed: ${message(error)}`; + }); + } + /** Runs `preset` as a new tab of the selected worktree, which has no terminals. */ choose(preset: Preset): void { const tab = this.tab.value; @@ -466,11 +539,70 @@ export class View { this.confirmation.value = null; } + openWorktreeMenu(worktree: string, x: number, y: number): void { + this.worktreeMenu.value = { worktree, x, y }; + } + + closeWorktreeMenu(): void { + this.worktreeMenu.value = null; + } + + /** Why `worktree` of the selected repo cannot be deleted now, or null when it can. */ + deleteBlocker(worktree: string): string | null { + const entry = this.entries.value.find((e) => e.path === worktree); + const listed = this.repo.value?.worktrees.find((w) => w.path === worktree); + if (entry === undefined || entry.gone || listed === undefined) return 'It is no longer a worktree'; + if (listed.main) return 'The main worktree cannot be deleted'; + if (listed.locked) return 'It is locked'; + if (this.host.value?.status !== 'connected') return 'Its host is not connected'; + return null; + } + + /** Deletes `worktree` of the selected repo; when the daemon reports what that would lose, asks to confirm first. */ + deleteWorktree(worktree: string): void { + this.worktreeMenu.value = null; + const tab = this.tab.value; + const repo = this.repo.value; + if (tab === null || repo === null || this.deleteBlocker(worktree) !== null) return; + const label = this.entries.value.find((e) => e.path === worktree)?.label ?? worktree; + const { host } = tab; + this.client + .request(host, { t: 'removeWorktree', worktree, force: false }) + .then((reply) => { + if (reply.t !== 'worktreeAtRisk') return; + const terminals = this.client.store.repo(host, tab.repo)?.terminals ?? []; + const running = reply.running.map((id) => { + const term = terminals.find((t) => t.termId === id); + return term === undefined ? `terminal ${String(id)}` : `${term.preset} ${String(id)}`; + }); + const listed = repo.worktrees.find((w) => w.path === worktree) ?? { detached: false, head: null }; + this.confirmation.value = { + t: 'removeWorktree', + host, + worktree, + label, + reasons: removalReasons({ ...reply, running }), + note: keptNote(listed, reply.base), + }; + }) + .catch((error: unknown) => { + this.notice.value = `Deleting ${label} failed: ${message(error)}`; + }); + } + /** Runs the confirmed action, showing its failure on the page. */ confirm(): void { const confirmation = this.confirmation.value; this.confirmation.value = null; if (confirmation === null) return; + if (confirmation.t === 'removeWorktree') { + this.client + .request(confirmation.host, { t: 'removeWorktree', worktree: confirmation.worktree, force: true }) + .catch((error: unknown) => { + this.notice.value = `Deleting ${confirmation.label} failed: ${message(error)}`; + }); + return; + } if (confirmation.t === 'removeRepo') { this.client.removeRepo(confirmation.host, confirmation.repo).catch((error: unknown) => { this.notice.value = @@ -732,11 +864,11 @@ export class View { /** The box the terminal `op` creates will have. */ private newPaneBox(op: PickerOp): Box { const area = this.localArea.value; - if (op.t === 'newTab') return terminalBoxOf(area); + if (op.t === 'newTab') return terminalBoxOf(area, false); const layout = split(this.layout.value, op.target, op.dir, 0); const root = layout.tabs[layout.active]?.root; const rect = root === undefined ? undefined : paneRects(root, area).find((p) => p.termId === 0)?.rect; - return terminalBoxOf(rect ?? area); + return terminalBoxOf(rect ?? area, rect !== undefined); } /** Adds the created terminal `termId` to the latest layout, as `op` asked if still possible, and focuses it. */ @@ -799,7 +931,7 @@ export class View { const focused = this.focused.value; const modal = this.modal.value; if ((status !== 'open' && status !== 'reconnecting') || tab === null || worktree === null) { - this.showOnly(null, []); + this.showOnly(null, [], false); return; } if (this.host.value?.status === 'connected' && status === 'open') { @@ -808,20 +940,20 @@ export class View { } } const shown = panes.filter((p) => this.manager.has(tab.host, p.termId)); - this.showOnly(tab.host, shown, this.drag.value !== null); + this.showOnly(tab.host, shown, hasPaneHeaders(panes.length), this.drag.value !== null); const focusKey = `${String(tab.host)}:${shown.map((p) => p.termId).join(',')}:${String(focused)}:${String(modal)}`; if (focusKey === this.focusKey) return; this.focusKey = focusKey; if (!modal && focused !== null && this.manager.has(tab.host, focused)) this.manager.focus(tab.host, focused); } - private showOnly(host: number | null, panes: readonly PaneRect[], dragging = false): void { - const key = `${String(host)}:${JSON.stringify(panes.map((p) => [p.termId, p.rect]))}`; + private showOnly(host: number | null, panes: readonly PaneRect[], headers: boolean, dragging = false): void { + const key = `${String(host)}:${String(headers)}:${JSON.stringify(panes.map((p) => [p.termId, p.rect]))}`; if (key === this.shownKey) return; this.shownKey = key; this.manager.show( host, - panes.map((p) => ({ termId: p.termId, box: terminalBoxOf(p.rect) })), + panes.map((p) => ({ termId: p.termId, box: terminalBoxOf(p.rect, headers) })), dragging, ); } diff --git a/test/e2e/add-repo.spec.ts b/test/e2e/add-repo.spec.ts index e1582df..1dc63c6 100644 --- a/test/e2e/add-repo.spec.ts +++ b/test/e2e/add-repo.spec.ts @@ -114,6 +114,22 @@ test.describe('add repo', () => { await expect(page.locator(worktreeEntry(other.worktrees[0] ?? ''))).toBeVisible(); }); + test('adding a repo that already has a tab selects that tab instead of adding another', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + const other = makeRepoWith(hub, 'other', []); + writeHubConfig(hub, { repos: [repo, other.repo], roots: [join(hub.dir, 'nothing-here')] }); + await openUi(hub, page); + await selectRepo(page, other.repo); + const dialog = await openAddRepo(page); + await hostConnected(dialog, LOCAL); + await dialog.locator(byTestId(TID.addRepoPath)).fill(repo); + await dialog.locator(byTestId(TID.addRepoPath)).press('Enter'); + await expect(dialog).toHaveCount(0); + await expect(page.locator(repoTab(repo))).toHaveCount(1); + await expect(page.locator(repoTab(repo))).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(byTestId(TID.repoTab))).toHaveCount(2); + }); + test('Escape closes the dialog without sending anything', async ({ hub, page, wire }) => { const root = join(hub.dir, 'r'); const b = makeRepo(join(root, 'b')); diff --git a/test/e2e/attention.spec.ts b/test/e2e/attention.spec.ts index aae6c41..78ae720 100644 --- a/test/e2e/attention.spec.ts +++ b/test/e2e/attention.spec.ts @@ -86,7 +86,8 @@ test.describe('attention marks', () => { await expectMark(page, worktreeEntry(feat), null); await expectMark(page, repoTab(repo), null); await expectMark(page, termTab(failing), 'failed'); - await expectMark(page, pane(failing), 'failed'); + // A lone pane has no header: its tab carries the mark. + await expectMark(page, pane(failing), null); }); test('a hidden terminal that printed and then stayed quiet for 3 s is marked done', async ({ hub, page }) => { diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index 494585d..54f9489 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -56,12 +56,22 @@ export const TID = { addRepoPath: 'add-repo-path', /** In the add-repo dialog after a failed `addRepo`; text holds the error code. */ addRepoError: 'add-repo-error', - /** Sidebar entry; `title` = path, `aria-selected`, `data-prunable="true"`, `data-gone="true"`; holds a checkbox input unless gone. */ + /** Sidebar entry; `data-path` = path, `title` = path then ` : ` per terminal (or "no terminals"), `aria-selected`, `data-prunable="true"`, `data-gone="true"`; holds a checkbox input unless gone; right-click opens the worktree menu. */ worktree: 'worktree', /** The label inside a sidebar entry; clicking it selects the worktree. */ worktreeLabel: 'worktree-label', - /** Checkbox input: checked = show checked worktrees only. */ + /** Checkbox input: checked = show checked worktrees only, in the selected repo; each repo keeps its own, across reloads. */ filterChecked: 'filter-checked', + /** Context menu of a worktree entry; Escape or a press outside closes it. */ + worktreeMenu: 'worktree-menu', + /** Menu item: deletes the worktree, at once when nothing would be lost, else after the confirmation; `disabled` for the main worktree, a locked or gone one, or an unconnected host. */ + deleteWorktree: 'delete-worktree', + /** One reason in the confirmation of a worktree deletion. */ + confirmReason: 'confirm-reason', + /** In the confirmation of a worktree deletion: whether its branch is kept, or that a detached worktree's commits become unreachable. */ + confirmNote: 'confirm-note', + /** Separator on the sidebar's right edge; dragging or ArrowLeft/ArrowRight resizes the sidebar; `aria-valuenow` = its width in px, kept across reloads. */ + sidebarResizer: 'sidebar-resizer', /** Terminal tab; `data-term` = its first live terminal's id, `aria-selected`, text contains "exited" once that terminal exited. */ termTab: 'term-tab', /** Close button inside a terminal tab; closes every terminal of the tab, confirming first when any is running. */ @@ -74,7 +84,7 @@ export const TID = { splitDown: 'split-down', /** Pane frame of the shown tab, placed over its terminal; `data-term`, `data-focused="true"` on the focused pane. */ pane: 'pane', - /** Close button inside a pane; closes its terminal, confirming first when it is running. */ + /** Close button in the header of a pane of a split (a lone pane has no header); closes its terminal, confirming first when it is running. */ paneClose: 'pane-close', /** Divider between the two sides of a split; `data-split` = right|down, `data-path` = path from the tab root, e.g. "", "b", "b.a". */ divider: 'divider', @@ -84,6 +94,22 @@ export const TID = { presetChoices: 'preset-choices', /** One preset in a picker or in the choices, in configured order; text = preset name; `aria-selected="true"` on the highlighted one. */ presetOption: 'preset-option', + /** Button beside each preset while more than one exists; sends `removePreset` for it. */ + presetRemove: 'preset-remove', + /** Row holding a preset option and its remove button; draggable while more than one preset exists, a drop sends `movePreset`. */ + presetRow: 'preset-row', + /** Button under a preset list; opens the add-preset form. */ + presetAdd: 'preset-add', + /** The add-preset form; Escape closes it without sending anything. */ + presetForm: 'preset-form', + /** Name input of the add-preset form. */ + presetName: 'preset-name', + /** Command input of the add-preset form; empty means the login shell. */ + presetCommand: 'preset-command', + /** Submits the add-preset form; sends `addPreset` when the form is valid. */ + presetSubmit: 'preset-submit', + /** In the add-preset form after a refused preset; text says why. */ + presetError: 'preset-error', /** Dialog confirming the close of running terminals. */ closeDialog: 'close-dialog', /** One running terminal listed in the close dialog; text holds its preset and its id. */ @@ -119,7 +145,7 @@ export const repoTab = (path: string): string => `${byTestId(TID.repoTab)}[title export const hostBanner = (host: number): string => `${byTestId(TID.hostBanner)}[data-host="${String(host)}"]`; -export const worktreeEntry = (path: string): string => `${byTestId(TID.worktree)}[title="${path}"]`; +export const worktreeEntry = (path: string): string => `${byTestId(TID.worktree)}[data-path="${path}"]`; export const termTab = (termId: number): string => `${byTestId(TID.termTab)}[data-term="${String(termId)}"]`; diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index 3ff09bf..5199fc6 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -105,7 +105,15 @@ export const confirmAction = async (page: Page): Promise => { /** Hub-level requests (not `host` envelopes or `hello`) the page sent since `from`, without their `req`. */ export const hubRequests = (wire: WireRecorder, from = 0): { t: string; host: number }[] => - wire.sentMessages(from).flatMap((m) => (m.t === 'hello' || m.t === 'host' ? [] : [{ t: m.t, host: m.host }])); + wire.sentMessages(from).flatMap((m) => ('host' in m && m.t !== 'host' ? [{ t: m.t, host: m.host }] : [])); + +/** Preset edits the page sent since `from`, without their `req`. */ +export const presetRequests = (wire: WireRecorder, from = 0): unknown[] => + wire.sentMessages(from).flatMap((m): unknown[] => { + if (m.t === 'addPreset') return [{ t: m.t, preset: m.preset }]; + if (m.t === 'movePreset') return [{ t: m.t, name: m.name, to: m.to }]; + return m.t === 'removePreset' ? [{ t: m.t, name: m.name }] : []; + }); /** Every entry of `host` in the `hosts` messages the page received since `from`. */ export const hostEntries = (wire: WireRecorder, host: number, from = 0): HostEntry[] => @@ -199,7 +207,7 @@ export const selectWorktree = async (page: Page, path: string): Promise => /** Paths of the listed sidebar entries, in order. */ export const listedWorktrees = (page: Page): Promise => - page.locator(byTestId(TID.worktree)).evaluateAll((entries) => entries.map((e) => e.getAttribute('title') ?? '')); + page.locator(byTestId(TID.worktree)).evaluateAll((entries: HTMLElement[]) => entries.map((e) => e.dataset['path'] ?? '')); /** The terminal id of the selected terminal tab. */ export const activeTerm = async (page: Page): Promise => { diff --git a/test/e2e/hosts.spec.ts b/test/e2e/hosts.spec.ts index 0e31428..d67a53b 100644 --- a/test/e2e/hosts.spec.ts +++ b/test/e2e/hosts.spec.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { makeRepo, waitUntil } from '../support/daemon-host.js'; import { FakeDaemon } from '../support/fake-daemon.js'; import { installRemote, type FakeRemote } from '../support/fake-ssh.js'; @@ -149,7 +149,7 @@ test.describe('host actions', () => { process.kill(await oneDaemon(hub), 'SIGKILL'); await waitUntil(() => hub.daemonPids().length === 0, 'the daemon to exit'); rmSync(hub.socket, { force: true }); - const fake = await FakeDaemon.listen(hub.socket, { protocol: PROTOCOL_VERSION - 1, version: '0.0.9', instance }); + const fake = await FakeDaemon.listen(hub.socket, { protocol: DAEMON_PROTOCOL_VERSION - 1, version: '0.0.9', instance }); try { await hub.startHub(); await page.reload(); @@ -173,7 +173,7 @@ test.describe('host actions', () => { const app = makeRepo(join(remote.home, 'app')); await installRemote(hub, ssh, 'box'); const before = remote.current(); - const fake = await FakeDaemon.listen(remote.socket, { protocol: PROTOCOL_VERSION - 1, version: '0.0.9' }); + const fake = await FakeDaemon.listen(remote.socket, { protocol: DAEMON_PROTOCOL_VERSION - 1, version: '0.0.9' }); writeHubConfig(hub, { hosts: [{ name: 'box', ssh: 'box', repos: [app] }] }); try { const browserDialogs = refuseBrowserDialogs(page); diff --git a/test/e2e/picker.spec.ts b/test/e2e/picker.spec.ts index 68b6f80..5628674 100644 --- a/test/e2e/picker.spec.ts +++ b/test/e2e/picker.spec.ts @@ -1,4 +1,5 @@ -import type { Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import type { Locator, Page } from '@playwright/test'; import { decodeMessage } from '../../src/protocol/index.js'; import { byTestId, terminalBox, termTab, TID, worktreeEntry } from './contract.js'; import { @@ -7,7 +8,9 @@ import { LOCAL, makeRepoWith, newTerminal, + openPage, openUi, + presetRequests, PRESETS, ready, selectWorktree, @@ -308,3 +311,159 @@ test.describe('preset choices of a worktree without terminals', () => { await expect.poll(() => createsSent(wire, mark).map((m) => [m.preset, m.command])).toEqual([[SHELL.name, SHELL.command]]); }); }); + +test.describe('editing presets', () => { + const NEW = { name: 'echo', command: 'echo PRESET-NEW; exec cat -v' }; + + /** The configured presets in `config.json`. */ + const configured = (path: string): unknown => { + const config: unknown = JSON.parse(readFileSync(path, 'utf8')); + return typeof config === 'object' && config !== null && 'presets' in config ? config.presets : undefined; + }; + + /** Opens the add form in `list` and submits `name` and `command`. */ + const addPreset = async (list: Locator, name: string, command: string): Promise => { + await list.locator(byTestId(TID.presetAdd)).click(); + const form = list.locator(byTestId(TID.presetForm)); + await expect(form.locator(byTestId(TID.presetName))).toBeFocused(); + await form.locator(byTestId(TID.presetName)).fill(name); + await form.locator(byTestId(TID.presetCommand)).fill(command); + await form.locator(byTestId(TID.presetSubmit)).click(); + }; + + test('a preset added from the choices is saved, shown in every page, and runs its command', async ({ hub, page, wire, context }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const other = await openPage(hub, context); + const choices = page.locator(byTestId(TID.presetChoices)); + const mark = wire.markSent(); + + await addPreset(choices, ` ${NEW.name} `, NEW.command); + expect(presetRequests(wire, mark)).toEqual([{ t: 'addPreset', preset: NEW }]); + await expect(choices.locator(byTestId(TID.presetForm))).toHaveCount(0); + const names = [...PRESETS.map((p) => p.name), NEW.name]; + await expect(choices.locator(byTestId(TID.presetOption))).toHaveText(names); + await expect(other.page.locator(byTestId(TID.presetChoices)).locator(byTestId(TID.presetOption))).toHaveText(names); + expect(configured(hub.configPath)).toEqual([...PRESETS, NEW]); + + await choices.locator(byTestId(TID.presetOption), { hasText: NEW.name }).click(); + await waitScreen(page, await activeTerm(page), 'PRESET-NEW'); + }); + + test('× removes a preset from every page and config.json, and the last preset offers none', async ({ hub, page, wire, context }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const other = await openPage(hub, context); + const choices = page.locator(byTestId(TID.presetChoices)); + const mark = wire.markSent(); + + await choices.locator(byTestId(TID.presetOption), { hasText: CAT.name }).locator('..').locator(byTestId(TID.presetRemove)).click(); + expect(presetRequests(wire, mark)).toEqual([{ t: 'removePreset', name: CAT.name }]); + await expect(choices.locator(byTestId(TID.presetOption))).toHaveText([SHELL.name]); + await expect(other.page.locator(byTestId(TID.presetChoices)).locator(byTestId(TID.presetOption))).toHaveText([SHELL.name]); + expect(configured(hub.configPath)).toEqual([SHELL]); + await expect(choices.locator(byTestId(TID.presetRemove))).toHaveCount(0); + }); + + test('the add form refuses a taken name without sending, and Escape closes it', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const choices = page.locator(byTestId(TID.presetChoices)); + const mark = wire.markSent(); + + await addPreset(choices, CAT.name, 'anything'); + await expect(choices.locator(byTestId(TID.presetError))).toHaveText(`A preset named ${CAT.name} exists`); + await choices.locator(byTestId(TID.presetName)).fill(' '); + await choices.locator(byTestId(TID.presetName)).press('Enter'); + await expect(choices.locator(byTestId(TID.presetError))).toHaveText('Give the preset a name'); + await choices.locator(byTestId(TID.presetName)).press('Escape'); + await expect(choices.locator(byTestId(TID.presetForm))).toHaveCount(0); + await expect(choices.locator(byTestId(TID.presetAdd))).toBeVisible(); + expect(presetRequests(wire, mark)).toEqual([]); + expect(configured(hub.configPath)).toEqual(PRESETS); + }); + + test('a preset added in the new-tab picker keeps the picker open and can be chosen there', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + await newTerminal(page); + await page.locator(byTestId(TID.newTab)).click(); + const picker = page.locator(byTestId(TID.presetPicker)); + await expect(picker).toBeVisible(); + + await addPreset(picker, NEW.name, NEW.command); + await expect(picker.locator(byTestId(TID.presetOption))).toHaveText([...PRESETS.map((p) => p.name), NEW.name]); + await expect(picker).toBeVisible(); + const before = await activeTerm(page); + const mark = wire.markSent(); + await picker.locator(byTestId(TID.presetOption), { hasText: NEW.name }).click(); + await expect.poll(() => createsSent(wire, mark).map((m) => [m.preset, m.command])).toEqual([[NEW.name, NEW.command]]); + await expect.poll(() => activeTerm(page)).not.toBe(before); + await waitScreen(page, await activeTerm(page), 'PRESET-NEW'); + }); + + test('dragging a preset reorders the presets in every page and in config.json', async ({ hub, page, wire, context }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const other = await openPage(hub, context); + const choices = page.locator(byTestId(TID.presetChoices)); + const row = (name: string): Locator => choices.locator(byTestId(TID.presetRow), { hasText: name }); + const mark = wire.markSent(); + + await row(CAT.name).dragTo(row(SHELL.name), { targetPosition: { x: 20, y: 2 } }); + await expect.poll(() => presetRequests(wire, mark)).toEqual([{ t: 'movePreset', name: CAT.name, to: 0 }]); + await expect(choices.locator(byTestId(TID.presetOption))).toHaveText([CAT.name, SHELL.name]); + await expect(other.page.locator(byTestId(TID.presetChoices)).locator(byTestId(TID.presetOption))).toHaveText([CAT.name, SHELL.name]); + expect(configured(hub.configPath)).toEqual([CAT, SHELL]); + }); + + test('Alt+Up moves the highlighted preset up in the picker, which keeps it highlighted', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + await newTerminal(page); + await page.locator(byTestId(TID.newTab)).click(); + const picker = page.locator(byTestId(TID.presetPicker)); + const options = picker.locator(byTestId(TID.presetOption)); + await page.keyboard.press('ArrowDown'); + await expect(options.nth(1)).toHaveAttribute('aria-selected', 'true'); + + await page.keyboard.press('Alt+ArrowUp'); + await expect(options).toHaveText([CAT.name, SHELL.name]); + await expect(options.nth(0)).toHaveAttribute('aria-selected', 'true'); + await expect(options.nth(0)).toBeFocused(); + expect(configured(hub.configPath)).toEqual([CAT, SHELL]); + + const mark = wire.markSent(); + await page.keyboard.press('Enter'); + await expect.poll(() => createsSent(wire, mark).map((m) => m.preset)).toEqual([CAT.name]); + }); + + test('two quick Alt+Down presses only ever move the highlighted preset', async ({ hub, page }) => { + const third = { name: 'third', command: 'echo THIRD' }; + hub.presets = [...PRESETS, third]; + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + await newTerminal(page); + await page.locator(byTestId(TID.newTab)).click(); + const options = page.locator(byTestId(TID.presetPicker)).locator(byTestId(TID.presetOption)); + await expect(options.nth(0)).toBeFocused(); + + await page.keyboard.press('Alt+ArrowDown'); + await page.keyboard.press('Alt+ArrowDown'); + await expect(options.nth(0)).toHaveText(CAT.name); + // The second press either waited for the first move or moved the same preset again. + const order = await options.allTextContents(); + expect([ + [CAT.name, SHELL.name, third.name], + [CAT.name, third.name, SHELL.name], + ]).toContainEqual(order); + expect(configured(hub.configPath)).toEqual(order.map((name) => [...PRESETS, third].find((p) => p.name === name))); + }); +}); diff --git a/test/e2e/sidebar.spec.ts b/test/e2e/sidebar.spec.ts index 4810c6d..7fad1cb 100644 --- a/test/e2e/sidebar.spec.ts +++ b/test/e2e/sidebar.spec.ts @@ -1,4 +1,9 @@ +import { existsSync, writeFileSync } from 'node:fs'; +import type { Page } from '@playwright/test'; +import { join } from 'node:path'; +import type { HubHost } from '../support/hub-host.js'; import { fakeWorktrees, git } from '../support/daemon-host.js'; +import { COMMANDS, runIn } from './attention-helpers.js'; import { byTestId, termTab, TID, worktreeEntry } from './contract.js'; import { confirmClose, @@ -16,6 +21,7 @@ import { test, typeLine, waitScreen, + watcher, } from './fixture.js'; const label = (path: string): string => `${worktreeEntry(path)} ${byTestId(TID.worktreeLabel)}`; @@ -104,10 +110,11 @@ test.describe('worktree sidebar', () => { }); test.describe('checked filter', () => { - test('"checked only" persists across a reload, lists only checked worktrees and applies to every repo', async ({ hub, page }) => { + test('"checked only" persists across a reload, lists only checked worktrees and belongs to its repo', async ({ hub, page }) => { const { repo, worktrees } = makeRepoWith(hub, 'app', ['a', 'b', 'c']); const [a = '', b = '', c = ''] = worktrees; - const second = makeRepoWith(hub, 'second', []); + const second = makeRepoWith(hub, 'second', ['d']); + const [d = ''] = second.worktrees; hub.writeRepos([repo, second.repo]); await openUi(hub, page); await selectWorktree(page, a); @@ -121,11 +128,41 @@ test.describe('checked filter', () => { expect(await listedWorktrees(page)).toEqual([a]); for (const hidden of [repo, b, c]) await expect(page.locator(worktreeEntry(hidden))).toHaveCount(0); + // The other repo keeps showing all its worktrees. await selectRepo(page, second.repo); + await expect(page.locator(byTestId(TID.filterChecked))).not.toBeChecked(); + await expect.poll(async () => (await listedWorktrees(page)).sort()).toEqual([second.repo, d].sort()); + + // Turning it on there leaves the first repo's filter as it was. + await page.locator(byTestId(TID.filterChecked)).check(); + await expect.poll(() => listedWorktrees(page)).toEqual([second.repo]); + await page.locator(byTestId(TID.filterChecked)).uncheck(); + await expect.poll(async () => (await listedWorktrees(page)).sort()).toEqual([second.repo, d].sort()); + await selectRepo(page, repo); await expect(page.locator(byTestId(TID.filterChecked))).toBeChecked(); - // Nothing checked there: its first worktree is selected and listed. - await expect(page.locator(worktreeEntry(second.repo))).toHaveAttribute('aria-selected', 'true'); - expect(await listedWorktrees(page)).toEqual([second.repo]); + expect(await listedWorktrees(page)).toEqual([a]); + }); + + test('switching to a repo with another filter shows its switch without animating it; a click animates', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + const second = makeRepoWith(hub, 'second', []); + hub.writeRepos([repo, second.repo]); + await openUi(hub, page); + // Long transitions, so a running one is still running when checked. + await page.addStyleTag({ content: '* , *::before, *::after { transition-duration: 10s !important; }' }); + const toggle = page.locator(byTestId(TID.filterChecked)); + const animating = (): Promise => + toggle.evaluate((input) => input.getAnimations({ subtree: true }).some((a) => a.playState === 'running')); + + await toggle.check(); + expect(await animating()).toBe(true); + + await selectRepo(page, second.repo); + await expect(toggle).not.toBeChecked(); + expect(await animating()).toBe(false); + await selectRepo(page, repo); + await expect(toggle).toBeChecked(); + expect(await animating()).toBe(false); }); test('the selected worktree stays listed and selected when unchecked under "checked only"', async ({ hub, page, wire }) => { @@ -152,3 +189,166 @@ test.describe('checked filter', () => { await expect(page.locator(checkbox(b))).not.toBeChecked(); }); }); + +test.describe('sidebar width', () => { + test('dragging the sidebar edge resizes the sidebar within bounds, arrow keys nudge it, and the width survives a reload', async ({ + hub, + page, + }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const sidebar = page.getByRole('navigation'); + const resizer = page.locator(byTestId(TID.sidebarResizer)); + const dragTo = async (x: number): Promise => { + const box = await resizer.boundingBox(); + if (box === null) throw new Error('the sidebar resizer is not shown'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(x, box.y + box.height / 2, { steps: 5 }); + await page.mouse.up(); + }; + const sidebarWidth = async (): Promise => (await sidebar.boundingBox())?.width ?? 0; + const left = (await sidebar.boundingBox())?.x ?? 0; + + await expect(resizer).toHaveAttribute('aria-valuenow', '260'); + await dragTo(left + 400); + await expect(resizer).toHaveAttribute('aria-valuenow', '400'); + expect(await sidebarWidth()).toBe(400); + const terminals = page.locator('.terminal-pane'); + expect((await terminals.boundingBox())?.x).toBe(left + 400); + + await dragTo(left + 20); + await expect(resizer).toHaveAttribute('aria-valuenow', '160'); + await dragTo(left + 5000); + await expect(resizer).toHaveAttribute('aria-valuenow', '640'); + + await dragTo(left + 400); + await resizer.focus(); + await page.keyboard.press('ArrowRight'); + await expect(resizer).toHaveAttribute('aria-valuenow', '416'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await expect(resizer).toHaveAttribute('aria-valuenow', '384'); + + await page.reload(); + await expect(resizer).toHaveAttribute('aria-valuenow', '384'); + expect(await sidebarWidth()).toBe(384); + }); +}); + +test.describe('worktree status and deletion', () => { + /** Gives `repo` a bare `origin` holding its `main`, with `origin/HEAD` pointing at it. */ + const withOrigin = (hub: HubHost, repo: string): void => { + const origin = join(hub.dir, 'origin.git'); + git(hub.dir, 'init', '-q', '--bare', '-b', 'main', origin); + git(repo, 'remote', 'add', 'origin', origin); + git(repo, 'push', '-q', 'origin', 'main'); + git(repo, 'remote', 'set-head', 'origin', 'main'); + }; + + const titleOf = async (page: Page, path: string): Promise => + (await page.locator(worktreeEntry(path)).getAttribute('title')) ?? ''; + + test('hovering a worktree shows its path and what each of its terminals is doing', async ({ hub, page }) => { + const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']); + const feat = worktrees[0] ?? ''; + hub.writeRepos([repo]); + await openUi(hub, page); + await expect.poll(() => titleOf(page, feat)).toBe(`${feat}\nno terminals`); + + const client = await watcher(hub, repo); + const ticker = await runIn(client, feat, COMMANDS.ticker); + await expect.poll(() => titleOf(page, feat), { timeout: 10_000 }).toBe(`${feat}\nprobe ${String(ticker)}: running`); + const failing = await runIn(client, feat, COMMANDS.fail); + await expect + .poll(() => titleOf(page, feat), { timeout: 10_000 }) + .toBe(`${feat}\nprobe ${String(ticker)}: running\nprobe ${String(failing)}: exited with code 1`); + }); + + test('deleting a merged, clean worktree without running terminals asks nothing', async ({ hub, page }) => { + const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']); + const feat = worktrees[0] ?? ''; + withOrigin(hub, repo); + hub.writeRepos([repo]); + await openUi(hub, page); + await page.locator(worktreeEntry(feat)).click({ button: 'right' }); + await page.locator(byTestId(TID.worktreeMenu)).locator(byTestId(TID.deleteWorktree)).click(); + await expect(page.locator(worktreeEntry(feat))).toHaveCount(0); + expect(existsSync(feat)).toBe(false); + await expect(page.locator(byTestId(TID.confirmDialog))).toHaveCount(0); + await expect(page.locator(byTestId(TID.worktreeMenu))).toHaveCount(0); + expect(git(repo, 'branch', '--list', 'feat')).toContain('feat'); + }); + + test('deleting a worktree at risk names every reason, keeps it on cancel and deletes it on confirm', async ({ hub, page }) => { + const { repo, worktrees } = makeRepoWith(hub, 'app', ['feat']); + const feat = worktrees[0] ?? ''; + withOrigin(hub, repo); + writeFileSync(join(feat, 'a'), 'a'); + git(feat, 'add', 'a'); + git(feat, 'commit', '-q', '-m', 'a'); + writeFileSync(join(feat, 'untracked'), 'new'); + hub.writeRepos([repo]); + await openUi(hub, page); + const client = await watcher(hub, repo); + const ticker = await runIn(client, feat, COMMANDS.ticker); + await expect.poll(() => titleOf(page, feat), { timeout: 10_000 }).toContain(`probe ${String(ticker)}`); + const dialog = page.locator(byTestId(TID.confirmDialog)); + const remove = async (): Promise => { + await page.locator(worktreeEntry(feat)).click({ button: 'right' }); + await page.locator(byTestId(TID.deleteWorktree)).click(); + await expect(dialog).toBeVisible(); + }; + + await remove(); + await expect(dialog.locator(byTestId(TID.confirmReason))).toHaveText([ + 'It has 1 commit not in origin/main.', + 'It has 1 uncommitted change, untracked files included.', + `Running terminals will be closed: probe ${String(ticker)}.`, + ]); + await expect(dialog.locator(byTestId(TID.confirmNote))).toHaveText('Its branch is kept.'); + await dialog.locator(byTestId(TID.confirmCancel)).click(); + await expect(dialog).toHaveCount(0); + expect(existsSync(feat)).toBe(true); + + await remove(); + const from = client.mark(); + await dialog.locator(byTestId(TID.confirmOk)).click(); + await expect(page.locator(worktreeEntry(feat))).toHaveCount(0); + expect(existsSync(feat)).toBe(false); + await client.waitFor('termClosed', (m) => m.termId === ticker, { from }); + expect(git(repo, 'rev-parse', 'feat')).toMatch(/^[0-9a-f]{40}$/); + }); + + test('deleting a detached worktree with commits says they become unreachable and names its head', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + withOrigin(hub, repo); + const detached = detachedWorktree(repo, 'loose'); + writeFileSync(join(detached.path, 'a'), 'a'); + git(detached.path, 'add', 'a'); + git(detached.path, 'commit', '-q', '-m', 'a'); + hub.writeRepos([repo]); + await openUi(hub, page); + await page.locator(worktreeEntry(detached.path)).click({ button: 'right' }); + await page.locator(byTestId(TID.deleteWorktree)).click(); + const dialog = page.locator(byTestId(TID.confirmDialog)); + await expect(dialog.locator(byTestId(TID.confirmReason))).toHaveText(['It has 1 commit not in origin/main.']); + const head = git(detached.path, 'rev-parse', '--short=7', 'HEAD'); + await expect(dialog.locator(byTestId(TID.confirmNote))).toHaveText( + `It has no branch: once it is deleted, its commits not in origin/main become unreachable; its head is ${head}.`, + ); + }); + + test('the main worktree cannot be deleted, and Escape closes the menu', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + await page.locator(worktreeEntry(repo)).click({ button: 'right' }); + const item = page.locator(byTestId(TID.deleteWorktree)); + await expect(item).toBeDisabled(); + await expect(item).toHaveAttribute('title', 'The main worktree cannot be deleted'); + await page.locator(byTestId(TID.worktreeMenu)).press('Escape'); + await expect(page.locator(byTestId(TID.worktreeMenu))).toHaveCount(0); + }); +}); diff --git a/test/e2e/splits.spec.ts b/test/e2e/splits.spec.ts index b1691d5..f576d9f 100644 --- a/test/e2e/splits.spec.ts +++ b/test/e2e/splits.spec.ts @@ -1,4 +1,5 @@ import { chmodSync } from 'node:fs'; +import type { Page } from '@playwright/test'; import type { DaemonClient } from '../support/daemon-client.js'; import { byTestId, pane, terminalBox, termTab, TID } from './contract.js'; import { @@ -529,3 +530,40 @@ test.describe('pane focus', () => { await expect.poll(() => focusedPane(page)).toBe(top); }); }); + +test.describe('pane headers', () => { + /** The top edges of the pane `termId` and of its terminal box. */ + const tops = async (page: Page, termId: number): Promise<[number, number]> => [ + (await page.locator(pane(termId)).boundingBox())?.y ?? Number.NaN, + (await page.locator(terminalBox(LOCAL, termId)).boundingBox())?.y ?? Number.NaN, + ]; + + test('a lone pane has no header and its terminal fills it; split panes each have one', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await openUi(hub, page); + const left = await newTerminal(page); + await ready(page, left); + await expect(page.locator(byTestId(TID.paneClose))).toHaveCount(0); + await expect + .poll(async () => { + const [paneTop, boxTop] = await tops(page, left); + return boxTop - paneTop; + }) + .toBe(0); + + const right = await splitPane(page, 'right'); + for (const term of [left, right]) await expect(page.locator(pane(term)).locator(byTestId(TID.paneClose))).toHaveCount(1); + await expect + .poll(async () => { + const [paneTop, boxTop] = await tops(page, right); + return boxTop - paneTop; + }) + .toBe(20); + + await page.locator(pane(right)).locator(byTestId(TID.paneClose)).click(); + await confirmClose(page); + await expect(page.locator(pane(right))).toHaveCount(0); + await expect(page.locator(byTestId(TID.paneClose))).toHaveCount(0); + }); +}); diff --git a/test/e2e/terminals.spec.ts b/test/e2e/terminals.spec.ts index ae50d5e..6bf159c 100644 --- a/test/e2e/terminals.spec.ts +++ b/test/e2e/terminals.spec.ts @@ -151,8 +151,9 @@ test.describe('terminal tabs', () => { await ready(page, term); await typeLine(page, term, "echo BYE''-MARK; exit"); await expect(page.locator(termTab(term))).toContainText('exited'); - await expect.poll(() => markOf(page, pane(term))).toBe('exited'); await expect.poll(() => markOf(page, termTab(term))).toBe('exited'); + // A lone pane has no header: its tab carries the mark. + expect(await markOf(page, pane(term))).toBeNull(); await expect(page.locator(terminalBox(LOCAL, term))).toBeVisible(); expect(await screenOf(page, term)).toContain('BYE-MARK'); }); @@ -527,3 +528,77 @@ test.describe('terminal settings', () => { expect(await opened.evaluate(() => window.opener === null)).toBe(true); }); }); + +declare global { + interface Window { + /** Set by `stubFocus`: makes the page report and announce gaining or losing the focus. */ + __wtdFocus?: (focused: boolean) => void; + } +} + +test.describe('terminal size with several pages', () => { + /** Makes `page` report the focus only when told to, as a browser window does; Playwright reports every page as focused. */ + const stubFocus = async (page: Page, focused: boolean): Promise => { + await page.addInitScript((initial) => { + let current = initial; + document.hasFocus = () => current; + window.__wtdFocus = (next) => { + current = next; + window.dispatchEvent(new Event(next ? 'focus' : 'blur')); + }; + }, focused); + }; + + const setFocus = (page: Page, focused: boolean): Promise => + page.evaluate((next) => { + window.__wtdFocus?.(next); + }, focused); + + let probes = 0; + + /** The PTY's size as `stty size` reports it in the terminal, typed through `page`. */ + const ptySize = async (page: Page, termId: number): Promise => { + const probe = ++probes; + await typeLine(page, termId, `echo SZ${String(probe)}-$(stty size)-END`); + const pattern = new RegExp(`SZ${String(probe)}-(\\d+ \\d+)-END`); + let size = ''; + await expect + .poll(async () => { + size = pattern.exec((await screenOf(page, termId)) ?? '')?.[1] ?? ''; + return size; + }) + .not.toBe(''); + return size; + }; + + test('the PTY takes the size of the page in focus, whatever unfocused pages do', async ({ hub, page, context }) => { + const { repo } = makeRepoWith(hub, 'app', []); + hub.writeRepos([repo]); + await stubFocus(page, true); + await openUi(hub, page); + const term = await newTerminal(page); + await ready(page, term); + const focusedSize = await ptySize(page, term); + + const other = await context.newPage(); + await stubFocus(other, false); + await other.setViewportSize({ width: 700, height: 450 }); + await other.goto(await hub.pageUrl()); + await expect(other.locator(terminalBox(LOCAL, term))).toBeVisible(); + expect(await ptySize(page, term)).toBe(focusedSize); + + await setFocus(page, false); + await setFocus(other, true); + const otherSize = await ptySize(page, term); + expect(otherSize).not.toBe(focusedSize); + + await page.setViewportSize({ width: 1100, height: 650 }); + expect(await ptySize(page, term)).toBe(otherSize); + + await setFocus(other, false); + await setFocus(page, true); + const resized = await ptySize(page, term); + expect(resized).not.toBe(otherSize); + expect(resized).not.toBe(focusedSize); + }); +}); diff --git a/test/integration/ssh-localhost.test.ts b/test/integration/ssh-localhost.test.ts index d6fc60d..76309d6 100644 --- a/test/integration/ssh-localhost.test.ts +++ b/test/integration/ssh-localhost.test.ts @@ -3,7 +3,7 @@ import { chmodSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { alive, makeRepo, waitUntil } from '../support/daemon-host.js'; import { exec, REPO_ROOT } from '../support/exec.js'; import { FakeDaemon } from '../support/fake-daemon.js'; @@ -153,7 +153,11 @@ describe('remote host over real ssh', () => { it('reinstalls and restarts an outdated remote daemon', async () => { await install(); - const fake = await FakeDaemon.listen(remote.socket, { protocol: PROTOCOL_VERSION - 1, version: '0.0.1-old', instance: 'old_remote' }); + const fake = await FakeDaemon.listen(remote.socket, { + protocol: DAEMON_PROTOCOL_VERSION - 1, + version: '0.0.1-old', + instance: 'old_remote', + }); await startHub(); const client = await host.session(); expect(await client.waitHost(REMOTE, (h) => h.status === 'outdated', { timeout: 30_000 })).toMatchObject({ instance: 'old_remote' }); diff --git a/test/process/cli.test.ts b/test/process/cli.test.ts index 04f47d9..6396b08 100644 --- a/test/process/cli.test.ts +++ b/test/process/cli.test.ts @@ -36,7 +36,11 @@ describe('dist/wtd.mjs', () => { }); it('prints its version', () => { - expect(wtd('--version')).toEqual({ status: 0, stdout: `wtd ${packageVersion()} (protocol 5)\n`, stderr: '' }); + expect(wtd('--version')).toEqual({ + status: 0, + stdout: `wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`, + stderr: '', + }); }); it('prints help listing every command and option', () => { diff --git a/test/process/connect.test.ts b/test/process/connect.test.ts index 834f5e6..6ac0792 100644 --- a/test/process/connect.test.ts +++ b/test/process/connect.test.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { mkdirSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { DaemonClient } from '../support/daemon-client.js'; import { DaemonHost, makeRepo, sessionOf, waitUntil, type WtdProcess } from '../support/daemon-host.js'; @@ -38,8 +38,8 @@ describe('wtd connect', () => { const { process: connect, client } = bridge(); const hello = await client.waitFor('hello', () => true, { timeout: 10_000 }); expect(client.received[0]).toEqual({ kind: 'message', message: hello }); - expect(hello.protocol).toBe(PROTOCOL_VERSION); - expect(hello.protocol).toBe(5); + expect(hello.protocol).toBe(DAEMON_PROTOCOL_VERSION); + expect(hello.protocol).toBe(6); const [daemon, ...others] = host.daemonPids(); if (daemon === undefined) throw new Error('no daemon is running'); expect(others).toEqual([]); diff --git a/test/process/daemon-server.test.ts b/test/process/daemon-server.test.ts index d7d4b22..0d8995b 100644 --- a/test/process/daemon-server.test.ts +++ b/test/process/daemon-server.test.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { encodeStreamData, PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { encodeStreamData, DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import type { DaemonClient } from '../support/daemon-client.js'; import { REPO_ROOT } from '../support/exec.js'; import { @@ -141,15 +141,15 @@ describe('single instance', () => { }); describe('handshake', () => { - it('greets every connection with hello for protocol 5, the package version and one instance id', async () => { + it('greets every connection with hello for protocol 6, the package version and one instance id', async () => { await host.start(); const a = await host.rawClient(); const b = await host.rawClient(); const helloA = await a.waitFor('hello'); const helloB = await b.waitFor('hello'); expect(a.received[0]).toEqual({ kind: 'message', message: helloA }); - expect(helloA.protocol).toBe(PROTOCOL_VERSION); - expect(helloA.protocol).toBe(5); + expect(helloA.protocol).toBe(DAEMON_PROTOCOL_VERSION); + expect(helloA.protocol).toBe(6); expect(helloA.version).toBe(packageVersion()); expect(helloA.instance).toMatch(/^[A-Za-z0-9_-]{1,64}$/); expect(helloB.instance).toBe(helloA.instance); @@ -192,7 +192,7 @@ describe('handshake', () => { describe('malformed traffic', () => { const output = encodeStreamData({ kind: 'output', termId: 1, offset: 0, data: new Uint8Array([120]) }); const snapshot = encodeStreamData({ kind: 'snapshot', termId: 1, offset: 0, data: new Uint8Array([120]) }); - const hello = { t: 'hello', protocol: PROTOCOL_VERSION, version: '1', instance: 'again' }; + const hello = { t: 'hello', protocol: DAEMON_PROTOCOL_VERSION, version: '1', instance: 'again' }; it.each([ [ diff --git a/test/process/daemon-worktrees.test.ts b/test/process/daemon-worktrees.test.ts index 629f776..d4dca07 100644 --- a/test/process/daemon-worktrees.test.ts +++ b/test/process/daemon-worktrees.test.ts @@ -386,3 +386,97 @@ describe('discoverRepos', () => { expect(reply).toEqual({ t: 'reposDiscovered', req: ANY_REQ, repos: [app] }); }); }); + +describe('removeWorktree', () => { + /** Gives the repo a bare `origin` holding its `main`, with `origin/HEAD` pointing at it. */ + const withOrigin = (): void => { + const origin = join(host.dir, 'origin.git'); + git(host.dir, 'init', '-q', '--bare', '-b', 'main', origin); + git(repo, 'remote', 'add', 'origin', origin); + git(repo, 'push', '-q', 'origin', 'main'); + git(repo, 'remote', 'set-head', 'origin', 'main'); + }; + + const commit = (cwd: string, file: string): void => { + writeFileSync(join(cwd, file), file); + git(cwd, 'add', file); + git(cwd, 'commit', '-q', '-m', file); + }; + + it('removes a merged, clean worktree at once, closing its exited terminals and keeping its branch', async () => { + withOrigin(); + const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); + const client = await host.client(); + await client.watch(repo); + const exited = await client.create(wt, { command: 'true' }); + await client.waitFor('termExited', (m) => m.termId === exited.termId); + const from = client.mark(); + expect(await client.request({ t: 'removeWorktree', worktree: wt, force: false })).toEqual({ t: 'done', req: ANY_REQ }); + expect(existsSync(wt)).toBe(false); + await client.waitFor('termClosed', (m) => m.termId === exited.termId, { from }); + await client.waitFor('worktreesChanged', (m) => find(m.worktrees, wt) === undefined, { from }); + expect(git(repo, 'branch', '--list', 'feat')).toContain('feat'); + }); + + it('keeps a worktree at risk and reports every risk: commits ahead, changes and running terminals', async () => { + withOrigin(); + const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); + commit(wt, 'a'); + writeFileSync(join(wt, 'untracked'), 'new'); + const client = await host.client(); + await client.watch(repo); + const running = await client.create(wt, { command: 'exec sleep 60' }); + expect(await client.request({ t: 'removeWorktree', worktree: wt, force: false })).toEqual({ + t: 'worktreeAtRisk', + req: ANY_REQ, + worktree: wt, + base: 'origin/main', + ahead: 1, + changes: 1, + running: [running.termId], + }); + expect(existsSync(wt)).toBe(true); + await client.expectNone('termClosed', () => true, 300); + }); + + it('reports a worktree of a repo without an origin as at risk', async () => { + const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); + const client = await host.client(); + await client.watch(repo); + expect(await client.request({ t: 'removeWorktree', worktree: wt, force: false })).toMatchObject({ + t: 'worktreeAtRisk', + base: null, + ahead: null, + changes: 0, + running: [], + }); + expect(existsSync(wt)).toBe(true); + }); + + it('removes a worktree at risk when forced, closing its running terminals', async () => { + withOrigin(); + const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); + commit(wt, 'a'); + writeFileSync(join(wt, 'untracked'), 'new'); + const client = await host.client(); + await client.watch(repo); + const running = await client.create(wt, { command: 'exec sleep 60' }); + const from = client.mark(); + expect(await client.request({ t: 'removeWorktree', worktree: wt, force: true })).toEqual({ t: 'done', req: ANY_REQ }); + expect(existsSync(wt)).toBe(false); + await client.waitFor('termClosed', (m) => m.termId === running.termId, { from }); + expect(git(repo, 'rev-parse', 'feat')).toMatch(/^[0-9a-f]{40}$/); + }); + + it('refuses the main worktree, a locked one and a path that is not a worktree', async () => { + const wt = addWorktree(repo, join(host.dir, 'wt'), 'feat'); + git(repo, 'worktree', 'lock', wt); + const client = await host.client(); + await client.watch(repo); + expect(await client.fails({ t: 'removeWorktree', worktree: repo, force: true })).toBe('busy'); + expect(await client.fails({ t: 'removeWorktree', worktree: wt, force: true })).toBe('busy'); + expect(await client.fails({ t: 'removeWorktree', worktree: join(host.dir, 'nope'), force: true })).toBe('unknown-worktree'); + expect(existsSync(repo)).toBe(true); + expect(existsSync(wt)).toBe(true); + }); +}); diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts index b5ee2b0..a60cc94 100644 --- a/test/process/hub-router.test.ts +++ b/test/process/hub-router.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync import { hostname } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FLOW_HIGH, FrameKind, PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; +import { FLOW_HIGH, FrameKind, DAEMON_PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; import { addWorktree, alive, DaemonHost, makeRepo, residentBytes, sleep, waitUntil, type WtdProcess } from '../support/daemon-host.js'; import { FakeDaemon, SocketProxy } from '../support/fake-daemon.js'; import { currentNodeDir, FakeSsh, installRemote, type FakeRemote } from '../support/fake-ssh.js'; @@ -20,6 +20,8 @@ const MiB = 1024 * KiB; const SHELL = { name: 'shell', command: null }; const CLAUDE = { name: 'claude', command: 'claude' }; +/** The presets of a configuration without `presets`. */ +const DEFAULT_PRESETS = [SHELL, CLAUDE, { name: 'codex', command: 'codex' }]; let host: HubHost; let hub: WtdProcess; @@ -113,7 +115,7 @@ describe('configuration snapshots', () => { it('gives a new session an edited configuration, leaving an open session unchanged', async () => { const open = await host.session(); - expect(presetsOf(open)).toEqual([[SHELL]]); + expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); const other = makeRepo(join(host.dir, 'other')); host.presets = [SHELL, CLAUDE]; host.writeRepos([repo, other]); @@ -122,7 +124,7 @@ describe('configuration snapshots', () => { expect(presetsOf(fresh)).toEqual([[SHELL, CLAUDE]]); await open.expectNone('hosts', (m) => m.hosts.some((h) => h.repos.includes(other)), 1000); expect(open.hosts()[0]?.repos).toEqual([repo]); - expect(presetsOf(open)).toEqual([[SHELL]]); + expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); }); it.each([ @@ -199,7 +201,7 @@ describe('daemon links and host status', () => { }); it('reports reconnecting, then down after 3 failures, and keeps retrying', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: PROTOCOL_VERSION, accept: 'close' }); + const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); const client = await host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting'); await client.waitHost(0, (h) => h.status === 'down', { timeout: 10_000 }); @@ -269,7 +271,7 @@ describe('routing', () => { }); it('answers a request to a reconnecting host with host-unavailable and drops its fire-and-forget messages', async () => { - await FakeDaemon.listen(host.socket, { protocol: PROTOCOL_VERSION, accept: 'close' }); + await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); const client = await host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); const reply = await client.request(0, { t: 'watchRepo', repo }); @@ -393,12 +395,12 @@ describe('daemon restart', () => { // A daemon the session's reconnect started alongside the restart refuses to start and exits. await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); const direct = await host.client(); - expect((await direct.handshake()).protocol).toBe(PROTOCOL_VERSION); + expect((await direct.handshake()).protocol).toBe(DAEMON_PROTOCOL_VERSION); }); it('restarts a newer daemon whose messages after hello are not valid for the hub’s protocol', async () => { const fake = await FakeDaemon.listen(host.socket, { - protocol: PROTOCOL_VERSION + 1, + protocol: DAEMON_PROTOCOL_VERSION + 1, instance: 'newer_1', afterHello: ['{"t":"frobnicated","widgets":[1,2,3]}', '{"t":"repoState","repo":7}'], }); @@ -454,7 +456,7 @@ describe('daemon restart', () => { }); it('answers host-unavailable for a host that is not connected or outdated', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: PROTOCOL_VERSION, accept: 'close' }); + const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); const client = await host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }); @@ -997,6 +999,74 @@ describe('removing repos', () => { }); }); +describe('editing presets', () => { + const HTOP = { name: 'htop', command: 'htop' }; + + it('adds a preset to config.json, answers done, then sends the presets to every open session', async () => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const a = await host.session(); + const b = await host.session(); + const [fromA, fromB] = [a.mark(), b.mark()]; + expect(await a.hubRequest({ t: 'addPreset', preset: HTOP })).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + for (const [client, from] of [ + [a, fromA], + [b, fromB], + ] as const) { + expect((await client.waitFor('presets', () => true, { from })).presets).toEqual([SHELL, HTOP]); + } + const doneAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'done'); + const presetsAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'presets'); + expect(doneAt).toBeLessThan(presetsAt); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [SHELL, HTOP] }); + }); + + it('removes a preset, writing the defaults without it when config.json had none', async () => { + host.writeConfig({ port: host.port, repos: [repo] }); + const client = await host.session(); + const from = client.mark(); + expect((await client.hubRequest({ t: 'removePreset', name: 'claude' })).m).toEqual({ t: 'done', req: 1 }); + const expected = [SHELL, { name: 'codex', command: 'codex' }]; + expect((await client.waitFor('presets', () => true, { from })).presets).toEqual(expected); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: expected }); + }); + + it('moves a preset in config.json, answers done, then sends the new order to every open session', async () => { + host.presets = [SHELL, CLAUDE, HTOP]; + host.writeRepos([repo]); + const a = await host.session(); + const b = await host.session(); + const fromB = b.mark(); + expect((await a.hubRequest({ t: 'movePreset', name: 'htop', to: 0 })).m).toEqual({ t: 'done', req: 1 }); + expect((await b.waitFor('presets', () => true, { from: fromB })).presets).toEqual([HTOP, SHELL, CLAUDE]); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [HTOP, SHELL, CLAUDE] }); + }); + + it('answers done without sending presets for a name it does not list', async () => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + expect((await client.hubRequest({ t: 'removePreset', name: 'nope' })).m).toEqual({ t: 'done', req: 1 }); + await client.expectNone('presets', () => true, 300); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it.each([ + ['adding a taken name', { t: 'addPreset', preset: { name: 'shell', command: 'zsh' } }, 'a preset named shell exists'], + ['removing the last preset', { t: 'removePreset', name: 'shell' }, 'the last preset cannot be removed'], + ] as const)('refuses %s with internal, leaving config.json unchanged', async (_name, body, text) => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + const reply = await client.hubRequest(body); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: null, code: 'internal' } }); + expect(reply.m.t === 'error' ? reply.m.message : '').toContain(text); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); +}); + describe('configuration edits', () => { it('refuses to edit an invalid config.json, naming the problem and leaving it unchanged', async () => { const added = makeRepo(join(host.dir, 'added')); @@ -1038,6 +1108,6 @@ describe('configuration edits', () => { const from = client.mark(); expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); await client.waitHost(0, (h) => h.repos.includes(added), { from }); - expect(presetsOf(client)).toEqual([[SHELL]]); + expect(presetsOf(client)).toEqual([DEFAULT_PRESETS]); }); }); diff --git a/test/process/hub-server.test.ts b/test/process/hub-server.test.ts index f78501c..e99a132 100644 --- a/test/process/hub-server.test.ts +++ b/test/process/hub-server.test.ts @@ -12,7 +12,7 @@ import { } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { encodeWsData, MAX_FRAME, PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { encodeWsData, MAX_FRAME, BROWSER_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { sleep, type WtdProcess } from '../support/daemon-host.js'; import { UpgradeRefused, type HubClient, type HubMessageOf } from '../support/hub-client.js'; import { HubHost, type RawResponse } from '../support/hub-host.js'; @@ -563,7 +563,7 @@ describe('session handshake', () => { const client = await host.open(['wtd', `wtd.token.${token}`]); expect(await client.waitFor('hello')).toEqual({ t: 'hello', - protocol: PROTOCOL_VERSION, + protocol: BROWSER_PROTOCOL_VERSION, version: packageVersion(), instance: host.record()?.instance, }); @@ -604,18 +604,30 @@ describe('session handshake', () => { expect(client.messages.slice(from, from + 2).map((m) => m.t)).toEqual(['presets', 'hosts']); }); - it('sends the shell preset alone and no repos without a configuration file', async () => { + it('sends the default presets and no repos without a configuration file', async () => { rmSync(host.configPath); const client = await host.session(); - expect(presetsOf(client)).toEqual([[{ name: 'shell', command: null }]]); + expect(presetsOf(client)).toEqual([ + [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + { name: 'codex', command: 'codex' }, + ], + ]); expect(client.hosts()[0]?.repos).toEqual([]); expect(existsSync(host.configPath)).toBe(false); }); - it('sends the shell preset alone when the configuration has no presets', async () => { + it('sends the shell, claude and codex presets when the configuration has no presets', async () => { host.writeConfig({ port: host.port }); const client = await host.session(); - expect(presetsOf(client)).toEqual([[{ name: 'shell', command: null }]]); + expect(presetsOf(client)).toEqual([ + [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + { name: 'codex', command: 'codex' }, + ], + ]); }); it('sends exactly the configured presets in their order', async () => { @@ -646,7 +658,7 @@ describe('session handshake', () => { it('answers a hello of another protocol with version-mismatch and closes the session', async () => { const client = await host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); - client.sendHello(PROTOCOL_VERSION + 1); + client.sendHello(BROWSER_PROTOCOL_VERSION + 1); expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, diff --git a/test/process/install-local.test.ts b/test/process/install-local.test.ts index 1b301d2..8dbe3de 100644 --- a/test/process/install-local.test.ts +++ b/test/process/install-local.test.ts @@ -101,7 +101,11 @@ describe('wtd install-local', () => { } expect(readdirSync(join(dir, PTY_PACKAGE)).sort()).toEqual(['LICENSE', 'lib', 'package.json', 'prebuilds']); expect(readdirSync(join(dir, PTY_PACKAGE, 'prebuilds')).length).toBeGreaterThan(0); - expect(shim(host.home, ['--version'])).toEqual({ status: 0, stdout: `wtd ${version} (protocol 5)\n`, stderr: '' }); + expect(shim(host.home, ['--version'])).toEqual({ + status: 0, + stdout: `wtd ${version} (daemon protocol 6, browser protocol 8)\n`, + stderr: '', + }); }); it('writes the shim as an owner-only POSIX shell script running the installing Node by absolute path', async () => { @@ -146,7 +150,7 @@ describe('wtd install-local', () => { await shimDaemon(host.home); const pid = await waitUntil(() => daemonPidsOf(host.stateHome)[0], 'the shim daemon'); expect(readlinkSync(`/proc/${String(pid)}/exe`)).toBe(node); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (protocol 5)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); }); }); @@ -175,7 +179,7 @@ describe('launcher', () => { expectInstalled(await installLocal([], { HOME: home })); const [program, ...args] = desktopExec(iniGroup(readFileSync(installPaths(home).launcher, 'utf8'), 'Desktop Entry').get('Exec') ?? ''); const result = spawnSync(program ?? '', [...args.slice(0, -1), '--version'], { env: host.env({ HOME: home }), encoding: 'utf8' }); - expect(result.stdout).toBe(`wtd ${packageVersion()} (protocol 5)\n`); + expect(result.stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); }); it('writes no unit file without --systemd', async () => { @@ -202,7 +206,7 @@ describe('--systemd', () => { ['--user', 'start', UNIT_NAME], ]); await waitUntil(() => canConnect(host.socket), 'the daemon the unit started', 10_000); - expect((await (await client()).waitFor('hello')).protocol).toBe(5); + expect((await (await client()).waitFor('hello')).protocol).toBe(6); }); it('writes an owner-only unit running the shim with daemon, restarting on failure, for default.target', async () => { @@ -310,7 +314,7 @@ describe('reinstalling', () => { const term = await after.create(repo, { command: 'echo after-reinstall; exec sleep 60' }); await after.attach(term.termId); await after.waitOutput(term.termId, 'after-reinstall'); - expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (protocol 5)\n`); + expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 8)\n`); }); it('runs the newest release through the shim after a reinstall', async () => { diff --git a/test/process/install-remote.test.ts b/test/process/install-remote.test.ts index 91184fd..0864fb0 100644 --- a/test/process/install-remote.test.ts +++ b/test/process/install-remote.test.ts @@ -15,7 +15,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { hostPaths } from '../../src/platform/files/index.js'; import { installRemote, type InstalledRemote } from '../../src/platform/install/index.js'; -import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { DaemonClient } from '../support/daemon-client.js'; import { alive, BUNDLE, DaemonHost, waitUntil } from '../support/daemon-host.js'; import { REPO_ROOT } from '../support/exec.js'; @@ -73,7 +73,7 @@ const expectRemoteLayout = (remote: FakeRemote): void => { /** Asserts `alias` bridges to a daemon of this version run by the Node at `node`. */ const expectBridged = async (alias: string, remote: FakeRemote, node: string): Promise => { const hello = await remoteConnect(alias).waitFor('hello', () => true, { timeout: 15_000 }); - expect(hello.protocol).toBe(PROTOCOL_VERSION); + expect(hello.protocol).toBe(DAEMON_PROTOCOL_VERSION); expect(hello.version).toBe(packageVersion()); const [pid, ...others] = await waitUntil(() => { const pids = remote.daemonPids(); diff --git a/test/support/daemon-client.ts b/test/support/daemon-client.ts index 67f0669..d637586 100644 --- a/test/support/daemon-client.ts +++ b/test/support/daemon-client.ts @@ -10,7 +10,7 @@ import { encodeMessage, encodeStreamData, FrameKind, - PROTOCOL_VERSION, + DAEMON_PROTOCOL_VERSION, StreamDecoder, type MessageOf, type Terminal, @@ -199,7 +199,7 @@ export class DaemonClient { } /** Waits for the daemon's hello, then sends ours. */ - async handshake(protocol = PROTOCOL_VERSION): Promise> { + async handshake(protocol = DAEMON_PROTOCOL_VERSION): Promise> { const hello = await this.waitFor('hello'); this.send({ t: 'hello', protocol, version: '0.0.0-test', instance: 'test_client' }); return hello; @@ -234,12 +234,15 @@ export class DaemonClient { } /** Sends a request with the next req and resolves with its correlated reply. */ - async request(body: RequestBody, timeout = DEFAULT_TIMEOUT): Promise> { + async request( + body: RequestBody, + timeout = DEFAULT_TIMEOUT, + ): Promise> { const req = this.nextReq++; const from = this.messages.length; const message: unknown = { ...body, req }; this.sendJson(message); - return this.waitFor(['done', 'error', 'termCreated', 'reposDiscovered'], (m) => m.req === req, { from, timeout }); + return this.waitFor(['done', 'error', 'termCreated', 'reposDiscovered', 'worktreeAtRisk'], (m) => m.req === req, { from, timeout }); } /** A request expected to succeed with `done`. */ diff --git a/test/support/hub-client.ts b/test/support/hub-client.ts index c0954aa..258e78e 100644 --- a/test/support/hub-client.ts +++ b/test/support/hub-client.ts @@ -5,7 +5,7 @@ import { decodeWsData, encodeMessage, encodeWsData, - PROTOCOL_VERSION, + BROWSER_PROTOCOL_VERSION, type MessageOf, type Terminal, } from '../../src/protocol/index.js'; @@ -33,7 +33,7 @@ export interface ReceivedData { /** A reply correlated to a request: a daemon reply inside an envelope, or a hub-level reply. */ export type Reply = - | { from: 'daemon'; host: number; m: DaemonEventOf<'done' | 'error' | 'termCreated' | 'reposDiscovered'> } + | { from: 'daemon'; host: number; m: DaemonEventOf<'done' | 'error' | 'termCreated' | 'reposDiscovered' | 'worktreeAtRisk'> } | { from: 'hub'; m: HubMessageOf<'done' | 'error' | 'reposDiscovered'> }; /** Thrown when the hub answers an upgrade with an HTTP response instead of switching protocols. */ @@ -125,7 +125,7 @@ export class HubClient { } /** Waits for the hub's hello, sends ours, and waits for the first `hosts`. */ - async handshake(protocol = PROTOCOL_VERSION): Promise<{ hello: HubMessageOf<'hello'>; hosts: HubMessageOf<'hosts'> }> { + async handshake(protocol = BROWSER_PROTOCOL_VERSION): Promise<{ hello: HubMessageOf<'hello'>; hosts: HubMessageOf<'hosts'> }> { const hello = await this.waitFor('hello'); const from = this.mark(); this.sendHello(protocol); @@ -133,7 +133,7 @@ export class HubClient { return { hello, hosts }; } - sendHello(protocol = PROTOCOL_VERSION): void { + sendHello(protocol = BROWSER_PROTOCOL_VERSION): void { this.send({ t: 'hello', protocol, version: '0.0.0-test', instance: 'test_browser' }); } diff --git a/vite.config.ts b/vite.config.ts index ff8551d..8609603 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,84 @@ -import { defineConfig } from 'vite'; +import type { ServerResponse } from 'node:http'; +import { defineConfig, type Plugin, type ProxyOptions, type UserConfig } from 'vite'; import pkg from './package.json' with { type: 'json' }; +import { readHubRecord, readToken, requestCode } from './src/hub/server/index.js'; +import { currentHostPaths } from './src/platform/files/index.js'; -export default defineConfig({ - root: 'src/web', - define: { __WTD_VERSION__: JSON.stringify(pkg.version) }, - esbuild: { jsx: 'automatic', jsxImportSource: 'preact' }, - // esbuild's minifier keeps string literals quoted, so the version stays findable in the bundle. - build: { outDir: '../../dist/web', emptyOutDir: true, minify: 'esbuild' }, +const LOGIN_PATH = '/login'; + +/** The dev server's port: `WTD_VITE_PORT`, else 5173. */ +const devPort = (): number => Number(process.env['WTD_VITE_PORT'] ?? '5173'); + +/** The running hub's port and token; `pnpm dev` needs a hub started by `wtd ui` or `wtd hub`. */ +const runningHub = async (): Promise<{ port: number; token: string }> => { + const paths = currentHostPaths(); + const [record, token] = await Promise.all([readHubRecord(paths.hubRecord), readToken(paths.hubToken)]); + if (record === null || token === null) throw new Error('no hub is running; start one with `node dist/wtd.mjs ui`'); + return { port: record.port, token }; +}; + +/** The dev page's own origin. */ +const devOrigin = (): string => `http://127.0.0.1:${String(devPort())}`; + +/** Responses the dev server sends may not be framed, so no other site can overlay the signed-in page. */ +const NO_FRAMES: Readonly> = { 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "frame-ancestors 'none'" }; + +/** Proxies `/ws` to the hub with its Host; only the dev page's own Origin becomes the hub's, so other sites stay refused. */ +const hubProxy = (port: number): Record => { + const target = `http://127.0.0.1:${String(port)}`; + return { + '/ws': { + target, + ws: true, + changeOrigin: true, + configure: (proxy) => { + proxy.on('proxyReqWs', (proxyReq, req) => { + if (req.headers.origin === devOrigin()) proxyReq.setHeader('origin', target); + }); + }, + }, + }; +}; + +/** Whether a request may sign in: anything but a request another site made, which browsers mark `cross-site` or `same-site`. */ +const loginAllowed = (site: string | string[] | undefined): boolean => site === undefined || site === 'none' || site === 'same-origin'; + +/** `/login` gets a fresh one-time code from the hub and redirects to the page with it. */ +const hubLogin = (port: number, token: string): Plugin => ({ + name: 'wtd-hub-login', + apply: 'serve', + configureServer(server) { + server.middlewares.use(LOGIN_PATH, (req, res: ServerResponse) => { + if (!loginAllowed(req.headers['sec-fetch-site'])) { + res.writeHead(403, { 'Content-Type': 'text/plain' }).end('sign in from the address bar, not from another site\n'); + return; + } + requestCode(port, token).then( + (code) => { + res.writeHead(302, { Location: `/#code=${code}` }).end(); + }, + (error: unknown) => { + res.writeHead(502, { 'Content-Type': 'text/plain' }).end(`hub refused a code: ${String(error)}\n`); + }, + ); + }); + server.httpServer?.once('listening', () => { + setTimeout(() => { + server.config.logger.info(` wtd login: http://127.0.0.1:${String(server.config.server.port)}${LOGIN_PATH}`); + }); + }); + }, +}); + +export default defineConfig(async ({ command }): Promise => { + const hub = command === 'serve' ? await runningHub() : null; + return { + root: 'src/web', + define: { __WTD_VERSION__: JSON.stringify(pkg.version) }, + esbuild: { jsx: 'automatic', jsxImportSource: 'preact' }, + // esbuild's minifier keeps string literals quoted, so the version stays findable in the bundle. + build: { outDir: '../../dist/web', emptyOutDir: true, minify: 'esbuild' }, + plugins: hub === null ? [] : [hubLogin(hub.port, hub.token)], + server: hub === null ? {} : { host: '127.0.0.1', port: devPort(), strictPort: true, headers: NO_FRAMES, proxy: hubProxy(hub.port) }, + }; });