mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdccd14c4 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
f1537a7887 | ||
|
|
ac94f339ac | ||
|
|
88f5a43a9f | ||
|
|
aca23aa404 | ||
|
|
f16f294576 | ||
|
|
737a2527d6 | ||
|
|
6f88e40b77 | ||
|
|
2c38e77f8a | ||
|
|
cf26853390 | ||
|
|
192a5994e0 | ||
|
|
566365e127 | ||
|
|
ff94637718 | ||
|
|
2063d15c20 | ||
|
|
fed3a0897a | ||
|
|
d9c760609f | ||
|
|
74e8015783 | ||
|
|
fea5626efc | ||
|
|
bd109d3b16 | ||
|
|
9fa44109b8 | ||
|
|
3e768e1b5b | ||
|
|
92f51fa619 | ||
|
|
06aba94ef1 | ||
|
|
ea80c5f471 | ||
|
|
0c4bb5169f | ||
|
|
b2423c90ce | ||
|
|
4152ee1015 | ||
|
|
90dfa328a8 | ||
|
|
294ce0a667 | ||
|
|
58b52fceff | ||
|
|
4fc75d494f | ||
|
|
948c7c54dd | ||
|
|
cd9218c23e | ||
|
|
db9a39405b | ||
|
|
d1bfbb4fcf | ||
|
|
bd4a1e9886 | ||
|
|
97cb5b5799 | ||
|
|
00b935abe6 | ||
|
|
bfc164a262 | ||
|
|
1b89d7a387 | ||
|
|
d9174a7a03 | ||
|
|
4150707a6b | ||
|
|
6944f842c7 |
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/codeman",
|
"source": "./plugins/codeman",
|
||||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.35.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6f88e40: ### Thanks
|
||||||
|
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
|
||||||
|
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
|
||||||
|
|
||||||
|
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
|
||||||
|
|
||||||
|
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
|
||||||
|
|
||||||
|
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
|
||||||
|
|
||||||
|
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
|
||||||
|
|
||||||
|
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
|
||||||
|
|
||||||
|
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
|
||||||
|
|
||||||
## 1.34.0
|
## 1.34.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.34.0 (must match `package.json`)
|
**Version**: 1.35.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -146,6 +146,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||||
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
|
||||||
|
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
|
||||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
|
||||||
|
|
||||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||||
@@ -257,7 +258,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||||
|
|
||||||
@@ -273,9 +274,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The planned tile grid reuses `TerminalTile` (`docs/tile-grid-plan.md`). → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||||
|
|
||||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||||
|
|
||||||
@@ -302,6 +303,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||||
|
|
||||||
|
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||||
|
|
||||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||||
|
|
||||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||||
@@ -322,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||||
|
|
||||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||||
|
|
||||||
@@ -386,7 +389,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
|
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -444,8 +444,11 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
## More Features
|
## More Features
|
||||||
|
|
||||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||||
|
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||||
|
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||||
|
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
export const BROWSER_TEST_GLOBS = [
|
export const BROWSER_TEST_GLOBS = [
|
||||||
'test/tab-rail-resize.browser.test.ts',
|
'test/tab-rail-resize.browser.test.ts',
|
||||||
'test/tab-activation.browser.test.ts',
|
'test/tab-activation.browser.test.ts',
|
||||||
|
'test/tab-layout-editing.browser.test.ts',
|
||||||
'test/session-sidebar-ux.browser.test.ts',
|
'test/session-sidebar-ux.browser.test.ts',
|
||||||
'test/session-options-responsive.browser.test.ts',
|
'test/session-options-responsive.browser.test.ts',
|
||||||
'test/inline-rename.test.ts',
|
'test/inline-rename.test.ts',
|
||||||
@@ -35,6 +36,9 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/shift-enter-keypress.browser.test.ts',
|
'test/shift-enter-keypress.browser.test.ts',
|
||||||
'test/key-tester.browser.test.ts',
|
'test/key-tester.browser.test.ts',
|
||||||
'test/webhook-settings.browser.test.ts',
|
'test/webhook-settings.browser.test.ts',
|
||||||
|
'test/case-custom-path.browser.test.ts',
|
||||||
|
'test/doctor-settings.browser.test.ts',
|
||||||
|
'test/git-status.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.browser.test.ts',
|
'test/split-pane-auto-collapse.browser.test.ts',
|
||||||
'test/mobile-ime-preview.browser.test.ts',
|
'test/mobile-ime-preview.browser.test.ts',
|
||||||
|
|||||||
@@ -700,6 +700,44 @@ normal `caseName`/`mode`/etc. body)
|
|||||||
jarring than a full relaunch, and folding it into the one-shot path is
|
jarring than a full relaunch, and folding it into the one-shot path is
|
||||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||||
|
|
||||||
|
## Creating a case in a custom folder
|
||||||
|
|
||||||
|
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
|
||||||
|
|
||||||
|
The target is judged before anything is written:
|
||||||
|
|
||||||
|
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
|
||||||
|
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
|
||||||
|
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
|
||||||
|
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
|
||||||
|
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
|
||||||
|
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
|
||||||
|
|
||||||
|
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||||
|
|
||||||
|
## Git status
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
|
||||||
|
|
||||||
|
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||||
|
|
||||||
|
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||||
|
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||||
|
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||||
|
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||||
|
|
||||||
|
`data` is `{ state, repos, reposTruncated, checkedAt }`:
|
||||||
|
|
||||||
|
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||||
|
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||||
|
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
|
||||||
|
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
|
||||||
|
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
|
||||||
|
|
||||||
|
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||||
|
|
||||||
## CLI management
|
## CLI management
|
||||||
|
|
||||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||||
@@ -748,6 +786,10 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
|
|||||||
|
|
||||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||||
|
|
||||||
## Voice dictation
|
## Voice dictation
|
||||||
|
|
||||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
|||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||||
|
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||||
|
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||||
|
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||||
|
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||||
|
> closes anything (Close Session has no default key).
|
||||||
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||||
|
|||||||
@@ -0,0 +1,860 @@
|
|||||||
|
# Tile Grid: Design Spec
|
||||||
|
|
||||||
|
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
|
||||||
|
**Author**: Claude (planning session with the maintainer), 2026-10-06
|
||||||
|
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
|
||||||
|
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Codeman's terminal area shows exactly one session at a time. The split pane
|
||||||
|
(`showSplitButton`, `src/web/public/terminal-split.js`) added a second live
|
||||||
|
session beside the active one, but stops at two, gives the second pane
|
||||||
|
("Pane B") a deliberately reduced feature set, and has no reconnect: every
|
||||||
|
Codeman restart (every release deploy) leaves Pane B dead until the split is
|
||||||
|
closed and reopened.
|
||||||
|
|
||||||
|
The goal is a dashboard of agents: four, six or nine live Claude sessions on
|
||||||
|
one monitor, each readable and typeable, with its state visible at a glance.
|
||||||
|
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
|
||||||
|
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
|
||||||
|
|
||||||
|
## Goal (v1)
|
||||||
|
|
||||||
|
- A **tile grid** of 1 to 9 live sessions in one Codeman window, laid out
|
||||||
|
automatically by count, with draggable column and row dividers.
|
||||||
|
- Every tile is equal: same terminal class, same features, same header. One
|
||||||
|
tile is **focused** and receives the keyboard.
|
||||||
|
- The rest of the app follows the focused tile: files panel, git status,
|
||||||
|
respawn and Ralph panels, subagent windows, voice, image paste.
|
||||||
|
- Tiles survive a Codeman restart (reconnect) and a page reload (per-device
|
||||||
|
persistence).
|
||||||
|
- The split pane stays as it is for now (decided). The grid is a separate
|
||||||
|
mode that shares the same tile class with it; the two are never open at the
|
||||||
|
same time (see "Coexistence with the split pane").
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
|
||||||
|
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
|
||||||
|
- More than 9 tiles.
|
||||||
|
- WebGL rendering inside tiles (see "Rendering" below).
|
||||||
|
- Full parity with the main terminal's touch and IME features: local-echo
|
||||||
|
overlay, the CJK input textarea, the keyboard accessory bar, touch gestures,
|
||||||
|
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
|
||||||
|
history" banner. These exist for touch devices or rare cases; a desktop
|
||||||
|
keyboard user types straight into xterm, which is how Codeman behaved before
|
||||||
|
those features existed.
|
||||||
|
- Server-side persistence of grids (named presets per owner).
|
||||||
|
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
|
||||||
|
|
||||||
|
## Current architecture (why this is not a CSS change)
|
||||||
|
|
||||||
|
`terminal-ui.js` is built around ONE terminal: `this.terminal` (one xterm),
|
||||||
|
`this._ws` / `this._wsSessionId` (one WebSocket, rebound on every tab switch
|
||||||
|
via `_disconnectWs()` + `_connectWs(id)`), `this._xtermSnapshots` (scrollback
|
||||||
|
snapshots restored into that one terminal), and about 280 references to that
|
||||||
|
singleton across input handling, sizing, link providers, local echo, IME,
|
||||||
|
touch handling and the keyboard accessory bar.
|
||||||
|
|
||||||
|
The split pane works around it with a second, independent object,
|
||||||
|
`SplitTerminalPane`: its own xterm, its own fit addon, its own
|
||||||
|
`/ws/sessions/:id/terminal` socket. That class is the seed of this feature.
|
||||||
|
It already handles, and this design keeps:
|
||||||
|
|
||||||
|
- single-flight buffer loads (`_loadBuffer` / `_refreshBuffer` /
|
||||||
|
`_endBufferLoad`), so two replays never interleave;
|
||||||
|
- the "disconnected" marker that must be the LAST thing on screen
|
||||||
|
(`_markerOwed`, `_stampMarkerIfOwed`);
|
||||||
|
- the bounded scroll-to-top history pull for shell sessions (`_pullHistory`);
|
||||||
|
- its own key handler gating app chords (palette, Alt nav, Ctrl+Z, Shift/Ctrl+Enter
|
||||||
|
via `send-key`, smart copy);
|
||||||
|
- divider drag with pointer capture, rAF-coalesced local fit, one PTY resize at
|
||||||
|
pointer-up, and teardown when the split collapses mid-drag.
|
||||||
|
|
||||||
|
## Key decision: equal tiles, main terminal parked
|
||||||
|
|
||||||
|
Three ways to put N sessions on screen were evaluated:
|
||||||
|
|
||||||
|
| Approach | How | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| A. Anchor plus light tiles | The main terminal stays as tile 1; other tiles are Pane-B style | Rejected. Tile 1 is privileged. The panels follow tile 1, not the tile you are typing in. Making another tile the "main" one costs two buffer reloads per click. |
|
||||||
|
| B. Equal tiles, main parked | Every tile is a `TerminalTile`. The main terminal is hidden and disconnected while the grid is open. `activeSessionId` always equals the focused tile's session. | **Chosen** |
|
||||||
|
| C. iframes of solo windows | Each tile is `/session/:id` in an iframe | Rejected. All frames share `codeman:clientId` (localStorage), and `SseStreamManager.addClient` evicts the previous stream with the same clientId, so frames knock each other's SSE offline about every 45 s (the staleness watchdog reconnects, evicting the next one). They also share `codeman:pendingInput`, load N full copies of the app, and play N notification sounds. |
|
||||||
|
|
||||||
|
Why B:
|
||||||
|
|
||||||
|
- **Focus changes are instant.** Moving focus is `xterm.focus()` plus an
|
||||||
|
`activeSessionId` update. No fetch, no replay, no flicker.
|
||||||
|
- **Panels follow focus for free.** Everything keyed on `activeSessionId`
|
||||||
|
(files panel, git status poll, respawn and Ralph panels, subagent window
|
||||||
|
visibility, voice, image upload, kill/close, tab highlight) follows the
|
||||||
|
focused tile once the tile branch of `selectSession` runs the same panel
|
||||||
|
refresh as a normal switch.
|
||||||
|
- **Hiding the main terminal is already proven safe.** Web tabs set
|
||||||
|
`display:none` on `.terminal-wrap` today (`.main.webview-active`). With the
|
||||||
|
container hidden, FitAddon's `proposeDimensions()` reads `auto` widths and
|
||||||
|
returns NaN, `clampTerminalDimensions` returns null, and no resize is sent.
|
||||||
|
- **The end state is clean.** Long term, the main terminal can itself become a
|
||||||
|
1x1 grid of the same class, which deletes the singleton. B moves toward that;
|
||||||
|
A entrenches it.
|
||||||
|
|
||||||
|
The cost of B is that tiles must reach desktop parity with the main terminal
|
||||||
|
on the features that matter at a desk: reliable input, reconnect, file-path
|
||||||
|
links, copy, image paste, voice. Those are listed under "Seams". Most of that
|
||||||
|
work also fixes gaps the split pane has today.
|
||||||
|
|
||||||
|
## User-facing behavior
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
- **Header Tiles button** (its own button, beside Split). Opens a picker with
|
||||||
|
checkboxes over open sessions, ordered like the tab strip. When the grid is
|
||||||
|
open, the button toggles it closed.
|
||||||
|
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
||||||
|
closed).
|
||||||
|
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
||||||
|
slot to add it.
|
||||||
|
- **"Open group as tiles"** in the tab-group menu (vertical rail, where group
|
||||||
|
menus exist).
|
||||||
|
- **New sessions started from this browser tab's Run button** while the grid is
|
||||||
|
open join the next free slot and take focus. Sessions created elsewhere
|
||||||
|
(agents, other devices, cron) do not join.
|
||||||
|
- **Toggle shortcut** (registry action `toggleTileGrid`).
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Automatic by tile count, computed by a pure helper:
|
||||||
|
|
||||||
|
| Tiles | Layout |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 1x1 |
|
||||||
|
| 2 | 2x1 |
|
||||||
|
| 3 | 3x1 if the grid area is at least ~1800 px wide, else 2x2 with one empty slot |
|
||||||
|
| 4 | 2x2 |
|
||||||
|
| 5-6 | 3x2 |
|
||||||
|
| 7-9 | 3x3 |
|
||||||
|
|
||||||
|
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
|
||||||
|
roughly 60 columns at the default tile font), so the picker disables additions
|
||||||
|
the window cannot fit.
|
||||||
|
|
||||||
|
Column and row dividers are draggable (generalizing the split divider): the
|
||||||
|
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
|
||||||
|
drag clamps both neighbors to the minimum tile size, reflows locally per
|
||||||
|
animation frame, and sends one resize per affected tile at pointer-up.
|
||||||
|
|
||||||
|
### Tile header
|
||||||
|
|
||||||
|
`● name ……… ⋯ ⤢ + ×`
|
||||||
|
|
||||||
|
- **●** status dot from the existing six-state classifier
|
||||||
|
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
|
||||||
|
`needs`, `error`, `waiting`, `working`, `idle`, `done`, styled with the
|
||||||
|
existing unscoped `.home-sessions-dot--*` classes. A `needs` tile also gets a
|
||||||
|
pulsing red border so a permission prompt is visible across the room. The
|
||||||
|
label ("working 3m") shows on hover via `_mobileOverviewSince` /
|
||||||
|
`_mobileOverviewStampText`.
|
||||||
|
- **name** via `textContent` with `data-i18n-skip` (a session literally named
|
||||||
|
"Sessions" must not be translated). Double-click renames through
|
||||||
|
`_queueInlineSessionName(id, name)`.
|
||||||
|
- **⋯** reuses `openTabRailActionMenu(event, id)` (tab-rail-resize.js): Session
|
||||||
|
options, Open in a new window, Close session.
|
||||||
|
- **⤢** zooms the tile to fill the grid, like tmux zoom. The other tiles stay
|
||||||
|
connected but hidden; hidden tiles measure NaN and send no resizes. Pressing
|
||||||
|
it again (or the shortcut) restores the grid.
|
||||||
|
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
|
||||||
|
session in this case", which runs the normal quick-start for the tile's case
|
||||||
|
and drops the result into the next slot.
|
||||||
|
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
|
||||||
|
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
|
||||||
|
|
||||||
|
### Focus and keyboard
|
||||||
|
|
||||||
|
- The focused tile gets an accent border; its session is `activeSessionId`.
|
||||||
|
- Tabs of tiled sessions carry an `.in-tiles` marker; the focused one is
|
||||||
|
`.active` as usual.
|
||||||
|
- Clicking a tile focuses it (a human selection, see "Focus and alert rules").
|
||||||
|
- New registry actions (all rebindable, all swallowed in every xterm key
|
||||||
|
handler so the chord never reaches a PTY):
|
||||||
|
- `toggleTileGrid` (proposed Ctrl+Shift+G)
|
||||||
|
- `focusTileLeft/Right/Up/Down` (proposed Alt+Shift+Arrows)
|
||||||
|
- `zoomTile` (proposed Alt+Shift+Enter)
|
||||||
|
- `removeTile` (unbound by default)
|
||||||
|
|
||||||
|
The defaults must be checked against xterm passthrough, Claude Code's own
|
||||||
|
bindings and browser chords before they are fixed.
|
||||||
|
- While the grid is open, Ctrl+Tab and Alt+[ / Alt+] cycle through tiles.
|
||||||
|
- A USER-initiated selection of a session that is NOT tiled (clicking its tab,
|
||||||
|
Alt+1-9 onto it, the command palette) leaves the grid and shows that session
|
||||||
|
in the normal single view; the grid is remembered and one click on Tiles
|
||||||
|
brings it back (decision 1). An app-driven selection (`auto: true`)
|
||||||
|
never collapses the grid; see "Selections while the grid is open".
|
||||||
|
- Ctrl+L clears the focused tile; Ctrl+W is delete-word in the focused tile
|
||||||
|
(it is not an app shortcut, decision 5); Ctrl +/-
|
||||||
|
changes the tile font size; Ctrl+Shift+R restores the focused tile's size.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Decided: per device, restored on reload. Stored in localStorage key
|
||||||
|
`codeman:tile-grid`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
|
||||||
|
"colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
|
||||||
|
duplicate ids on load. Never restored in a solo window.
|
||||||
|
|
||||||
|
The restore runs INSIDE `handleInit`, in place of its initial
|
||||||
|
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
||||||
|
after it. Otherwise the page first selects the active session in the main
|
||||||
|
terminal, whose first non-shell select per page pulls an unbounded `?full=1`
|
||||||
|
capture, only to park that terminal a moment later. With a stored open grid,
|
||||||
|
the main terminal never loads on that page load. A later `handleInit` (SSE
|
||||||
|
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
||||||
|
against the live list without rebuilding tiles that are still alive.
|
||||||
|
|
||||||
|
### Gating
|
||||||
|
|
||||||
|
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
||||||
|
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
|
||||||
|
`showSplitButton`, which is unchanged; a desk can show both buttons.
|
||||||
|
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
|
||||||
|
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
|
||||||
|
Narrowing the window while the grid is open returns to the single view and
|
||||||
|
keeps the stored grid.
|
||||||
|
- Hidden in solo windows (`body.solo-mode`).
|
||||||
|
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. `TerminalTile` (`terminal-tile.js`, load order 7.4, PR 1)
|
||||||
|
|
||||||
|
The `SplitTerminalPane` class moves out of `terminal-split.js` into a new
|
||||||
|
`src/web/public/terminal-tile.js` and is renamed `TerminalTile`, keeping every
|
||||||
|
behavior listed under "Current architecture". The split orchestration stays in
|
||||||
|
`terminal-split.js` and constructs a `TerminalTile` for Pane B; the grid (PR 2)
|
||||||
|
constructs one per tile. New in the class:
|
||||||
|
|
||||||
|
**Reconnect.** The primary pane's backoff ladder (`CodemanWsReconnect`,
|
||||||
|
constants.js: 0, 250 ms, 500 ms, ... capped at 10 s) plus up to 250 ms of
|
||||||
|
jitter, the attempt count reset only by a successful open. A reconnect is also kicked when SSE `handleInit` reports the
|
||||||
|
server is back. After every reopen the tile runs a bounded refresh (the same
|
||||||
|
in-stream `\x1bc` clear plus replay as `_refreshBuffer`), because output
|
||||||
|
frames carry no sequence number and a gap cannot be replayed otherwise. That
|
||||||
|
refresh goes through the grid's load queue like every other load (see "Load
|
||||||
|
cost"): after a deploy restart all N tiles reopen within the same second, and
|
||||||
|
N unqueued refreshes are exactly the capture storm the queue exists to
|
||||||
|
prevent. Close codes that must NOT reconnect:
|
||||||
|
|
||||||
|
| Code | Meaning | Tile does | Owner decides (via `onExit`) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4009 | Session exited | Stops reconnecting, reports the code | PR 1 split: an "exited" marker in Pane B (the split's existing delete path collapses it if the session is removed). PR 2 grid: the Attach overlay |
|
||||||
|
| 4003 / 4004 | Forbidden / session gone | Stops reconnecting, reports the code (4003 is stopped by the tile itself: `CodemanWsReconnect` classes it as transient) | PR 1 split: a marker saying why. PR 2 grid: removes the tile |
|
||||||
|
| 4010 | Superseded by a socket with the same cid | Stops, but only for the CURRENT socket (see below) | Same marker as 4003 |
|
||||||
|
|
||||||
|
The class never decides what happens to its container; it reports the close
|
||||||
|
code through the `onExit` callback and the owner (split or grid) acts.
|
||||||
|
|
||||||
|
**Replacing a socket is race-free.** A reconnect can be kicked (by
|
||||||
|
`handleInit`) while the old socket still looks open on the client: a half-open
|
||||||
|
connection whose `onclose` has not fired yet. The new socket carries the same
|
||||||
|
cid, so the server supersedes the old one with a 4010, and that late `onclose`
|
||||||
|
would run the "4010: stop" branch on a perfectly healthy tile. So before
|
||||||
|
opening a replacement the tile detaches the old socket's handlers (as
|
||||||
|
`destroy()` already does: null `onopen`/`onmessage`/`onclose`/`onerror`, then
|
||||||
|
`close()`), and every handler checks `event.target === this.ws` and ignores
|
||||||
|
events from any socket that is no longer current.
|
||||||
|
|
||||||
|
The marker text becomes `[disconnected, reconnecting…]` (a stop writes
|
||||||
|
`[disconnected: <why>]`, `TerminalTile.STOP_MARKERS`) and keeps its "last
|
||||||
|
thing on screen" rule. On reopen the closed state is cleared BEFORE the gap
|
||||||
|
refresh, or the refresh re-owes the marker and stamps it under a healthy
|
||||||
|
pane. `reconnectNow()` skips the backoff and never replaces an open socket.
|
||||||
|
|
||||||
|
**Client id on the upgrade URL.** `cid=${clientId}:${tabNonce}:tile`. The
|
||||||
|
connection registry supersedes by cid PER SESSION, so a distinct suffix means a
|
||||||
|
tile can never evict the main terminal's socket in a 4010 loop even if the same
|
||||||
|
session were ever on both. Input frames keep the BARE `clientId` (that is what
|
||||||
|
the server dedups on).
|
||||||
|
|
||||||
|
**Reliable input.** `terminal.onData` calls
|
||||||
|
`app._sendInputAsync(this.sessionId, data)`, which rides the tile's own socket
|
||||||
|
through the input-socket map (see "Seams"). This gives the tile the same
|
||||||
|
exactly-once delivery as the main terminal (per-session `seq`, ACKed, persisted
|
||||||
|
until ACK), and through `_ackDelivery` it clears the session's idle alert when
|
||||||
|
you type, which Pane B never did.
|
||||||
|
|
||||||
|
**Geometry** (rules from #464, see `docs/architecture-invariants.md`):
|
||||||
|
|
||||||
|
- One method, `syncGeometry()`, measures (`proposeDimensions()`), resizes the
|
||||||
|
xterm and sends `{t:'z', c, r, v:'desktop'}` with THE SAME raw dimensions.
|
||||||
|
The xterm and the PTY never disagree. There is deliberately NO 40x10 floor
|
||||||
|
(unlike the main terminal's `clampTerminalDimensions`): commit `57406f6c`
|
||||||
|
removed it from Pane B because the split's 20% divider clamp can leave Pane
|
||||||
|
B at about 240 px, roughly 28 columns, and a floored xterm is wider than its
|
||||||
|
container and clips columns. The server's own range (columns 1-500, rows
|
||||||
|
1-200) is the only bound. Grid tiles never get that narrow anyway (the
|
||||||
|
minimum tile size keeps them near 60 columns).
|
||||||
|
- Unchanged dimensions are not resent (Pane B had no such dedupe and fanned out
|
||||||
|
a `tmux resize-window` per animation frame during drags before the rAF fix).
|
||||||
|
- The `{t:'zc'}` reply is handled: adopt the PTY's COLUMNS only, keep local
|
||||||
|
rows, via the pure `reconcilePtyGeometry` (constants.js). Pane B ignores
|
||||||
|
`zc` today.
|
||||||
|
- A hidden tile (zoomed out, web tab active) measures NaN and does nothing; it
|
||||||
|
syncs when shown again.
|
||||||
|
- Detached sessions are never tiled, so the split's `detachedSessions` yield in
|
||||||
|
`_sendResize` becomes a removal (see "Edge cases").
|
||||||
|
|
||||||
|
**Rendering.** DOM renderer, no WebGL addon, as Pane B does today. Chrome
|
||||||
|
allows roughly 16 live WebGL contexts per page and the main terminal keeps
|
||||||
|
one. Measure nine busy tiles on the DOM renderer before considering WebGL
|
||||||
|
(follow-up).
|
||||||
|
|
||||||
|
**Scrollback.** Tiles use their own cap, `TILE_SCROLLBACK` (proposed 10,000
|
||||||
|
lines), not `DEFAULT_SCROLLBACK` (50,000). Nine DOM-rendered xterms at 50k
|
||||||
|
lines each is a real memory cost, and a tile's initial load is already bounded
|
||||||
|
to a 1 MiB window, so a larger buffer only fills with live output over time.
|
||||||
|
The shell history pull's "pane full" check reads `term.options.scrollback`, so
|
||||||
|
it adapts to the lower cap unchanged.
|
||||||
|
|
||||||
|
**Key handler.** Pane B's `attachCustomKeyEventHandler` stays in
|
||||||
|
`TerminalTile` (every tile IS a `TerminalTile`, so no separate factory is
|
||||||
|
needed), plus:
|
||||||
|
|
||||||
|
- Ctrl+V routes into the image-paste trap with this tile's terminal and session;
|
||||||
|
- the new tile chords are swallowed (return false) so they never reach the PTY.
|
||||||
|
|
||||||
|
**Links and copy.** The tile registers the file-path link provider and uses the
|
||||||
|
shared copy helper (see "Seams"), so clicking a path printed in a tile opens the
|
||||||
|
file preview for THAT session.
|
||||||
|
|
||||||
|
**Disposal.** `destroy()` closes the socket (handlers nulled first, as today),
|
||||||
|
unregisters from the input-socket map, removes listeners and disposes the
|
||||||
|
xterm. Nothing may outlive a removed tile (24-hour sessions rule).
|
||||||
|
|
||||||
|
### 2. `TileGrid` controller and layout helper
|
||||||
|
|
||||||
|
- `computeTileLayout({ count, width, height, minTileW, minTileH })` and
|
||||||
|
`tileGridCapacity(...)`: pure, in `constants.js`, exported on
|
||||||
|
`window.CodemanTileGrid` beside the existing helper namespaces.
|
||||||
|
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
|
||||||
|
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
|
||||||
|
`CodemanApp.prototype` methods like the split code) owns: the ordered tile list, `focusedId`,
|
||||||
|
`zoomedId`, track fractions, the `<section class="tile-grid">` element, one
|
||||||
|
`ResizeObserver` on that section (the main terminal's observer watches a
|
||||||
|
hidden node and stops firing), the divider drags and the load queue.
|
||||||
|
- DOM: the grid is a NEW sibling of `.terminal-wrap` inside `.main`, toggled by
|
||||||
|
`.main.tiles-active`. Unlike the split there is no reparenting of
|
||||||
|
`.terminal-wrap`. CSS adds `.main.webview-active .tile-grid { display: none }`
|
||||||
|
beside the existing `.terminal-split-container` rule.
|
||||||
|
|
||||||
|
### 3. Parking the main terminal
|
||||||
|
|
||||||
|
**Enter:**
|
||||||
|
|
||||||
|
1. `_cleanupPreviousSession()` once. Its snapshot of the current session is
|
||||||
|
correct at that moment, and it disconnects the main socket and flushes local
|
||||||
|
echo.
|
||||||
|
2. Add `.main.tiles-active`: `.terminal-wrap` hidden, `.tile-grid` shown.
|
||||||
|
3. `hideWelcome()`.
|
||||||
|
|
||||||
|
**Guards.** With the main socket closed, `_wsReady` is false and the SSE
|
||||||
|
terminal fallback would start writing the focused session's output into the
|
||||||
|
hidden xterm (every handler keys on `activeSessionId`). One predicate,
|
||||||
|
`_tilesOwnTerminal()`, turns these into no-ops while the grid is open:
|
||||||
|
|
||||||
|
- `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh`
|
||||||
|
(it would fetch `?full=1` for nothing), `_scheduleDroppedOutputRecovery`;
|
||||||
|
- the `terminal.writeln` calls in `_onSessionCompletion` and `_onSessionError`;
|
||||||
|
- `retryConnection` and the `keepTerminal` branch of `handleInit`, which would
|
||||||
|
reconnect the main socket;
|
||||||
|
- as a backstop, beside the existing `detachedSessions` checks in
|
||||||
|
`sendResize`, `throttledResize`, `_maybeRefetchFullHistory` and
|
||||||
|
`restoreTerminalSize`;
|
||||||
|
- the WebGL long-task guard (`_installWebGLLongTaskGuard`). Its
|
||||||
|
`PerformanceObserver` watches the WHOLE page and counts every long task while
|
||||||
|
the main terminal's WebGL addon exists, so long tasks caused by tile rendering
|
||||||
|
or tile replays would trip it and write the sticky `codeman-webgl-disabled`
|
||||||
|
marker (7 days), silently moving the main terminal to the DOM renderer for
|
||||||
|
reasons that have nothing to do with WebGL. While tiles own the terminal the
|
||||||
|
observer callback must not count entries.
|
||||||
|
|
||||||
|
A missed guard is mostly harmless (exit replays from scratch) but costs fetches
|
||||||
|
and CPU, so the guard test enumerates them.
|
||||||
|
|
||||||
|
`_computeConnectionDescriptor` must derive the header connection state from the
|
||||||
|
tile sockets while the grid is open (all open: connected; any reconnecting:
|
||||||
|
degraded), or the header shows "Connecting" forever.
|
||||||
|
|
||||||
|
The SSE subscription stays `[activeSessionId]` as in single view. Those frames
|
||||||
|
are dropped by the guards. (An empty list means "all sessions", so there is no
|
||||||
|
"none" to subscribe to; this matches today's single-view duplication anyway.)
|
||||||
|
|
||||||
|
**Exit:**
|
||||||
|
|
||||||
|
1. Destroy every tile, remove `.main.tiles-active`.
|
||||||
|
2. `this._lastResizeDims = null` (as `_redock` does).
|
||||||
|
3. Invalidate the main terminal's cached content for EVERY tiled id, not just
|
||||||
|
the focused one: the `_xtermSnapshots` entry, the `codeman-xs-<id>`
|
||||||
|
localStorage key and the buffer-cache entry. Those were written before the
|
||||||
|
grid opened, possibly hours earlier, and `selectSession` paints a snapshot as
|
||||||
|
a first frame before its fetch replaces it, so the next switch to a formerly
|
||||||
|
tiled session would flash content from before the grid.
|
||||||
|
4. `selectSession(focusedId, { forceReload: true, auto: true })`. For the
|
||||||
|
already-active id that path drops the stale snapshot and nulls
|
||||||
|
`activeSessionId` BEFORE `_cleanupPreviousSession`, so nothing wrong is
|
||||||
|
saved, then reconnects and replays normally.
|
||||||
|
|
||||||
|
### 4. The tile branch of `selectSession`
|
||||||
|
|
||||||
|
Placed in `app.js` directly after the "already active" early return (~7876),
|
||||||
|
so tapping the focused tab still acknowledges its alert and the detached-window
|
||||||
|
check (~7855) still runs first:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
|
||||||
|
// Decision 1: only a USER-initiated pick (or an explicit leaveTiles) of a
|
||||||
|
// non-tiled session leaves the grid. An app-driven one never collapses it.
|
||||||
|
if (options.auto === true && !options.leaveTiles) return;
|
||||||
|
this.closeTileGrid({ keepStored: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_selectTiledSession` keeps:
|
||||||
|
|
||||||
|
- `++this._selectGeneration` (aborts any in-flight normal select at its next
|
||||||
|
`_isStaleSelect` check);
|
||||||
|
- `_hideWebviewLayer()`; `activeSessionId = id`; `_activateFileBrowserSession`;
|
||||||
|
the `codeman-active-session` key; `hideWelcome()`;
|
||||||
|
- `markIdleAlertSeen(id)` only when user-initiated (`options.auto !== true`);
|
||||||
|
- `_updateActiveTabImmediate`, tab glow, `renderSessionTabs`,
|
||||||
|
`closeSessionSidebarOnHandheld`;
|
||||||
|
- `updateAttachmentHistoryBadge`, `KeyboardAccessoryBar.refreshForActiveSession`,
|
||||||
|
`refreshHostWakeBanner`, `currentSessionWorkingDir`;
|
||||||
|
- the deferred panel block (respawn banner and countdown, action log, task
|
||||||
|
panel, Ralph state, CLI info, project insights, subagent window visibility,
|
||||||
|
file browser). That block (~8452-8523) is first extracted into
|
||||||
|
`_refreshSessionPanels(id, generation)` so both paths share one copy;
|
||||||
|
- focusing the tile's xterm.
|
||||||
|
|
||||||
|
It skips everything bound to the main terminal: `terminal.focus()`,
|
||||||
|
`_cleanupPreviousSession`, the truncation banner, `playTerminalEntrance`,
|
||||||
|
local-echo state, `_beginBufferLoad`, `syncTerminalGeometry`, both
|
||||||
|
`sendResize` calls, snapshot/cache/fetch replay, `_fullHistoryLoaded`,
|
||||||
|
`_markTerminalBufferReconciled`, `_connectWs`, scroll-to-bottom, the resize
|
||||||
|
retry, and the `pid === null` attach POST (the tile's Attach overlay owns it).
|
||||||
|
|
||||||
|
The split's own `selectSession` and `_onSessionDeleted` wrappers stay. They
|
||||||
|
act only while `this._splitPane` is set, and the grid never opens alongside a
|
||||||
|
split (see "Coexistence with the split pane"), so the two never compete.
|
||||||
|
|
||||||
|
#### Selections while the grid is open
|
||||||
|
|
||||||
|
Several app-driven paths call `selectSession` on their own, and without the
|
||||||
|
`auto` rule above each of them would land on a non-tiled session and collapse
|
||||||
|
the grid. Each one gets an explicit grid-aware behavior:
|
||||||
|
|
||||||
|
| Path | Today | With the grid open |
|
||||||
|
|---|---|---|
|
||||||
|
| Close session on the focused tile (`closeSession`, from its menu or a user-bound key) | Reads `wasActive` before its `await`, adds the id to `_closingSessions`, then selects the first remaining `sessionOrder` entry with `auto: true`, which is often NOT tiled | A grid-aware fallback picker: remove the tile, then focus the neighboring tile (next in grid order, else previous). Only with no tiles left does it fall back to the `sessionOrder` pick, which closes the grid. Note the split's `_onSessionDeleted` wrapper deliberately skips selection for ids in `_closingSessions`, so the fallback MUST live in `closeSession` itself, not in the delete wrapper. |
|
||||||
|
| Session deleted elsewhere (`_onSessionDeleted`) | The handoff selects the first remaining `sessionOrder` entry | If it was tiled: remove the tile and focus a neighbor with `auto: true`. If it was not tiled it was not active, so there is no handoff. |
|
||||||
|
| Boot restore (`handleInit`) | `selectSession(restoreId, { auto: true })` | Replaced by the grid restore when a stored grid is open (see "Persistence") |
|
||||||
|
| URL `#session=<id>` link | `selectSession(id, { auto: true })` | Following a link is navigation, so this path passes `leaveTiles: true`: a tiled id focuses its tile, a non-tiled id opens the single view (grid kept in storage) |
|
||||||
|
| Pane promotion after a delete (split) | `selectSession(promoted, { auto: true })` | Unchanged for the split; unreachable while the grid is open, since no split can be open then |
|
||||||
|
| Auto-join of a session created by this tab's Run | Run selects the new session | The session joins the grid first, then is selected through the tile branch |
|
||||||
|
|
||||||
|
### 5. Focus and alert rules
|
||||||
|
|
||||||
|
These follow the Approvals Inbox acknowledgement rule
|
||||||
|
(`docs/architecture-invariants.md#approvals-inbox`):
|
||||||
|
|
||||||
|
| Event | Acknowledges the idle alert? |
|
||||||
|
|---|---|
|
||||||
|
| Pointerdown on a tile, a tile-nav chord, a click on its tab | Yes (human selection) |
|
||||||
|
| Typing into a tile | Yes, via `_ackDelivery` on the input ACK |
|
||||||
|
| Opening the grid, restoring it, promoting a neighbor after a delete, auto-join of a new session | No (`auto: true`) |
|
||||||
|
| A tile merely being visible | No |
|
||||||
|
| Anything | Never clears permission/question (action) alerts; those clear only on resolution |
|
||||||
|
|
||||||
|
Notifications are unchanged: a visible but unfocused tile still raises its
|
||||||
|
sound/title/desktop notification, so nothing is silently swallowed.
|
||||||
|
|
||||||
|
### 6. Seams (small refactors, no behavior change on their own)
|
||||||
|
|
||||||
|
| Feature | Today | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| Input socket | `_drainSession`, `_sendInputEphemeral`, `_redeliverSweep` and `_onWsInputAck` assume the single `_ws` / `_wsSessionId` / `_wsLastRecvAt` | An `_inputSocketFor(sessionId)` map that the main terminal and each tile register into (`{ ws, ready(), lastRecvAt }`). `{t:'ia'}` acks carry no session id, so each socket's `onmessage` passes its own: `_onWsInputAck(seq, msg, sessionId)`. Without the map, tile input still works through the HTTP POST fallback (durable, but one awaited POST per record). |
|
||||||
|
| File-path links | `registerFilePathLinkProvider()` reads `self.terminal` and opens with `self.activeSessionId` | `registerFilePathLinkProvider(terminal = this.terminal, getSessionId = () => this.activeSessionId)` returning the provider; about five references change |
|
||||||
|
| Copy | `copyTerminalSelection` / `cleanedTerminalSelection` read `this.terminal`; Pane B re-implements them | Both take `(terminal, sessionId)`; Pane B's copy is deleted |
|
||||||
|
| Image paste | `_handleImagePaste()` uses the main terminal; `_uploadAndInsertImages` inserts with `sendInput()`, which re-reads `activeSessionId` AFTER the upload (an existing bug: switch tabs mid-upload and the paths land in the wrong session) | `_handleImagePaste({ terminal, sessionId })`; insert with `_sendInputAsync(sessionId, paths, { useMux: true })` |
|
||||||
|
| Voice | `_insertText` re-reads `app.activeSessionId` at insert time and appends to the main local-echo overlay | Capture the target in `start()`; send with `_sendInputAsync(target, …)`; skip the overlay when the target is not the main terminal |
|
||||||
|
| Shortcuts | Ctrl+L (`clearTerminal`) and Ctrl+Shift+R (`restoreTerminalSize`) act on `this.terminal` | Resolve through `_focusedPane()` returning `{ terminal, sessionId, isPrimary }`; Close Session (no default key) already takes an id |
|
||||||
|
| Font, family, weight, skin | `setFontSize` / `setFontFamily` / `setFontWeight` / `applyTerminalSkin` special-case `this._splitPane` | Loop over all tiles |
|
||||||
|
|
||||||
|
### 7. Fonts
|
||||||
|
|
||||||
|
Tiles get their own per-device font size, `codeman-tile-font-size` (default
|
||||||
|
13), because a tile is a fraction of the screen. While the grid is open,
|
||||||
|
Ctrl +/- changes the tile font for all tiles. A font change is a geometry
|
||||||
|
change (#464): every tile re-runs `syncGeometry()` afterwards.
|
||||||
|
|
||||||
|
### 8. Load cost
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/terminal` runs three SYNCHRONOUS tmux calls
|
||||||
|
(`list-panes`, `capture-pane`, `display-message` via `execSync`, each with a
|
||||||
|
5 s timeout). Nine `full=1` loads at once would not run in parallel; they would
|
||||||
|
run back to back on the event loop and stall every WS and SSE stream on the
|
||||||
|
server for seconds.
|
||||||
|
|
||||||
|
So EVERY tile load goes through ONE grid-level client queue (PR 2, plugged in
|
||||||
|
through a `scheduleLoad` option PR 2 adds to `TerminalTile`): the initial load,
|
||||||
|
the refresh after a reconnect, a server `{t:'r'}` refresh, and the shell
|
||||||
|
history pull. No tile calls `fetch('/terminal…')` on its own. The tile's
|
||||||
|
single-flight flag stays (it is what keeps one tile's replays from
|
||||||
|
interleaving); the queue sits in front of it and bounds the whole grid.
|
||||||
|
|
||||||
|
- concurrency 1, focused tile first, then reading order (a user-triggered
|
||||||
|
history pull jumps ahead of background refreshes);
|
||||||
|
- after a deploy restart, the N reconnect refreshes drain one at a time
|
||||||
|
instead of hitting the server together;
|
||||||
|
- each load uses the BOUNDED window `?full=1&tail=TERMINAL_TAIL_SIZE` (1 MiB)
|
||||||
|
for TUI sessions and `?tail=…` for shells, never an unbounded `full=1`;
|
||||||
|
- a tile shows a quiet "loading" state until its turn;
|
||||||
|
- full history is one action away: zoom then exit tiles, or exit tiles.
|
||||||
|
|
||||||
|
### 9. Coexistence with the split pane
|
||||||
|
|
||||||
|
Decided: the split pane stays for now. The two modes are mutually exclusive
|
||||||
|
and share one tile class:
|
||||||
|
|
||||||
|
- Opening the grid while a split is open closes the split first and seeds the
|
||||||
|
grid with both of its sessions (Pane A's focused, Pane B's beside it), so
|
||||||
|
"split, then want more" is one click.
|
||||||
|
- While the grid is open, `openSplitPicker` and `openSplitPane` refuse and the
|
||||||
|
Split button shows as disabled (`aria-disabled`), the same refusal pattern
|
||||||
|
`openSplitPane` already uses for web tabs and the welcome screen.
|
||||||
|
- Closing the grid never reopens a split.
|
||||||
|
- The split's wrappers (`selectSession`, `_onSessionDeleted`) key on
|
||||||
|
`this._splitPane`, which is null whenever the grid is open, so they stay
|
||||||
|
inert there without changes.
|
||||||
|
- Shared code lives in `TerminalTile` and the seams from PR 1, so a fix to tile
|
||||||
|
behavior reaches both modes. Whether to retire the split later (a 2-tile grid
|
||||||
|
covers it) is left for after the grid has been used for a while.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| Situation | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| A tiled session is deleted (here or elsewhere) | Tile removed; a neighbor gets focus with `auto: true`; the last tile gone falls through to the normal handoff |
|
||||||
|
| Closing the focused tile's session | The grid stays open and the neighboring tile takes focus (grid-aware fallback in `closeSession`, see "Selections while the grid is open") |
|
||||||
|
| A tiled session is popped out to its own window | Tile removed: that window now owns the PTY size |
|
||||||
|
| Session exited or not attached (`pid === null`, `paneExit`) | The tile body shows "Not attached" with an Attach button: `POST /interactive` (or `/shell` for shell mode) with NO body, at most one in flight per session (the route has no in-flight guard of its own). A tripped PTY-exit breaker goes through the existing confirm before `clearBreaker: true`; no automatic path ever sends it |
|
||||||
|
| A web tab is opened | Grid hidden by CSS; sockets stay up; hidden tiles send no resizes. Selecting a tiled session's tab brings the grid back |
|
||||||
|
| Window narrower than 1180 px | Back to single view of the focused session; stored grid kept |
|
||||||
|
| Window shrinks below the tiles' minimum size | The focused tile zooms with a short hint; widening restores the grid |
|
||||||
|
| Codeman restarts (deploy) | Tiles reconnect; their refreshes drain through the load queue one at a time; `handleInit` reconciles ids without rebuilding live tiles |
|
||||||
|
| A half-open tile socket when a reconnect is kicked | The old socket's handlers are detached before the replacement opens, so its late 4010 close is ignored |
|
||||||
|
| A phone opens a tiled session | Its resize is declined while the desktop holds a sizing claim and was active in the last 90 s (existing `Session.resize` arbitration) |
|
||||||
|
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
|
||||||
|
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
|
||||||
|
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
|
||||||
|
| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
|
||||||
|
| Solo window | Tiles unavailable |
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
**No server change is required.**
|
||||||
|
|
||||||
|
- N sockets to N different sessions each use one slot of that session's
|
||||||
|
`MAX_WS_PER_SESSION = 5` (`ws-routes.ts`). There is no per-client or global WS
|
||||||
|
cap.
|
||||||
|
- One `/api/events` SSE stream already carries every session's lifecycle and
|
||||||
|
hook events, so tiles need no extra stream for their status dots.
|
||||||
|
- `v:'desktop'` resizes already register a sizing claim, so a phone cannot
|
||||||
|
shrink a tiled session while the desktop is active.
|
||||||
|
- The WS sends nothing on connect, which is why each tile loads its buffer
|
||||||
|
first (already true for Pane B).
|
||||||
|
|
||||||
|
Separate follow-up PRs worth doing (see "Follow-ups").
|
||||||
|
|
||||||
|
## Invariants this feature must keep
|
||||||
|
|
||||||
|
- **#464 geometry**: a tile's xterm and its PTY never disagree; one function
|
||||||
|
sizes both; `zc` adoption is columns only; withhold the fit wherever the
|
||||||
|
resize is withheld.
|
||||||
|
- **Grid and split are never open together**: opening the grid closes a split;
|
||||||
|
the split refuses to open while the grid is open.
|
||||||
|
- **One place per session in this browser tab**: the main terminal is parked
|
||||||
|
while the grid is open, a session is in at most one tile, detached sessions
|
||||||
|
are never tiled.
|
||||||
|
- **App-driven selections never collapse the grid**: only a user-initiated pick
|
||||||
|
(or an explicit `leaveTiles`) of a non-tiled session leaves it; every
|
||||||
|
app-driven fallback (close, delete, restore) picks a tile.
|
||||||
|
- **One load queue**: no tile fetches `/terminal` outside the grid's queue
|
||||||
|
(initial, reconnect, `{t:'r'}`, history pull), because each capture blocks
|
||||||
|
the server's event loop.
|
||||||
|
- **Only the current socket counts**: a tile ignores events from any socket
|
||||||
|
that is no longer `this.ws`, and detaches handlers before replacing one.
|
||||||
|
- **Tiles never touch main-terminal state**: no long tasks counted against the
|
||||||
|
main terminal's WebGL, and its snapshots for tiled ids are invalidated on
|
||||||
|
exit.
|
||||||
|
- **Alerts**: visible is not acknowledged; only a human selection or delivered
|
||||||
|
input acknowledges idle; action alerts are never cleared by view or input;
|
||||||
|
app-driven selections pass `auto: true`.
|
||||||
|
- **PTY-exit breaker**: never `clearBreaker` from an automatic path.
|
||||||
|
- **Replay clears are in-stream** (`\x1bc` queued in the write stream), never
|
||||||
|
`reset()`.
|
||||||
|
- **Capture fetches carry deadlines that cover the body** (reuse
|
||||||
|
`CodemanFetchDeadline`, as `_pullHistory` does).
|
||||||
|
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
|
||||||
|
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
|
||||||
|
- **Palette chords** are swallowed in every xterm key handler.
|
||||||
|
- **Escape**: the picker's close method returns early when the picker is not
|
||||||
|
open (the global Escape handler calls every close method).
|
||||||
|
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
|
||||||
|
- **No secrets in localStorage**: the stored grid holds ids only.
|
||||||
|
- **Memory**: everything a tile creates is released in `destroy()`.
|
||||||
|
|
||||||
|
## Delivery: two PRs
|
||||||
|
|
||||||
|
Decided: the work ships as two PRs. PR 1 stands on its own: it builds the tile
|
||||||
|
class and the seams, and the existing split pane runs on them, so users get a
|
||||||
|
better split before the grid exists. PR 2 adds the grid on top.
|
||||||
|
|
||||||
|
### PR 1: tile foundation (the split pane gets better)
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. **Seams.** The input-socket map; the parameterized link provider, copy,
|
||||||
|
image paste and voice target; `_focusedPane()`; a `_forEachTile()` helper
|
||||||
|
that replaces the `this._splitPane` special cases in the font, family,
|
||||||
|
weight and skin setters. No behavior change on its own. Unit tests for each.
|
||||||
|
2. **`TerminalTile`.** The class moves out of `terminal-split.js` into a new
|
||||||
|
`src/web/public/terminal-tile.js` (load order 7.4, before
|
||||||
|
`terminal-split.js` at 7.5) and is renamed from `SplitTerminalPane`. The
|
||||||
|
split's orchestration (`openSplitPane`, `closeSplitPane`, the picker, the
|
||||||
|
divider drag, the wrappers) stays in `terminal-split.js` and constructs a
|
||||||
|
`TerminalTile` for Pane B. New in the class: reconnect with race-free socket
|
||||||
|
replacement and the close-code table, the `:tile` cid suffix, reliable input
|
||||||
|
through the socket map, `zc` handling with one geometry method, the
|
||||||
|
file-path link provider and image paste, and an `onExit` callback. The
|
||||||
|
`scrollback` and `scheduleLoad` options were deferred to PR 2, which
|
||||||
|
introduces them together with the grid's load queue, their first user.
|
||||||
|
3. **Split pane follows focus.** `_focusedPane()` returns Pane B while its
|
||||||
|
xterm has focus, so Ctrl+L, Ctrl+Shift+R, voice and image paste act on the
|
||||||
|
pane you are typing in. This removes most of the asymmetry the split-pane
|
||||||
|
spec documented and accepted for its v1 ("Ctrl+L or Ctrl+W typed while Pane
|
||||||
|
B has focus clears or closes Pane A"). Typing into Pane B now clears its
|
||||||
|
idle alert through `_ackDelivery`, which it never did.
|
||||||
|
**Ctrl+W no longer closes anything** (decision 5): Close Session has no
|
||||||
|
default key, so Ctrl+W reaches the focused pane as delete-word.
|
||||||
|
4. **Docs.** Update the split-pane paragraph in CLAUDE.md and
|
||||||
|
`docs/architecture-invariants.md#split-pane-sessions` (Pane B now
|
||||||
|
reconnects, delivers input exactly once, has links and image paste, and
|
||||||
|
follows focus; the "deliberately plainer" list shrinks accordingly), add
|
||||||
|
`terminal-tile.js`(7.4) to the frontend load order, add a note at the top of
|
||||||
|
`docs/split-pane-sessions-plan.md` pointing here.
|
||||||
|
|
||||||
|
What users get from PR 1 alone: a split pane that survives deploys, never
|
||||||
|
loses or doubles a keystroke across a reconnect, has clickable file paths and
|
||||||
|
image paste, and whose shortcuts act on the pane that has focus.
|
||||||
|
|
||||||
|
PR 1 tests (gate):
|
||||||
|
|
||||||
|
- `test/terminal-tile-unit.test.ts`, moved from
|
||||||
|
`split-pane-terminal-unit.test.ts` with every existing case kept
|
||||||
|
(single-flight, marker-last including the async-parse fake, history pull),
|
||||||
|
plus: reconnect backoff and each close code, a late `onclose` (4010) from a
|
||||||
|
replaced socket is ignored and the tile keeps running, `zc` columns-only
|
||||||
|
adoption, the cid suffix, input routed through `_sendInputAsync` (as built:
|
||||||
|
`test/terminal-tile-input.test.ts`, which runs `connect()` for real).
|
||||||
|
- `test/input-socket-map.test.ts`: acks routed to the right session's queue,
|
||||||
|
redelivery per socket, POST fallback when no socket is registered.
|
||||||
|
- `test/focused-pane-shortcuts.test.ts`: with Pane B focused, Ctrl+L clears
|
||||||
|
Pane B, Ctrl+Shift+R restores Pane B's size, voice and image paste target
|
||||||
|
Pane B's session, and a user-bound Close Session still targets the active
|
||||||
|
session; `test/ctrl-w-never-closes.test.ts` pins that no default shortcut
|
||||||
|
answers Ctrl+W;
|
||||||
|
with Pane A focused nothing changes.
|
||||||
|
- Geometry: with the split divider at its 20% clamp, Pane B's xterm and the
|
||||||
|
size it sends are both under 40 columns and equal (no floor regression).
|
||||||
|
- `onExit`: each close code is reported once to the owner and stops
|
||||||
|
reconnecting; the split shows the matching marker.
|
||||||
|
- The image-paste wrong-session fix: an upload that finishes after a tab switch
|
||||||
|
still inserts into the session it started in.
|
||||||
|
- Every existing `split-pane-*` test keeps passing (class name updated where it
|
||||||
|
is referenced).
|
||||||
|
|
||||||
|
PR 1 browser tests: the existing `split-pane-*.browser.test.ts` files (they
|
||||||
|
match master, one pre-existing environmental failure in both). Pane B
|
||||||
|
reconnecting after a server restart and a click on a printed path opening
|
||||||
|
Pane B's file preview are covered by unit tests and checked live on the beta
|
||||||
|
instance rather than as browser tests (the harness cannot restart its own
|
||||||
|
server).
|
||||||
|
|
||||||
|
PR 1 verification: a split with two real Claude sessions on the beta instance;
|
||||||
|
restart the server mid-typing in Pane B (reconnect, refresh, no lost or doubled
|
||||||
|
input); type into Pane B while it has an idle alert (the alert clears); Ctrl+L
|
||||||
|
in Pane B; image paste into Pane B.
|
||||||
|
|
||||||
|
### PR 2: tile grid
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. **Grid core.** `_refreshSessionPanels()` extraction from `selectSession`
|
||||||
|
(no behavior change, its own commit first), layout helpers, the grid section
|
||||||
|
and CSS, parking with guards (SSE handlers, reconnect paths, WebGL long-task
|
||||||
|
observer), the `selectSession` tile branch with the `auto` rule, the
|
||||||
|
grid-aware `closeSession` fallback, focus rules, tile chords in the shortcut
|
||||||
|
registry, the single grid-level load queue that every tile load goes
|
||||||
|
through (a new `scheduleLoad` option on `TerminalTile`, plus a `scrollback`
|
||||||
|
option for `TILE_SCROLLBACK`), coexistence with the split.
|
||||||
|
2. **Tile chrome and entry points.** Header (dot, name, menu, zoom, add,
|
||||||
|
remove), the Attach overlay, picker, dividers, drag-a-tab, Ctrl/Cmd+click,
|
||||||
|
"Open group as tiles".
|
||||||
|
3. **Persistence.** `codeman:tile-grid` restore inside `handleInit` (in place
|
||||||
|
of the initial select), snapshot invalidation on exit, auto-join of new
|
||||||
|
sessions from this tab, the `showTileGridButton` setting.
|
||||||
|
4. **Docs.** A tile-grid paragraph in CLAUDE.md beside the split-pane one,
|
||||||
|
`tile-grid.js`(7.6) in the load order, header-button and z-index notes,
|
||||||
|
`docs/architecture-invariants.md#tile-grid`, a wiki page under
|
||||||
|
`docs/wiki/`.
|
||||||
|
|
||||||
|
## Testing (PR 2)
|
||||||
|
|
||||||
|
**Gate (`npm test`):**
|
||||||
|
|
||||||
|
- `test/tile-grid-layout.test.ts`: `computeTileLayout`, `tileGridCapacity`,
|
||||||
|
`sanitizeTileGridState` (unknown, deleted, detached, duplicate ids).
|
||||||
|
- `test/tile-grid-select-branch.test.ts` and an extension of
|
||||||
|
`test/session-select-ack-gate.test.ts`: the branch never calls `_connectWs` or
|
||||||
|
`_cleanupPreviousSession`; acknowledgement only when user-initiated; an
|
||||||
|
`auto: true` selection of a non-tiled session leaves the grid open; a
|
||||||
|
user-initiated one closes it; `leaveTiles: true` closes it.
|
||||||
|
- `test/tile-grid-close-fallback.test.ts`: closing (`closeSession`) the
|
||||||
|
focused tile keeps the grid open and focuses the neighboring tile, even when
|
||||||
|
the first `sessionOrder` entry is not tiled; closing the last tile falls back
|
||||||
|
to the normal pick.
|
||||||
|
- `test/tile-grid-park-guards.test.ts`: every guarded SSE handler is a no-op
|
||||||
|
while tiles own the terminal, and the WebGL long-task observer counts nothing
|
||||||
|
while tiles own the terminal.
|
||||||
|
- `test/tile-grid-load-queue.test.ts`: N tiles reconnecting together produce at
|
||||||
|
most one in-flight `/terminal` fetch at a time; `{t:'r'}` and history pulls go
|
||||||
|
through the same queue; a destroyed tile's queued load is dropped.
|
||||||
|
- `test/tile-grid-restore.test.ts`: with a stored open grid, `handleInit`
|
||||||
|
restores the grid and never calls the main terminal's buffer load; on exit,
|
||||||
|
snapshot and cache entries for every tiled id are invalidated.
|
||||||
|
- `test/tile-grid-split-coexistence.test.ts`: opening the grid closes an open
|
||||||
|
split and seeds the grid with both of its sessions; the split cannot open
|
||||||
|
while the grid is open; the split's wrappers do nothing while the grid is
|
||||||
|
open.
|
||||||
|
- `test/tile-grid-per-device-setting.test.ts` and a hidden-button CSS case, in
|
||||||
|
the shape of the split-pane ones: `showTileGridButton` is in `displayKeys`,
|
||||||
|
stripped from the PUT, not in the schema, and `.btn-tile-grid--hidden` has a
|
||||||
|
`display: none` rule.
|
||||||
|
- Shortcut tests: the new chords are swallowed in the main and tile key
|
||||||
|
handlers; `mobile-header-buttons-policy` keeps the button off phones.
|
||||||
|
|
||||||
|
**Browser (`npm run test:browser -- <file>`, NOT in the gate):**
|
||||||
|
|
||||||
|
- Open 4 tiles; all render live output independently.
|
||||||
|
- Click-to-focus routes keystrokes to the right PTY (assert with
|
||||||
|
`tmux capture-pane`, never on HTTP 200).
|
||||||
|
- Ctrl+L clears only the focused tile; Shift+Enter inserts a newline in a tile.
|
||||||
|
- Zoom and unzoom refit; a divider drag sends exactly one resize per affected
|
||||||
|
tile at pointer-up.
|
||||||
|
- Deleting a tiled session removes its tile and moves focus.
|
||||||
|
- Reload restores the grid; a server restart reconnects every tile.
|
||||||
|
- Narrowing below 1180 px returns to the single view.
|
||||||
|
|
||||||
|
Reminder: `npm test -- <browser file>` matches nothing, runs zero tests and
|
||||||
|
exits green. Use the browser runner for those files and read the file count.
|
||||||
|
|
||||||
|
## Verification before merging PR 2
|
||||||
|
|
||||||
|
- Build in the worktree and run an isolated beta instance (its own
|
||||||
|
`CODEMAN_INSTANCE`, so its own data dir and tmux socket), reached through
|
||||||
|
`tailscale serve` on a never-used port.
|
||||||
|
- Six throwaway Claude sessions, as in the target picture; Playwright captures
|
||||||
|
at `deviceScaleFactor: 1`, unique filenames.
|
||||||
|
- A Chrome performance trace with all six tiles working at once for 60 s,
|
||||||
|
recorded in the PR, with pass/fail bars:
|
||||||
|
- p95 frame time at or below 25 ms (40 fps or better);
|
||||||
|
- no long task of 200 ms or more after the initial load settles (three of
|
||||||
|
those in 30 s is what trips the WebGL fallback, so the bar matches the
|
||||||
|
codebase's own threshold);
|
||||||
|
- JS heap within ±10% between minute 1 and minute 10 of a ten-minute run
|
||||||
|
(no growth from tile churn: add and remove tiles and zoom a few times in
|
||||||
|
between);
|
||||||
|
- the initial load of six tiles completes with the server's event loop never
|
||||||
|
blocked for more than one capture at a time (check `Server-Timing` on each
|
||||||
|
`/terminal` response).
|
||||||
|
|
||||||
|
Missing a bar blocks the merge or lowers the tile cap, not the bar.
|
||||||
|
- A server restart while typing into a tile: reconnect, refresh, no lost or
|
||||||
|
doubled input.
|
||||||
|
- A phone opened on one tiled session: the desktop tile keeps its size while
|
||||||
|
active.
|
||||||
|
|
||||||
|
## Follow-ups (not in these PRs)
|
||||||
|
|
||||||
|
1. Make `captureActivePaneBuffer` async (`execFile`) and add a server-side limit
|
||||||
|
on concurrent captures, so no client can stall the event loop with captures.
|
||||||
|
2. When a session's PTY size changes, send `zc` to EVERY socket on that
|
||||||
|
session. Today only the resizing socket hears back, so a second desktop
|
||||||
|
viewer keeps a stale width and renders garbled output (#464).
|
||||||
|
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||||
|
on drain) for grids over slow links.
|
||||||
|
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
||||||
|
a "Load full history" action inside a tile.
|
||||||
|
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
|
||||||
|
6. Named grid presets, possibly per owner on the server.
|
||||||
|
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
||||||
|
which removes the singleton from `terminal-ui.js`.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Clicking a tab that is not tiled.** Decided: a user-initiated pick
|
||||||
|
leaves the grid and shows that session in the single
|
||||||
|
view; the grid is kept for one-click return. App-driven selections never
|
||||||
|
leave it either way. Alternative: swap that session into the focused tile.
|
||||||
|
2. **The split pane.** Decided: keep it alongside the grid for now; the two
|
||||||
|
share `TerminalTile` and never open together.
|
||||||
|
3. **Persistence.** Decided: per device, restored on reload.
|
||||||
|
4. **PR shape.** Decided: two PRs. PR 1 is the tile foundation (the split
|
||||||
|
improves on its own), PR 2 is the grid.
|
||||||
|
5. **Ctrl+W.** Decided: it never closes a session. Close Session has no
|
||||||
|
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
|
||||||
|
killed sessions with no confirm); it stays bindable in App Settings →
|
||||||
|
Shortcuts.
|
||||||
|
|
||||||
|
## Code anchors
|
||||||
|
|
||||||
|
Line numbers are approximate (as of 1.35.0) and drift; the names are stable.
|
||||||
|
|
||||||
|
| Area | Where |
|
||||||
|
|---|---|
|
||||||
|
| Split pane class and orchestration | `src/web/public/terminal-split.js` (`SplitTerminalPane`, `openSplitPane`, `closeSplitPane`, `_installSplitDividerDrag`, the `selectSession` and `_onSessionDeleted` wrappers) |
|
||||||
|
| Split helpers | `src/web/public/constants.js` ~1592-1634 (`SPLIT_PANE_MIN_WIDTH`, `clampDividerPercent`, `buildSplitPickerSessions`), exported as `window.CodemanSplitPane` |
|
||||||
|
| `selectSession` | `src/web/public/app.js` ~7844-8644; early return ~7868-7876; deferred panels ~8452-8523 |
|
||||||
|
| `_cleanupPreviousSession` | `app.js` ~7377 |
|
||||||
|
| Reliable input | `app.js` ~3558-3920 (`_sendInputAsync`, `_reliableSend`, `_nextSeq`, `_drainSession`, `_ackDelivery`, `_onWsInputAck`, `_redeliverSweep`); main socket URL ~3341 |
|
||||||
|
| SSE terminal fallback | `app.js` `_onSessionTerminal` ~2192, `_onSessionNeedsRefresh` ~2916, `_onSessionClearTerminal` ~3020 |
|
||||||
|
| Geometry | `terminal-ui.js` `syncTerminalGeometry` ~5894, `sendResize` ~5981, `_onPtyGeometryReport` ~6072, `throttledResize` ~1292-1429; `constants.js` `reconcilePtyGeometry` ~1850 |
|
||||||
|
| Link provider | `terminal-ui.js` `registerFilePathLinkProvider` ~1831 |
|
||||||
|
| Main key handler | `terminal-ui.js` ~552-718 |
|
||||||
|
| Shortcut registry | `app.js` `DEFAULT_SHORTCUTS` ~406-557, `SHORTCUT_ACTIONS` ~1248, capture handler ~1263-1363 |
|
||||||
|
| Status classifier | `app.js` `_sidebarRichRow` ~5058; `mobile-overview.js` `_mobileOverviewState` ~130; dot CSS `.home-sessions-dot--*` in styles.css |
|
||||||
|
| Session action menu | `tab-rail-resize.js` `openTabRailActionMenu` ~318 |
|
||||||
|
| Tab drag | `app.js` `setupTabDragHandlers` ~7137 |
|
||||||
|
| Tab-group menu | `app.js` `openTabGroupMenu` ~6737 |
|
||||||
|
| Image paste | `image-input.js` `_handleImagePaste` ~53, `_uploadAndInsertImages` ~130 |
|
||||||
|
| Voice target | `voice-input.js` `start` ~666, `_insertText` ~962 |
|
||||||
|
| WS route and caps | `src/web/routes/ws-routes.ts` (`MAX_WS_PER_SESSION`, frame handling), `src/web/ws-connection-registry.ts` |
|
||||||
|
| Resize arbitration | `src/session.ts` `resize` / `claimDesktopSizing` ~4339-4426 |
|
||||||
|
| Terminal capture | `src/web/routes/session-routes.ts` `GET /api/sessions/:id/terminal` ~2986; `src/tmux-manager.ts` `captureActivePaneBuffer` ~3825 |
|
||||||
|
| Attach | `session-routes.ts` `POST /api/sessions/:id/interactive` ~1566 |
|
||||||
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
|
|||||||
|
|
||||||
| How | Result |
|
| How | Result |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| ------------------------------- | --------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------- |
|
||||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||||
| `Ctrl+W` | Kill the active session. |
|
|
||||||
| `Ctrl+Tab` | Next session. |
|
| `Ctrl+Tab` | Next session. |
|
||||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||||
|
|
||||||
|
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||||
|
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||||
|
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||||
|
|
||||||
## Terminal
|
## Terminal
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
|||||||
|
|
||||||
| Tab | Use it when |
|
| Tab | Use it when |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
|
||||||
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
|||||||
| To do this | Do that |
|
| To do this | Do that |
|
||||||
| ------------------------- | ------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------------------------------------------- |
|
||||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||||
| Stop everything | `tmux -L codeman kill-server`. |
|
| Stop everything | `tmux -L codeman kill-server`. |
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle L
|
|||||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||||
Ultracode Windows, Cron.
|
Ultracode Windows, Cron.
|
||||||
|
|
||||||
|
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||||
|
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||||
|
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
||||||
|
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||||
|
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||||
|
window; off lists every file by its full path.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||||
@@ -145,8 +152,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||||
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
|
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
||||||
**Users** administration entry is injected here.
|
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||||
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|
||||||
## Session Options
|
## Session Options
|
||||||
|
|
||||||
@@ -181,6 +190,8 @@ Some things are configured before the server starts, not in the UI:
|
|||||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||||
|
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||||
|
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
|||||||
| -------------------- | --------------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------- |
|
||||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
||||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
|
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||||
|
|
||||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||||
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||||
| Close | `Ctrl+W` |
|
| Close | The tab's close control (no key by default) |
|
||||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
Tabs can also be dragged to reorder.
|
||||||
@@ -124,6 +124,13 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||||
covered in [Mobile Guide](Mobile-Guide).
|
covered in [Mobile Guide](Mobile-Guide).
|
||||||
|
|
||||||
|
## Bottom bar
|
||||||
|
|
||||||
|
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
|
||||||
|
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
|
||||||
|
Panels → Bottom bar**. Click it for the Git window. See
|
||||||
|
[Working With Files](Working-With-Files#git-changes).
|
||||||
|
|
||||||
## Connection state
|
## Connection state
|
||||||
|
|
||||||
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
||||||
|
|||||||
@@ -163,6 +163,41 @@ HEIC images from an iPhone are converted to JPEG on the way in.
|
|||||||
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
||||||
surface as an artifact attachment rather than a path you have to go and find.
|
surface as an artifact attachment rather than a path you have to go and find.
|
||||||
|
|
||||||
|
## Git changes
|
||||||
|
|
||||||
|
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||||
|
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||||
|
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||||
|
conflicts, `✓` when everything is committed and pushed.
|
||||||
|
|
||||||
|
Click it for a draggable window, in the style of the File Viewer:
|
||||||
|
|
||||||
|
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
|
||||||
|
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
|
||||||
|
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
|
||||||
|
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
|
||||||
|
not on remote"), which counts every commit on no remote rather than showing a green tick.
|
||||||
|
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
|
||||||
|
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
|
||||||
|
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
|
||||||
|
list of full paths instead.
|
||||||
|
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
|
||||||
|
coloured. Staged files show index versus last commit, not-staged files show working tree
|
||||||
|
versus index, untracked files show as all additions and deleted files as all removals.
|
||||||
|
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||||
|
note instead, and a diff over 400 KB is cut short.
|
||||||
|
- A session folder that holds several projects gets one collapsible section per repository
|
||||||
|
found up to two levels down. They all start collapsed (each summary line shows its branch and
|
||||||
|
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||||
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
|
||||||
|
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||||
|
workspace is skipped even from a local session (a container can write there, and git would run
|
||||||
|
that repository's own configuration on the host). The
|
||||||
|
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||||
|
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.34.0",
|
"version": "1.35.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
|||||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||||
|
one that does not answer or cannot be read (an unreachable network mount, a
|
||||||
|
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||||
|
create a replacement for it;
|
||||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||||
`SESSION_BUSY` for the identical condition.
|
`SESSION_BUSY` for the identical condition.
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
|
|||||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||||
|
one that does not answer or cannot be read (an unreachable network mount, a
|
||||||
|
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||||
|
create a replacement for it;
|
||||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||||
`SESSION_BUSY` for the identical condition.
|
`SESSION_BUSY` for the identical condition.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* @module config/dependency-registry
|
* @module config/dependency-registry
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { enabledClis } from './cli-registry/registry.js';
|
import { enabledClis } from './cli-registry/registry.js';
|
||||||
import { compileVersionRegex } from './cli-registry/patterns.js';
|
import { compileVersionRegex } from './cli-registry/patterns.js';
|
||||||
|
|
||||||
@@ -30,6 +32,24 @@ export interface PathResolver {
|
|||||||
* there and a false "installed" contradicts the run mode's own resolver.
|
* there and a false "installed" contradicts the run mode's own resolver.
|
||||||
*/
|
*/
|
||||||
requireVersionMatch?: boolean;
|
requireVersionMatch?: boolean;
|
||||||
|
/**
|
||||||
|
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
|
||||||
|
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
|
||||||
|
* prefix is invisible to `which` while the run mode, which falls back to the registry's
|
||||||
|
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
|
||||||
|
*/
|
||||||
|
searchDirs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
|
||||||
|
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
|
||||||
|
* would pull in the whole resolver chain; keep the two in step.
|
||||||
|
*/
|
||||||
|
function expandSearchDir(dir: string): string {
|
||||||
|
if (dir === '~') return homedir();
|
||||||
|
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
||||||
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
|
|||||||
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
||||||
// program, so a version mismatch means MISSING rather than unknown-version.
|
// program, so a version mismatch means MISSING rather than unknown-version.
|
||||||
requireVersionMatch: version?.requireVersionMatch,
|
requireVersionMatch: version?.requireVersionMatch,
|
||||||
|
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
|
||||||
|
*
|
||||||
|
* A linked case can live on a network mount, and a hard mount that went away makes
|
||||||
|
* `stat()` wait until the mount comes back. The probe gives up on such a path after
|
||||||
|
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
|
||||||
|
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
|
||||||
|
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
|
||||||
|
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
|
||||||
|
*
|
||||||
|
* Both are env-overridable, in the same style as the other config modules. A slow
|
||||||
|
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
|
||||||
|
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
|
||||||
|
* server started with a larger pool gets a higher ceiling without further setup.
|
||||||
|
*
|
||||||
|
* @module config/path-probe
|
||||||
|
*/
|
||||||
|
|
||||||
|
function envInt(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const raw = parseInt(process.env[name] || '', 10);
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a caller waits for one path probe before the answer is "unknown". */
|
||||||
|
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
|
||||||
|
* included: the threadpool size minus one, so a dead mount can never take the last
|
||||||
|
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
|
||||||
|
* one cannot keep a worker free at all, so the ceiling never drops below one.
|
||||||
|
*/
|
||||||
|
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timed-out probes allowed to stay pending before new BULK probes are refused
|
||||||
|
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
|
||||||
|
* stalled path on a network or FUSE mount already takes the rest of that mount out
|
||||||
|
* of probing (a stall anywhere else takes out only the stalled path), so the cap
|
||||||
|
* only engages once that many UNRELATED places have stopped answering. It defaults
|
||||||
|
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
|
||||||
|
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
|
||||||
|
*/
|
||||||
|
export const MAX_STALLED_PATH_PROBES = Math.min(
|
||||||
|
PATH_PROBE_STALL_CEILING,
|
||||||
|
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
|
||||||
|
);
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
|
||||||
|
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
|
||||||
|
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
|
||||||
|
* visible without leaving Codeman.
|
||||||
|
*
|
||||||
|
* Split so the parts that matter test without a repo:
|
||||||
|
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
|
||||||
|
* `parseCommitLog`
|
||||||
|
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
|
||||||
|
* single-flight cache so several tabs polling one repo cost one set of git processes
|
||||||
|
*
|
||||||
|
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
|
||||||
|
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
|
||||||
|
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||||
|
* to the outer one, and is not scanned;
|
||||||
|
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||||
|
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
||||||
|
* symlinks), each reported separately;
|
||||||
|
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
||||||
|
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||||
|
*
|
||||||
|
* Rules the code keeps and the tests pin:
|
||||||
|
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
|
||||||
|
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
|
||||||
|
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
|
||||||
|
* refreshing the index, so polling cannot contend with the agent's own git commands.
|
||||||
|
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
|
||||||
|
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
|
||||||
|
* revision range.
|
||||||
|
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
|
||||||
|
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
|
||||||
|
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
|
||||||
|
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
|
||||||
|
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
|
||||||
|
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
|
||||||
|
* Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
|
||||||
|
* case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
|
||||||
|
* the container could have planted that config and git here would run it on the host.
|
||||||
|
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
|
||||||
|
* through `redactGitCredentials`.
|
||||||
|
*
|
||||||
|
* @module git-workspace-status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { basename, join, relative, sep } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const GIT_TIMEOUT_MS = 10_000;
|
||||||
|
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||||
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
|
/** Max file rows returned. The counts stay exact. */
|
||||||
|
export const MAX_FILES = 300;
|
||||||
|
/** Max unpushed commits listed. The count stays exact. */
|
||||||
|
export const MAX_COMMITS = 50;
|
||||||
|
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
|
||||||
|
const CACHE_TTL_MS = 4000;
|
||||||
|
const CACHE_MAX_ENTRIES = 64;
|
||||||
|
|
||||||
|
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
|
||||||
|
|
||||||
|
export interface GitFileEntry {
|
||||||
|
/** Path relative to the repository root, as git reports it. */
|
||||||
|
path: string;
|
||||||
|
/** Rename/copy source, when the entry is one. */
|
||||||
|
origPath?: string;
|
||||||
|
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
|
||||||
|
index: string;
|
||||||
|
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
|
||||||
|
worktree: string;
|
||||||
|
kind: GitFileKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCommitEntry {
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
/** Seconds since the epoch. */
|
||||||
|
time: number;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorkspaceStatus {
|
||||||
|
/**
|
||||||
|
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
|
||||||
|
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
|
||||||
|
*/
|
||||||
|
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||||
|
reason?: 'remote' | 'docker';
|
||||||
|
error?: string;
|
||||||
|
repoRoot?: string;
|
||||||
|
/** Null when HEAD is detached. */
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
/**
|
||||||
|
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
|
||||||
|
* cloning an empty repository and committing): nothing is tracked.
|
||||||
|
*/
|
||||||
|
upstreamGone: boolean;
|
||||||
|
ahead: number;
|
||||||
|
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
|
||||||
|
behind: number;
|
||||||
|
/** Whether the repository has any remote at all. */
|
||||||
|
hasRemote: boolean;
|
||||||
|
counts: {
|
||||||
|
staged: number;
|
||||||
|
unstaged: number;
|
||||||
|
untracked: number;
|
||||||
|
conflicted: number;
|
||||||
|
/** Distinct paths that are not committed. */
|
||||||
|
uncommitted: number;
|
||||||
|
stashes: number;
|
||||||
|
};
|
||||||
|
files: GitFileEntry[];
|
||||||
|
filesTruncated: boolean;
|
||||||
|
/** Commits on this branch that no remote has: exact. */
|
||||||
|
unpushedCount: number;
|
||||||
|
unpushed: GitCommitEntry[];
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
|
||||||
|
branch: null,
|
||||||
|
detached: false,
|
||||||
|
upstream: null,
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
hasRemote: false,
|
||||||
|
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
|
||||||
|
files: [],
|
||||||
|
filesTruncated: false,
|
||||||
|
unpushedCount: 0,
|
||||||
|
unpushed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyStatus = (
|
||||||
|
state: GitWorkspaceStatus['state'],
|
||||||
|
extra: Partial<GitWorkspaceStatus> = {}
|
||||||
|
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ParsedStatus {
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
|
||||||
|
upstreamGone: boolean;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
files: GitFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
|
||||||
|
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
|
||||||
|
* one more NUL-terminated token holding the original path.
|
||||||
|
*/
|
||||||
|
export function parsePorcelainV2(text: string): ParsedStatus {
|
||||||
|
const out: ParsedStatus = {
|
||||||
|
branch: null,
|
||||||
|
detached: false,
|
||||||
|
upstream: null,
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
let sawAb = false;
|
||||||
|
const tokens = text.split('\0');
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const t = tokens[i];
|
||||||
|
if (!t) continue;
|
||||||
|
if (t.startsWith('# ')) {
|
||||||
|
const [key, ...rest] = t.slice(2).split(' ');
|
||||||
|
const value = rest.join(' ');
|
||||||
|
if (key === 'branch.head') {
|
||||||
|
out.detached = value === '(detached)';
|
||||||
|
out.branch = out.detached ? null : value;
|
||||||
|
} else if (key === 'branch.upstream') {
|
||||||
|
out.upstream = value;
|
||||||
|
} else if (key === 'branch.ab') {
|
||||||
|
sawAb = true;
|
||||||
|
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
||||||
|
if (m) {
|
||||||
|
out.ahead = Number(m[1]);
|
||||||
|
out.behind = Number(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = t[0];
|
||||||
|
if (type === '1') {
|
||||||
|
// 1 XY sub mH mI mW hH hI path
|
||||||
|
const f = t.split(' ');
|
||||||
|
const xy = f[1] ?? '..';
|
||||||
|
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
|
||||||
|
} else if (type === '2') {
|
||||||
|
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
|
||||||
|
const f = t.split(' ');
|
||||||
|
const xy = f[1] ?? '..';
|
||||||
|
const path = f.slice(9).join(' ');
|
||||||
|
const origPath = tokens[++i] ?? '';
|
||||||
|
out.files.push(...entriesFor(xy, path, origPath));
|
||||||
|
} else if (type === 'u') {
|
||||||
|
// u XY sub m1 m2 m3 mW h1 h2 h3 path
|
||||||
|
const f = t.split(' ');
|
||||||
|
out.files.push({
|
||||||
|
path: f.slice(10).join(' '),
|
||||||
|
index: f[1]?.[0] ?? 'U',
|
||||||
|
worktree: f[1]?.[1] ?? 'U',
|
||||||
|
kind: 'conflicted',
|
||||||
|
});
|
||||||
|
} else if (type === '?') {
|
||||||
|
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
|
||||||
|
}
|
||||||
|
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
|
||||||
|
}
|
||||||
|
out.upstreamGone = out.upstream !== null && !sawAb;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
|
||||||
|
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
|
||||||
|
const index = xy[0] ?? '.';
|
||||||
|
const worktree = xy[1] ?? '.';
|
||||||
|
const rows: GitFileEntry[] = [];
|
||||||
|
const base = origPath ? { path, origPath } : { path };
|
||||||
|
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
|
||||||
|
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
|
||||||
|
export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||||
|
const out: GitCommitEntry[] = [];
|
||||||
|
for (const record of text.split('\x1e')) {
|
||||||
|
const r = record.replace(/^\n+/, '');
|
||||||
|
if (!r) continue;
|
||||||
|
const [hash, author, time, ...subject] = r.split('\x1f');
|
||||||
|
if (!hash) continue;
|
||||||
|
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IO
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
||||||
|
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
|
||||||
|
|
||||||
|
export const runGit: GitRunner = async (cwd, args) => {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||||
|
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
|
||||||
|
// a configured gpg.program to verify signatures.
|
||||||
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: GIT_TIMEOUT_MS,
|
||||||
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
|
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
||||||
|
const e = err as { code?: unknown; stderr?: unknown; message?: string };
|
||||||
|
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
|
||||||
|
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
|
||||||
|
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
|
||||||
|
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
|
||||||
|
return { notARepo: false, message: 'git timed out' };
|
||||||
|
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
|
||||||
|
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
||||||
|
let statusText: string;
|
||||||
|
try {
|
||||||
|
statusText = await git(cwd, [
|
||||||
|
'status',
|
||||||
|
'--porcelain=v2',
|
||||||
|
'--branch',
|
||||||
|
'-z',
|
||||||
|
'--untracked-files=normal',
|
||||||
|
'--ignore-submodules=dirty',
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
const f = describeFailure(err);
|
||||||
|
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||||
|
}
|
||||||
|
const parsed = parsePorcelainV2(statusText);
|
||||||
|
|
||||||
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await git(cwd, args);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
|
||||||
|
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
|
||||||
|
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
|
||||||
|
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
|
||||||
|
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
|
||||||
|
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
|
||||||
|
const [root, remotes, stash, countText, logText] = await Promise.all([
|
||||||
|
safe(['rev-parse', '--show-toplevel']),
|
||||||
|
safe(['remote']),
|
||||||
|
safe(['stash', 'list', '--format=%gd']),
|
||||||
|
safe(['rev-list', '--count', ...range]),
|
||||||
|
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasRemote = remotes.trim().length > 0;
|
||||||
|
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
|
||||||
|
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
|
||||||
|
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
|
||||||
|
|
||||||
|
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||||
|
const distinct = new Set<string>();
|
||||||
|
for (const f of parsed.files) {
|
||||||
|
counts[f.kind]++;
|
||||||
|
distinct.add(f.path);
|
||||||
|
}
|
||||||
|
counts.uncommitted = distinct.size;
|
||||||
|
counts.stashes = stash.split('\n').filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: 'ok',
|
||||||
|
repoRoot: root.trim() || undefined,
|
||||||
|
branch: parsed.branch,
|
||||||
|
detached: parsed.detached,
|
||||||
|
upstream: parsed.upstream,
|
||||||
|
upstreamGone: parsed.upstreamGone,
|
||||||
|
ahead: parsed.ahead,
|
||||||
|
behind: parsed.behind,
|
||||||
|
hasRemote,
|
||||||
|
counts,
|
||||||
|
files: parsed.files.slice(0, MAX_FILES),
|
||||||
|
filesTruncated: parsed.files.length > MAX_FILES,
|
||||||
|
unpushedCount,
|
||||||
|
unpushed,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
at: number;
|
||||||
|
value?: T;
|
||||||
|
inflight?: Promise<T>;
|
||||||
|
}
|
||||||
|
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
|
||||||
|
|
||||||
|
/** For tests. */
|
||||||
|
export function clearGitStatusCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
toplevelCache.clear();
|
||||||
|
discoveryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
|
||||||
|
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
|
||||||
|
* Refresh and expects the truth) but still joins a computation that is already running, which is as
|
||||||
|
* current as a new one would be.
|
||||||
|
*/
|
||||||
|
async function singleFlight<T>(
|
||||||
|
map: Map<string, CacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
opts: { now: () => number; fresh?: boolean },
|
||||||
|
compute: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const hit = map.get(key);
|
||||||
|
if (hit?.inflight) return hit.inflight;
|
||||||
|
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||||
|
|
||||||
|
const inflight = compute();
|
||||||
|
map.set(key, { at: opts.now(), inflight });
|
||||||
|
try {
|
||||||
|
const value = await inflight;
|
||||||
|
map.set(key, { at: opts.now(), value });
|
||||||
|
if (map.size > CACHE_MAX_ENTRIES) {
|
||||||
|
for (const [k, v] of map) {
|
||||||
|
if (map.size <= CACHE_MAX_ENTRIES) break;
|
||||||
|
if (k !== key && !v.inflight) map.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
map.delete(key);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
|
||||||
|
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
|
||||||
|
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
|
||||||
|
*/
|
||||||
|
export async function getGitWorkspaceStatus(
|
||||||
|
cwd: string,
|
||||||
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
||||||
|
): Promise<GitWorkspaceStatus> {
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
||||||
|
const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
|
||||||
|
|
||||||
|
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||||
|
function enclosingRepoRoot(
|
||||||
|
cwd: string,
|
||||||
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
||||||
|
): Promise<RepoToplevel> {
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
|
try {
|
||||||
|
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
||||||
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
|
} catch (err) {
|
||||||
|
const f = describeFailure(err);
|
||||||
|
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Which repositories: the overview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
|
||||||
|
const DISCOVERY_MAX_DEPTH = 2;
|
||||||
|
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
|
||||||
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
|
/** Repositories reported for one workspace. */
|
||||||
|
export const MAX_REPOS = 12;
|
||||||
|
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
||||||
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
|
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
|
||||||
|
/** Status calls in flight at once for one overview: each is several git processes. */
|
||||||
|
const STATUS_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
export interface GitRepoEntry {
|
||||||
|
/** Folder name of the repository (its root's basename). */
|
||||||
|
name: string;
|
||||||
|
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
|
||||||
|
path: string;
|
||||||
|
status: GitWorkspaceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorkspaceOverview {
|
||||||
|
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
|
||||||
|
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
|
||||||
|
reason?: 'remote' | 'docker';
|
||||||
|
error?: string;
|
||||||
|
repos: GitRepoEntry[];
|
||||||
|
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
||||||
|
reposTruncated: boolean;
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyOverview = (
|
||||||
|
state: GitWorkspaceOverview['state'],
|
||||||
|
extra: Partial<GitWorkspaceOverview> = {}
|
||||||
|
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
|
||||||
|
|
||||||
|
const realOr = async (p: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await fs.realpath(p);
|
||||||
|
} catch {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
|
||||||
|
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
|
||||||
|
|
||||||
|
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
|
||||||
|
* workspaces: a container can write there, so git must not run on its behalf on the host.
|
||||||
|
*/
|
||||||
|
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
|
||||||
|
if (!realRoots.length) return false;
|
||||||
|
const real = await realOr(path);
|
||||||
|
return realRoots.some((r) => isWithin(real, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
|
||||||
|
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
|
||||||
|
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
|
||||||
|
*/
|
||||||
|
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
|
||||||
|
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
|
||||||
|
if (root === here) return false;
|
||||||
|
return root === sep || h === root || h.startsWith(root + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasDotGit(dir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
|
||||||
|
const DISCOVERY_MAX_SCAN = 5000;
|
||||||
|
|
||||||
|
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
|
||||||
|
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await fs.opendir(dir);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out: import('node:fs').Dirent[] = [];
|
||||||
|
try {
|
||||||
|
for await (const e of handle) {
|
||||||
|
out.push(e);
|
||||||
|
if (out.length >= DISCOVERY_MAX_SCAN) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* a folder that fails mid-read: use what was read */
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
|
export async function discoverChildRepos(
|
||||||
|
cwd: string,
|
||||||
|
excludeRealRoots: string[] = []
|
||||||
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
|
const found: string[] = [];
|
||||||
|
let level = [cwd];
|
||||||
|
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const dir of level) {
|
||||||
|
const entries = await readDirBounded(dir);
|
||||||
|
if (!entries) continue;
|
||||||
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
|
||||||
|
for (const e of entries) {
|
||||||
|
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
|
||||||
|
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
|
||||||
|
const child = join(dir, e.name);
|
||||||
|
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
|
||||||
|
if (await isInsideAny(child, excludeRealRoots)) continue;
|
||||||
|
if (await hasDotGit(child)) found.push(child);
|
||||||
|
else next.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
|
|
||||||
|
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
|
||||||
|
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const out: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++;
|
||||||
|
out[i] = await fn(items[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitOverviewOptions {
|
||||||
|
git?: GitRunner;
|
||||||
|
now?: () => number;
|
||||||
|
fresh?: boolean;
|
||||||
|
home?: string;
|
||||||
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
|
dockerWorkspaces?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceRepos =
|
||||||
|
| { kind: 'docker' }
|
||||||
|
| { kind: 'error'; error: string }
|
||||||
|
| { kind: 'enclosing'; root: string }
|
||||||
|
| { kind: 'children'; dirs: string[]; truncated: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||||
|
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
|
||||||
|
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
|
||||||
|
*/
|
||||||
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
|
const now = opts.now ?? Date.now;
|
||||||
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
|
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
||||||
|
// could carry config (a clean filter) that runs on the host.
|
||||||
|
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
|
||||||
|
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
||||||
|
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||||
|
// repositories below.
|
||||||
|
const top = await enclosingRepoRoot(cwd, opts);
|
||||||
|
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||||
|
if (top.state === 'ok') {
|
||||||
|
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||||
|
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
|
||||||
|
return { kind: 'enclosing', root: top.root };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not inside a repository of this workspace: look below for projects.
|
||||||
|
const hit = discoveryCache.get(cwd);
|
||||||
|
let found: { dirs: string[]; truncated: boolean };
|
||||||
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
|
else {
|
||||||
|
found = await discoverChildRepos(cwd, dockerRoots);
|
||||||
|
discoveryCache.set(cwd, { at: now(), value: found });
|
||||||
|
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||||
|
}
|
||||||
|
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||||
|
const dirs: string[] = [];
|
||||||
|
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||||
|
return { kind: 'children', dirs, truncated: found.truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything git knows about the session's workspace: the enclosing repository when there is one,
|
||||||
|
* otherwise each repository found below the working directory. See the module header for the rules.
|
||||||
|
*/
|
||||||
|
export async function getGitWorkspaceOverview(
|
||||||
|
cwd: string,
|
||||||
|
opts: GitOverviewOptions = {}
|
||||||
|
): Promise<GitWorkspaceOverview> {
|
||||||
|
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||||
|
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
|
if (where.kind === 'enclosing') {
|
||||||
|
const primary = await getGitWorkspaceStatus(cwd, opts);
|
||||||
|
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
|
const root = primary.repoRoot ?? where.root;
|
||||||
|
return {
|
||||||
|
state: 'ok',
|
||||||
|
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
|
||||||
|
reposTruncated: false,
|
||||||
|
checkedAt: primary.checkedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
||||||
|
const repos: GitRepoEntry[] = [];
|
||||||
|
where.dirs.forEach((dir, i) => {
|
||||||
|
const status = statuses[i];
|
||||||
|
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||||
|
});
|
||||||
|
if (!repos.length) return emptyOverview('not-a-repo');
|
||||||
|
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
|
||||||
|
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
|
||||||
|
* repository with this rather than recomputing every repository's status.
|
||||||
|
*/
|
||||||
|
export async function findWorkspaceRepo(
|
||||||
|
cwd: string,
|
||||||
|
repo: string,
|
||||||
|
opts: GitOverviewOptions = {}
|
||||||
|
): Promise<string | null> {
|
||||||
|
const where = await resolveWorkspaceRepos(cwd, opts);
|
||||||
|
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
|
||||||
|
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
|
||||||
|
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-file diff ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
|
||||||
|
export const MAX_DIFF_BYTES = 400 * 1024;
|
||||||
|
|
||||||
|
export interface GitFileDiff {
|
||||||
|
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
|
||||||
|
diff: string;
|
||||||
|
truncated: boolean;
|
||||||
|
binary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
|
||||||
|
export function isSafeRepoRelativePath(p: string): boolean {
|
||||||
|
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
|
||||||
|
return !p.split('/').includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
|
||||||
|
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
|
||||||
|
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
|
||||||
|
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
|
||||||
|
* `git diff`, which is why a container-writable repository never reaches this function.
|
||||||
|
*/
|
||||||
|
export async function getGitFileDiff(
|
||||||
|
repoRoot: string,
|
||||||
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
|
opts: { git?: GitRunner } = {}
|
||||||
|
): Promise<GitFileDiff> {
|
||||||
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
|
throw new Error('Invalid path');
|
||||||
|
}
|
||||||
|
const git = opts.git ?? runGit;
|
||||||
|
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
|
||||||
|
let args: string[];
|
||||||
|
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
|
||||||
|
else {
|
||||||
|
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
|
||||||
|
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
|
||||||
|
}
|
||||||
|
let out: string;
|
||||||
|
let cutShort = false;
|
||||||
|
try {
|
||||||
|
out = await git(repoRoot, args);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
|
||||||
|
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
|
||||||
|
// any oversized diff.
|
||||||
|
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
|
||||||
|
out = e.stdout;
|
||||||
|
cutShort = true;
|
||||||
|
} else throw err;
|
||||||
|
}
|
||||||
|
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
|
||||||
|
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
|
||||||
|
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
|
||||||
|
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
|
||||||
|
}
|
||||||
+80
-13
@@ -30,7 +30,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
@@ -40,6 +39,52 @@ import type { HookEventType } from './types.js';
|
|||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||||
import { dataPath } from './config/instance.js';
|
import { dataPath } from './config/instance.js';
|
||||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||||
|
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||||
|
* which gives up after a timeout and answers "unknown", this waits for the real
|
||||||
|
* answer: only ENOENT reads as absent, anything else throws, so a
|
||||||
|
* stalled or unreadable workspace can never be mistaken for an empty one and
|
||||||
|
* have its settings recreated over the top. It is async, so a dead mount ties
|
||||||
|
* up a threadpool worker rather than the event loop.
|
||||||
|
*/
|
||||||
|
async function pathExistsForWrite(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
|
||||||
|
* stalled probe (the bulk cap refused it, or the stat failed with something other
|
||||||
|
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
|
||||||
|
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
|
||||||
|
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
|
||||||
|
* on a dead mount those never settle, and each would hold a threadpool worker the
|
||||||
|
* probe's ceiling does not count.
|
||||||
|
*/
|
||||||
|
async function probeBeforeTouching(path: string) {
|
||||||
|
const state = await probePath(path);
|
||||||
|
if (state !== 'unknown' || isNearStalledPath(path)) return state;
|
||||||
|
return probePath(path, { pastCap: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
|
||||||
|
* it did not answer (a mount that is not responding, a refused probe, an unreadable
|
||||||
|
* path). See `probeBeforeTouching` for why "unknown" is a skip.
|
||||||
|
*/
|
||||||
|
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
|
||||||
|
const state = await probeBeforeTouching(path);
|
||||||
|
if (state === 'absent') return 'absent';
|
||||||
|
if (state === 'unknown') return 'unreachable';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||||
@@ -558,7 +603,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
|||||||
if (keysToRemove.length === 0) return;
|
if (keysToRemove.length === 0) return;
|
||||||
|
|
||||||
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
||||||
if (!existsSync(settingsPath)) return;
|
if (!(await pathExistsForWrite(settingsPath))) return;
|
||||||
|
|
||||||
let existing: Record<string, unknown>;
|
let existing: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -590,7 +635,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
|||||||
*/
|
*/
|
||||||
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +666,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
|||||||
*/
|
*/
|
||||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +695,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
|
|||||||
*/
|
*/
|
||||||
export async function writeHooksConfig(casePath: string): Promise<void> {
|
export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +743,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||||
if (!existsSync(claudeDir)) {
|
if (!(await pathExistsForWrite(claudeDir))) {
|
||||||
await mkdir(claudeDir, { recursive: true });
|
await mkdir(claudeDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +783,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
|||||||
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
||||||
*/
|
*/
|
||||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||||
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
|
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
|
||||||
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
|
||||||
let existing: Record<string, unknown>;
|
let existing: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -820,7 +865,17 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
|||||||
*/
|
*/
|
||||||
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(workspace)) return;
|
// "absent" stays absent: the install below would mkdir -p a deleted repo back
|
||||||
|
// into being. "unknown" is skipped too, never asked again with an unbounded
|
||||||
|
// lstat (see probeBeforeTouching).
|
||||||
|
const state = await probeBeforeTouching(workspace);
|
||||||
|
if (state === 'absent') return;
|
||||||
|
if (state === 'unknown') {
|
||||||
|
console.warn(
|
||||||
|
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
|
||||||
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -883,7 +938,7 @@ export function generateStatusLineCommand(): string {
|
|||||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||||
let existing: Record<string, unknown> = {};
|
let existing: Record<string, unknown> = {};
|
||||||
if (existsSync(settingsPath)) {
|
if (await pathExistsForWrite(settingsPath)) {
|
||||||
try {
|
try {
|
||||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -898,7 +953,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
|||||||
const desired = generateStatusLineCommand();
|
const desired = generateStatusLineCommand();
|
||||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||||
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
||||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
if (!(await pathExistsForWrite(claudeDir))) await mkdir(claudeDir, { recursive: true });
|
||||||
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||||
} else {
|
} else {
|
||||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||||
@@ -957,7 +1012,7 @@ function statusLineExporterScriptContent(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
||||||
if (!existsSync(settingsPath)) return undefined;
|
if (await absentOrUnreachable(settingsPath)) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
const current = parsed.statusLine as { command?: unknown } | undefined;
|
const current = parsed.statusLine as { command?: unknown } | undefined;
|
||||||
@@ -1103,9 +1158,21 @@ export async function resolveStatusLineCliCommand(
|
|||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||||
let userHasOwnStatusLine = false;
|
let userHasOwnStatusLine = false;
|
||||||
if (existsSync(settingsPath)) {
|
const skip = await absentOrUnreachable(settingsPath);
|
||||||
|
// Unreachable: whether the user configured their own statusLine there cannot be
|
||||||
|
// told, and this must never override a real one, so inject nothing.
|
||||||
|
if (skip === 'unreachable') return undefined;
|
||||||
|
if (!skip) {
|
||||||
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
raw = await readFile(settingsPath, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
|
||||||
|
raw = '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing = raw ? JSON.parse(raw) : {};
|
||||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||||
if (current && typeof current.command === 'string') {
|
if (current && typeof current.command === 'string') {
|
||||||
if (current.command.includes(STATUSLINE_MARKER)) {
|
if (current.command.includes(STATUSLINE_MARKER)) {
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ export interface CaseInfo {
|
|||||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||||
/** Whether this is a linked local folder */
|
/** Whether this is a linked local folder */
|
||||||
linked?: boolean;
|
linked?: boolean;
|
||||||
|
/**
|
||||||
|
* The case folder did not answer (an unreachable network mount, or an error other
|
||||||
|
* than "no such file"), or its probe was refused because folders on other unreachable
|
||||||
|
* mounts are still not answering, so whether it still exists is unknown. A refused
|
||||||
|
* probe can set this on a healthy linked case. Absent = it answered.
|
||||||
|
*/
|
||||||
|
unreachable?: boolean;
|
||||||
/**
|
/**
|
||||||
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
|
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
|
||||||
* (the packaged skill's workers, or any spawn naming a parent session), read back
|
* (the packaged skill's workers, or any spawn naming a parent session), read back
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Bounded existence probe for user-chosen paths.
|
||||||
|
*
|
||||||
|
* A linked case can live on a network mount (NFS, SMB, sshfs). When that mount
|
||||||
|
* goes unreachable, a hard mount makes `stat()` wait forever. A synchronous
|
||||||
|
* probe (`existsSync`) on such a path blocks the event loop and freezes the
|
||||||
|
* whole web server; even an async `stat()` never settles and permanently holds
|
||||||
|
* one of libuv's few threadpool workers, which every other `fs`, `dns.lookup`
|
||||||
|
* and `crypto` call in the process shares.
|
||||||
|
*
|
||||||
|
* The probe therefore answers one of THREE things, never two:
|
||||||
|
* - `'present'` / `'absent'`: the filesystem answered (ENOENT and ENOTDIR are
|
||||||
|
* the only errors that mean absent);
|
||||||
|
* - `'unknown'`: it did not answer in `PATH_PROBE_TIMEOUT_MS`, it answered with
|
||||||
|
* some other error (EIO from a soft mount that gave up, EACCES), or the probe
|
||||||
|
* was refused (below). "Unknown" is NOT "absent": a caller that would create,
|
||||||
|
* scaffold or 404 on absence must not do so on unknown.
|
||||||
|
*
|
||||||
|
* And it keeps a dead mount from draining the threadpool:
|
||||||
|
* - one in-flight probe per path, shared by concurrent callers;
|
||||||
|
* - a path whose probe timed out is "stalled" until that stat finally settles.
|
||||||
|
* Paths NEAR a stalled one are answered "unknown" without a new stat, so one
|
||||||
|
* dead mount costs one worker, not one per case and file on it. "Near" means on
|
||||||
|
* the same mount when that mount is a network or FUSE filesystem (NFS, SMB,
|
||||||
|
* sshfs and the like): under the deepest mount point holding the stalled path,
|
||||||
|
* with its type, read from `/proc/self/mounts` (procfs, which never waits on the
|
||||||
|
* dead filesystem). Otherwise it narrows to the stalled path and everything under
|
||||||
|
* it: when the deepest mount is local (a path typed under a local `/home` can
|
||||||
|
* reach a NAS through a symlink, and must not take the rest of `/home` with it),
|
||||||
|
* is `/`, or the table is unavailable (not Linux). Unrelated paths are probed
|
||||||
|
* normally;
|
||||||
|
* - once `MAX_STALLED_PATH_PROBES` stalled stats are pending, new probes are
|
||||||
|
* refused process-wide (answered "unknown"), since each would risk another
|
||||||
|
* worker. Probes merely in flight do not count, so concurrent healthy probes
|
||||||
|
* never get refused. A caller acting on ONE path at a user's explicit request
|
||||||
|
* (opening a case, starting a session in it) may pass `{ pastCap: true }`: its
|
||||||
|
* probe is still bounded and still recorded as stalled if it hangs (so a dead
|
||||||
|
* path costs at most one worker however often it is retried), but it is not
|
||||||
|
* refused just because unrelated mounts are dead. Bulk scans (the case list)
|
||||||
|
* keep the cap; the per-spawn hook and statusLine helpers retry one refused
|
||||||
|
* probe past it and then skip a path that still answers "unknown", rather than
|
||||||
|
* touch it with an unbounded call. `pastCap` still stops at
|
||||||
|
* `PATH_PROBE_STALL_CEILING` (the threadpool size minus one), so explicit
|
||||||
|
* requests against several dead paths can never take the last worker.
|
||||||
|
*
|
||||||
|
* Both events are logged once (`console.warn`): a path's first stall, and the
|
||||||
|
* cap engaging, so "my case vanished" and "hooks stopped firing" leave a trace.
|
||||||
|
*
|
||||||
|
* Writers should not use this at all: a writer that must tell "missing" apart
|
||||||
|
* from "unreachable" wants an ENOENT-aware async `lstat` (see
|
||||||
|
* `pathExistsForWrite` in hooks-config.ts).
|
||||||
|
*
|
||||||
|
* @module utils/bounded-path-probe
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../config/path-probe.js';
|
||||||
|
|
||||||
|
/** What a probe could establish about a path. */
|
||||||
|
export type PathProbeState = 'present' | 'absent' | 'unknown';
|
||||||
|
/** Like {@link PathProbeState}, with "present" split by whether it is a directory. */
|
||||||
|
export type PathProbeKind = 'directory' | 'file' | 'absent' | 'unknown';
|
||||||
|
|
||||||
|
const inFlight = new Map<string, Promise<PathProbeKind>>();
|
||||||
|
/** Stalled path -> the directory whose subtree is answered "unknown" while it stays stalled. */
|
||||||
|
const stalled = new Map<string, string>();
|
||||||
|
let capWarned = false;
|
||||||
|
|
||||||
|
async function statKind(path: string): Promise<PathProbeKind> {
|
||||||
|
try {
|
||||||
|
return (await fs.stat(path)).isDirectory() ? 'directory' : 'file';
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException)?.code;
|
||||||
|
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithin(path: string, root: string): boolean {
|
||||||
|
if (path === root) return true;
|
||||||
|
return path.startsWith(root.endsWith(sep) ? root : root + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filesystem types whose stall means the whole mount is gone (network and FUSE). */
|
||||||
|
const REMOTE_FS_TYPES = new Set([
|
||||||
|
'nfs',
|
||||||
|
'nfs4',
|
||||||
|
'cifs',
|
||||||
|
'smb3',
|
||||||
|
'smbfs',
|
||||||
|
'9p',
|
||||||
|
'ceph',
|
||||||
|
'glusterfs',
|
||||||
|
'afs',
|
||||||
|
'lustre',
|
||||||
|
'davfs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isRemoteFsType(fsType: string): boolean {
|
||||||
|
return REMOTE_FS_TYPES.has(fsType) || fsType.startsWith('fuse.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepest mount holding `abs`, from the kernel's mount table; undefined when unreadable. */
|
||||||
|
function mountOf(abs: string): { mountPoint: string; fsType: string } | undefined {
|
||||||
|
let table: string;
|
||||||
|
try {
|
||||||
|
table = readFileSync('/proc/self/mounts', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let best: { mountPoint: string; fsType: string } | undefined;
|
||||||
|
for (const line of table.split('\n')) {
|
||||||
|
const [, field, fsType] = line.split(' ');
|
||||||
|
if (!field || !fsType) continue;
|
||||||
|
// The table octal-escapes space, tab, newline and backslash in mount points.
|
||||||
|
const mountPoint = field.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)));
|
||||||
|
if (isWithin(abs, mountPoint) && (!best || mountPoint.length > best.mountPoint.length)) {
|
||||||
|
best = { mountPoint, fsType };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtree a stalled path takes down with it (see the module comment): its
|
||||||
|
* mount when that is a network or FUSE filesystem, else just the path itself.
|
||||||
|
*/
|
||||||
|
function stallScope(abs: string): string {
|
||||||
|
const mount = mountOf(abs);
|
||||||
|
return mount && mount.mountPoint !== '/' && isRemoteFsType(mount.fsType) ? mount.mountPoint : abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `path` is near a path whose probe is still stalled (see the module
|
||||||
|
* comment), i.e. whether the probe would answer "unknown" for it without a stat.
|
||||||
|
* Lets a caller tell "this workspace sits on the dead mount" apart from "the
|
||||||
|
* probe was refused for capacity".
|
||||||
|
*/
|
||||||
|
export function isNearStalledPath(path: string): boolean {
|
||||||
|
const abs = resolve(path);
|
||||||
|
for (const scope of stalled.values()) {
|
||||||
|
if (isWithin(abs, scope)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for {@link probePathKind} / {@link probePath}. */
|
||||||
|
export interface PathProbeOptions {
|
||||||
|
/** Probe even while the stall cap is engaged (see the module comment). */
|
||||||
|
pastCap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe `path` without letting an unresponsive filesystem block the caller for
|
||||||
|
* longer than `PATH_PROBE_TIMEOUT_MS`. Follows symlinks, like `stat()`.
|
||||||
|
*/
|
||||||
|
export async function probePathKind(path: string, options: PathProbeOptions = {}): Promise<PathProbeKind> {
|
||||||
|
const abs = resolve(path);
|
||||||
|
if (isNearStalledPath(abs)) return 'unknown';
|
||||||
|
|
||||||
|
let probe = inFlight.get(abs);
|
||||||
|
if (!probe) {
|
||||||
|
// pastCap lifts the bulk cap, never the ceiling that keeps one worker free.
|
||||||
|
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) {
|
||||||
|
if (!capWarned) {
|
||||||
|
capWarned = true;
|
||||||
|
console.warn(
|
||||||
|
`[path-probe] ${stalled.size} path probes are stalled on unresponsive filesystems; ` +
|
||||||
|
'not starting new ones until one answers (paths read as unknown meanwhile)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
probe = statKind(abs);
|
||||||
|
const started = probe;
|
||||||
|
inFlight.set(abs, started);
|
||||||
|
void started.finally(() => {
|
||||||
|
inFlight.delete(abs);
|
||||||
|
stalled.delete(abs);
|
||||||
|
if (stalled.size < MAX_STALLED_PATH_PROBES) capWarned = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
probe,
|
||||||
|
new Promise<PathProbeKind>((resolveTimeout) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (inFlight.get(abs) === probe && !stalled.has(abs)) {
|
||||||
|
stalled.set(abs, stallScope(abs));
|
||||||
|
console.warn(
|
||||||
|
`[path-probe] ${abs} did not answer within ${PATH_PROBE_TIMEOUT_MS} ms ` +
|
||||||
|
'(unreachable mount?); treating it and its neighbours as unknown until it does'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolveTimeout('unknown');
|
||||||
|
}, PATH_PROBE_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a probe of `path` answers "unknown" right now: its mount is not answering
|
||||||
|
* (`'stalled'`, it is near a stalled probe), new probes are refused because enough
|
||||||
|
* UNRELATED paths are stalled (`'refused'`; `pastCap` picks which limit applies), or
|
||||||
|
* neither, so the filesystem answered with an error such as EACCES or EIO
|
||||||
|
* (`'unreadable'`). For messages only: it reads the state now, not at probe time.
|
||||||
|
*/
|
||||||
|
export function unknownPathReason(path: string, options: PathProbeOptions = {}): 'stalled' | 'refused' | 'unreadable' {
|
||||||
|
if (isNearStalledPath(path)) return 'stalled';
|
||||||
|
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) return 'refused';
|
||||||
|
return 'unreadable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing sentence for an "unknown" probe of `path` (`label` names it, e.g.
|
||||||
|
* "workingDir"). A refused probe says so, rather than blaming a folder that was never
|
||||||
|
* checked: at the ceiling every new folder reads "unknown" until a dead mount answers.
|
||||||
|
*/
|
||||||
|
export function describeUnknownPath(label: string, path: string, options: PathProbeOptions = {}): string {
|
||||||
|
return unknownPathReason(path, options) === 'refused'
|
||||||
|
? `${label} was not checked: folders on other unreachable mounts are still not answering, ` +
|
||||||
|
`so Codeman is not checking new folders until one does (see the server log): ${path}`
|
||||||
|
: `${label} is not responding or not readable: ${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tri-state probe of `path`; see the module comment for what "unknown" means. */
|
||||||
|
export async function probePath(path: string, options: PathProbeOptions = {}): Promise<PathProbeState> {
|
||||||
|
const kind = await probePathKind(path, options);
|
||||||
|
return kind === 'directory' || kind === 'file' ? 'present' : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` only when `path` is known to exist. For DISPLAY decisions only (does a
|
||||||
|
* case have a CLAUDE.md): it folds "unknown" into `false`, so never use it to
|
||||||
|
* decide that something is absent and may be created, scaffolded or reported
|
||||||
|
* missing; use {@link probePath} for that.
|
||||||
|
*/
|
||||||
|
export async function boundedPathExists(path: string): Promise<boolean> {
|
||||||
|
return (await probePath(path)) === 'present';
|
||||||
|
}
|
||||||
@@ -122,7 +122,8 @@ export interface ProductionCliResolverHostOptions {
|
|||||||
allowRealIoUnderVitest?: boolean;
|
allowRealIoUnderVitest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExecutableRegularFile(path: string): boolean {
|
/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */
|
||||||
|
export function isExecutableRegularFile(path: string): boolean {
|
||||||
try {
|
try {
|
||||||
if (!statSync(path).isFile()) return false;
|
if (!statSync(path).isFile()) return false;
|
||||||
accessSync(path, constants.X_OK);
|
accessSync(path, constants.X_OK);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { isAbsolute, join } from 'node:path';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import { isExecutableRegularFile } from './cli-executable-resolver.js';
|
||||||
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
|
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
|
||||||
|
|
||||||
export interface EnvDetectionInputs {
|
export interface EnvDetectionInputs {
|
||||||
@@ -62,6 +64,8 @@ export interface ProbeHost {
|
|||||||
environment: ProbeEnvironment;
|
environment: ProbeEnvironment;
|
||||||
which(bin: string): string | null;
|
which(bin: string): string | null;
|
||||||
fileExists(path: string): boolean;
|
fileExists(path: string): boolean;
|
||||||
|
/** An executable regular file, the run mode's own test for a `searchDirs` candidate. */
|
||||||
|
isExecutableFile(path: string): boolean;
|
||||||
runVersion(bin: string, args: string[]): string | null;
|
runVersion(bin: string, args: string[]): string | null;
|
||||||
windowsProgramRoots(): string[];
|
windowsProgramRoots(): string[];
|
||||||
windowsFileVersion(winPath: string): string | null;
|
windowsFileVersion(winPath: string): string | null;
|
||||||
@@ -94,18 +98,33 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
|
|||||||
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
|
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
|
||||||
|
|
||||||
if (spec.resolver.kind === 'path') {
|
if (spec.resolver.kind === 'path') {
|
||||||
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
|
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
|
||||||
for (const bin of bins) {
|
for (const bin of bins) {
|
||||||
const resolved = host.which(bin);
|
// The same candidate order and the same per-candidate test as the run mode's resolver
|
||||||
if (resolved) {
|
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
|
||||||
const out = host.runVersion(bin, [versionArg ?? '--version']);
|
// a service the PATH is minimal and the run mode finds the CLI through those dirs, so
|
||||||
|
// the doctor must too. A search-dir candidate counts only as an absolute path to an
|
||||||
|
// executable regular file, so a relative dir from a custom clis.json or a file without
|
||||||
|
// the x bit reads as missing here exactly as it does in the Run menu.
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const onPath = host.which(bin);
|
||||||
|
if (onPath && isAbsolute(onPath)) candidates.push(onPath);
|
||||||
|
for (const dir of searchDirs ?? []) {
|
||||||
|
const candidate = join(dir, bin);
|
||||||
|
if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH
|
||||||
|
if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate);
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
|
||||||
|
const out = host.runVersion(candidate, [versionArg ?? '--version']);
|
||||||
const version = out ? extractVersion(out, versionRegex) : undefined;
|
const version = out ? extractVersion(out, versionRegex) : undefined;
|
||||||
// A generic binary name that prints the wrong thing is some OTHER program (see
|
// A generic binary name that prints the wrong thing is some OTHER program (see
|
||||||
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; the
|
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
|
||||||
// alternative is claiming a tool is installed that the feature's own resolver
|
// the alternative is claiming a tool is installed that the feature's own resolver
|
||||||
// rejects, which reads as "the mode is broken" rather than "install it".
|
// rejects, or missing one it accepts (an npm squatter on the PATH in front of the
|
||||||
|
// real grok in ~/.grok/bin), which reads as "the mode is broken".
|
||||||
if (requireVersionMatch && !version) continue;
|
if (requireVersionMatch && !version) continue;
|
||||||
return finalize(base, tool, resolved, version);
|
return finalize(base, tool, candidate, version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...base, status: 'missing', installHint };
|
return { ...base, status: 'missing', installHint };
|
||||||
@@ -132,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
|
|||||||
return registry.map((tool) => checkTool(tool, host));
|
return registry.map((tool) => checkTool(tool, host));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then
|
||||||
|
// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold
|
||||||
|
// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned.
|
||||||
|
// Same reasoning as the resolver host in cli-executable-resolver.ts.
|
||||||
function safeWhich(bin: string): string | null {
|
function safeWhich(bin: string): string | null {
|
||||||
try {
|
try {
|
||||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
}).trim();
|
}).trim();
|
||||||
const first = out.split(/\r?\n/)[0]?.trim();
|
const first = out.split(/\r?\n/)[0]?.trim();
|
||||||
return first && existsSync(first) ? first : null;
|
return first && existsSync(first) ? first : null;
|
||||||
@@ -151,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
|
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
|
||||||
@@ -187,11 +212,12 @@ function readWindowsFileVersion(winPath: string): string | null {
|
|||||||
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
|
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
}).trim();
|
}).trim();
|
||||||
const out = execFileSync(
|
const out = execFileSync(
|
||||||
'powershell.exe',
|
'powershell.exe',
|
||||||
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' }
|
||||||
).trim();
|
).trim();
|
||||||
return out || null;
|
return out || null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -209,6 +235,7 @@ export function createRealHost(): ProbeHost {
|
|||||||
environment,
|
environment,
|
||||||
which: safeWhich,
|
which: safeWhich,
|
||||||
fileExists: existsSync,
|
fileExists: existsSync,
|
||||||
|
isExecutableFile: isExecutableRegularFile,
|
||||||
runVersion: safeRunVersion,
|
runVersion: safeRunVersion,
|
||||||
windowsProgramRoots: listWindowsProgramRoots,
|
windowsProgramRoots: listWindowsProgramRoots,
|
||||||
windowsFileVersion: readWindowsFileVersion,
|
windowsFileVersion: readWindowsFileVersion,
|
||||||
|
|||||||
@@ -68,3 +68,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
|||||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
export type { FileQueryMatcher } from './file-query.js';
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||||
|
export {
|
||||||
|
boundedPathExists,
|
||||||
|
describeUnknownPath,
|
||||||
|
probePath,
|
||||||
|
probePathKind,
|
||||||
|
isNearStalledPath,
|
||||||
|
unknownPathReason,
|
||||||
|
} from './bounded-path-probe.js';
|
||||||
|
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
|
||||||
|
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
|
||||||
|
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
|
||||||
|
* judged before anything is created:
|
||||||
|
*
|
||||||
|
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
|
||||||
|
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
|
||||||
|
* a case this accepts is one a session can actually start in;
|
||||||
|
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
|
||||||
|
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
|
||||||
|
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
|
||||||
|
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
|
||||||
|
* around it;
|
||||||
|
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
|
||||||
|
* folder listed both as a local case and as a linked one would make deleting it remove files;
|
||||||
|
* - its parent must already exist (one folder is created, never a whole chain), and the folder
|
||||||
|
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
|
||||||
|
* job, and silently scaffolding into someone's project is the one thing this must never do);
|
||||||
|
* - it must not be a symlink.
|
||||||
|
*
|
||||||
|
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
|
||||||
|
* so it can be tested without a disk.
|
||||||
|
*
|
||||||
|
* @module web/case-path
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
||||||
|
import { isValidWorkingDir } from './schemas.js';
|
||||||
|
import { describeUnknownPath, probePath } from '../utils/index.js';
|
||||||
|
|
||||||
|
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
|
||||||
|
const BLOCKED_SYSTEM_ROOTS = [
|
||||||
|
'/bin',
|
||||||
|
'/boot',
|
||||||
|
'/dev',
|
||||||
|
'/etc',
|
||||||
|
'/lib',
|
||||||
|
'/lib32',
|
||||||
|
'/lib64',
|
||||||
|
'/proc',
|
||||||
|
'/run',
|
||||||
|
'/sbin',
|
||||||
|
'/sys',
|
||||||
|
'/usr',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Home-relative trees that hold credentials or other tools' own configuration. */
|
||||||
|
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];
|
||||||
|
|
||||||
|
export interface NewCasePathContext {
|
||||||
|
home: string;
|
||||||
|
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
|
||||||
|
dataDir: string;
|
||||||
|
/**
|
||||||
|
* The cases directories (the caller's own and the shared one). A folder in one of them is already
|
||||||
|
* listed as a local case, so it must not be registered as a linked one too.
|
||||||
|
*/
|
||||||
|
casesDirs?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewCasePathResult =
|
||||||
|
| { ok: true; path: string; existedEmpty: boolean }
|
||||||
|
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE'; reason: string };
|
||||||
|
|
||||||
|
const isWithin = (child: string, root: string): boolean =>
|
||||||
|
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
|
||||||
|
|
||||||
|
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
|
||||||
|
export function expandHome(raw: string, home: string): string {
|
||||||
|
if (raw === '~') return home;
|
||||||
|
if (raw.startsWith('~/')) return join(home, raw.slice(2));
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
|
||||||
|
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
|
||||||
|
*/
|
||||||
|
export function blockedReason(
|
||||||
|
absPath: string,
|
||||||
|
ctx: NewCasePathContext,
|
||||||
|
systemRoots: readonly string[] = BLOCKED_SYSTEM_ROOTS
|
||||||
|
): string | null {
|
||||||
|
if (absPath === sep) return 'The filesystem root cannot be a case';
|
||||||
|
for (const root of systemRoots) {
|
||||||
|
if (isWithin(absPath, root)) return `${root} is a system directory`;
|
||||||
|
}
|
||||||
|
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
|
||||||
|
for (const dir of BLOCKED_HOME_DIRS) {
|
||||||
|
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
|
||||||
|
}
|
||||||
|
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
|
||||||
|
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
|
||||||
|
// the one this process uses.
|
||||||
|
if (absPath.startsWith(ctx.home + sep)) {
|
||||||
|
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
|
||||||
|
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
|
||||||
|
}
|
||||||
|
for (const dir of ctx.casesDirs ?? []) {
|
||||||
|
if (isWithin(absPath, dir)) {
|
||||||
|
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
|
||||||
|
async function realpathOr(p: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fs.realpath(p);
|
||||||
|
} catch {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
|
||||||
|
async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathContext, string[]]> {
|
||||||
|
const [home, dataDir, casesDirs, systemRoots] = await Promise.all([
|
||||||
|
realpathOr(ctx.home),
|
||||||
|
realpathOr(ctx.dataDir),
|
||||||
|
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
|
||||||
|
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
|
||||||
|
]);
|
||||||
|
return [{ home, dataDir, casesDirs }, systemRoots];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
|
||||||
|
* Never creates anything.
|
||||||
|
*/
|
||||||
|
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
|
||||||
|
const typed = raw.trim();
|
||||||
|
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
|
||||||
|
const expanded = expandHome(typed, ctx.home);
|
||||||
|
if (!isValidWorkingDir(expanded)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID',
|
||||||
|
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolve(expanded);
|
||||||
|
const typedBlock = blockedReason(target, ctx);
|
||||||
|
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
|
||||||
|
|
||||||
|
// Bounded first: the parent can sit on a network mount that stopped answering, where the
|
||||||
|
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
|
||||||
|
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
|
||||||
|
const parentState = await probePath(dirname(target), { pastCap: true });
|
||||||
|
if (parentState === 'absent') {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
|
||||||
|
}
|
||||||
|
if (parentState === 'unknown') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'UNREACHABLE',
|
||||||
|
reason: describeUnknownPath('The parent folder', dirname(target), { pastCap: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
|
||||||
|
let realParent: string;
|
||||||
|
try {
|
||||||
|
realParent = await fs.realpath(dirname(target));
|
||||||
|
if (!(await fs.stat(realParent)).isDirectory()) {
|
||||||
|
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
|
||||||
|
}
|
||||||
|
const real = join(realParent, basename(target));
|
||||||
|
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
|
||||||
|
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
|
||||||
|
const [resolvedCtx, resolvedSystemRoots] = await resolvedPolicy(ctx);
|
||||||
|
const realBlock = blockedReason(real, ctx) ?? blockedReason(real, resolvedCtx, resolvedSystemRoots);
|
||||||
|
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const st = await fs.lstat(real);
|
||||||
|
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
|
||||||
|
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
|
||||||
|
if ((await fs.readdir(real)).length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'EXISTS',
|
||||||
|
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, path: real, existedEmpty: true };
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
|
||||||
|
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
+786
-30
@@ -419,7 +419,14 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
id: 'close-session',
|
id: 'close-session',
|
||||||
group: 'Session',
|
group: 'Session',
|
||||||
label: 'Close Session',
|
label: 'Close Session',
|
||||||
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
|
// ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
|
||||||
|
// word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||||
|
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
|
||||||
|
// with the split open it was not even the pane being typed in. Ctrl+W now
|
||||||
|
// reaches the terminal like any other key. Closing stays on the tab's close
|
||||||
|
// control and menu (with their confirm), and anyone who wants a key binds
|
||||||
|
// one in App Settings → Shortcuts.
|
||||||
|
bindings: [],
|
||||||
action: 'killActiveSession',
|
action: 'killActiveSession',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -577,14 +584,23 @@ const SIDEBAR_RICH_CLOCK_MS = 20000;
|
|||||||
*/
|
*/
|
||||||
const URL_SESSION_WAIT_MS = 30000;
|
const URL_SESSION_WAIT_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How old the sessionStorage copy of unsaved tab-group edits may be when the
|
||||||
|
* next page replays it (see _restorePendingTabLayoutEdits). A reload takes
|
||||||
|
* seconds; an older copy is from a tab that sat closed or a different visit.
|
||||||
|
*/
|
||||||
|
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
|
||||||
|
|
||||||
class CodemanApp {
|
class CodemanApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||||
this.draggedTabId = null; // Currently dragged tab session ID
|
this.draggedTabId = null; // Currently dragged tab session ID
|
||||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
// Owner tab layout: read via GET /api/tab-layout and edited from the vertical
|
||||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
// rail via PUT /api/tab-layout (editTabLayout). It decides how the rail GROUPS
|
||||||
|
// rows; the server projects it onto the session order, so sessionOrder above
|
||||||
|
// stays the tab order.
|
||||||
this.tabLayout = null;
|
this.tabLayout = null;
|
||||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||||
@@ -858,6 +874,11 @@ class CodemanApp {
|
|||||||
// writes when the freshly computed descriptor is identical (COD-136).
|
// writes when the freshly computed descriptor is identical (COD-136).
|
||||||
this._lastIndicatorDescriptor = null;
|
this._lastIndicatorDescriptor = null;
|
||||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||||
|
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||||
|
// session they are bound to: the split pane's second terminal registers its
|
||||||
|
// socket here so its input rides the same exactly-once queue. Values are
|
||||||
|
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
|
||||||
|
this._extraInputSockets = new Map();
|
||||||
this._persistReliableTimer = null;
|
this._persistReliableTimer = null;
|
||||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||||
@@ -866,6 +887,8 @@ class CodemanApp {
|
|||||||
// Flush the durable queue synchronously when the page is hidden/closed —
|
// Flush the durable queue synchronously when the page is hidden/closed —
|
||||||
// debounced persistence may have a pending write we mustn't lose on reload.
|
// debounced persistence may have a pending write we mustn't lose on reload.
|
||||||
window.addEventListener('pagehide', () => this._persistReliableNow());
|
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||||
|
// Tab group edits not yet confirmed by the server survive a reload.
|
||||||
|
window.addEventListener('pagehide', () => this._persistPendingTabLayoutEdits());
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||||
// A background tab's timers are throttled, so the 5s watchdog may not
|
// A background tab's timers are throttled, so the 5s watchdog may not
|
||||||
@@ -1267,6 +1290,16 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// An open group menu (or a grouped-rail drag) owns this Escape: close
|
||||||
|
// just that, not every panel behind it.
|
||||||
|
if (this._tabGroupMenu && this._tabGroupMenuKeydown) {
|
||||||
|
this._tabGroupMenuKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._tabLayoutDrag?.active && this._tabLayoutDragKeydown) {
|
||||||
|
this._tabLayoutDragKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.closeAllPanels();
|
this.closeAllPanels();
|
||||||
this.closeHelp();
|
this.closeHelp();
|
||||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||||
@@ -3376,7 +3409,7 @@ class CodemanApp {
|
|||||||
} else if (msg.t === 'ia') {
|
} else if (msg.t === 'ia') {
|
||||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||||
// the durable queue so it can never be re-delivered/lost.
|
// the durable queue so it can never be re-delivered/lost.
|
||||||
this._onWsInputAck(msg.seq, msg);
|
this._onWsInputAck(msg.seq, msg, sessionId);
|
||||||
} else if (msg.t === 'zc') {
|
} else if (msg.t === 'zc') {
|
||||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||||
// always the one this client asked for (issue #464).
|
// always the one this client asked for (issue #464).
|
||||||
@@ -3558,6 +3591,49 @@ class CodemanApp {
|
|||||||
this._reliableSend(sessionId, input, useMux);
|
this._reliableSend(sessionId, input, useMux);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
|
||||||
|
* first, then one registered by a second terminal (the split pane). The
|
||||||
|
* delivery layer below asks this instead of reading `this._ws` directly, so a
|
||||||
|
* second terminal's input gets the same exactly-once queue, ACKs and
|
||||||
|
* half-open detection as the primary's.
|
||||||
|
*
|
||||||
|
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
|
||||||
|
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
|
||||||
|
*/
|
||||||
|
_inputSocketFor(sessionId) {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||||
|
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
|
||||||
|
}
|
||||||
|
const handle = this._extraInputSockets?.get(sessionId);
|
||||||
|
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a second terminal's socket for `sessionId` (call from its onopen).
|
||||||
|
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
|
||||||
|
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
|
||||||
|
* just opened does not look silent to the redelivery sweep, which would
|
||||||
|
* otherwise force-close it as half-open on the first stale record.
|
||||||
|
*/
|
||||||
|
_registerInputSocket(sessionId, handle) {
|
||||||
|
if (!sessionId || !handle) return;
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
if (!this._extraInputSockets) this._extraInputSockets = new Map();
|
||||||
|
this._extraInputSockets.set(sessionId, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a registration, but only the one `handle` made: a replacement socket
|
||||||
|
* registers its own handle before the old one's close lands, and that late
|
||||||
|
* close must not unregister the socket that replaced it.
|
||||||
|
*/
|
||||||
|
_unregisterInputSocket(sessionId, handle) {
|
||||||
|
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
||||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||||
@@ -3569,9 +3645,10 @@ class CodemanApp {
|
|||||||
*/
|
*/
|
||||||
_sendInputEphemeral(sessionId, input) {
|
_sendInputEphemeral(sessionId, input) {
|
||||||
if (!sessionId || !input) return;
|
if (!sessionId || !input) return;
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// socket died mid-send — fall through to a best-effort POST
|
// socket died mid-send — fall through to a best-effort POST
|
||||||
@@ -3625,11 +3702,12 @@ class CodemanApp {
|
|||||||
// over the single ordered stream. They stay pending until the server ACKs
|
// over the single ordered stream. They stay pending until the server ACKs
|
||||||
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||||
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
for (const rec of list) {
|
for (const rec of list) {
|
||||||
if (rec.sentAt !== 0) continue;
|
if (rec.sentAt !== 0) continue;
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||||
rec.sentAt = Date.now();
|
rec.sentAt = Date.now();
|
||||||
rec.tries++;
|
rec.tries++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3650,7 +3728,7 @@ class CodemanApp {
|
|||||||
if (!cur || cur.length === 0) break;
|
if (!cur || cur.length === 0) break;
|
||||||
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||||
// so we don't redundantly re-POST what onopen is already re-sending.
|
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
if (this._inputSocketFor(sessionId)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const rec = cur[0];
|
const rec = cur[0];
|
||||||
@@ -3738,8 +3816,10 @@ class CodemanApp {
|
|||||||
* called a duplicate is the mechanism working as designed — the original did
|
* called a duplicate is the mechanism working as designed — the original did
|
||||||
* land — and re-sending it would type the same thing twice.
|
* land — and re-sending it would type the same thing twice.
|
||||||
*/
|
*/
|
||||||
_onWsInputAck(seq, msg) {
|
_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
|
||||||
const sessionId = this._wsSessionId;
|
// `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
|
||||||
|
// frames carry none, and with a second terminal's socket in play
|
||||||
|
// `this._wsSessionId` is no longer the only candidate.
|
||||||
if (!sessionId || !Number.isInteger(seq)) return;
|
if (!sessionId || !Number.isInteger(seq)) return;
|
||||||
if (msg && msg.err) {
|
if (msg && msg.err) {
|
||||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||||
@@ -3784,19 +3864,20 @@ class CodemanApp {
|
|||||||
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||||
const list = this._pendingDeliveries.get(sessionId);
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
if (!list || list.length === 0) continue;
|
if (!list || list.length === 0) continue;
|
||||||
const isActiveWs =
|
const sock = this._inputSocketFor(sessionId);
|
||||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
if (sock) {
|
||||||
if (isActiveWs) {
|
|
||||||
const oldest = list[0];
|
const oldest = list[0];
|
||||||
// Only tear the socket down when the oldest unacked frame is stale AND the
|
// Only tear the socket down when the oldest unacked frame is stale AND the
|
||||||
// socket has been silent for the timeout: a connection still delivering
|
// socket has been silent for the timeout: a connection still delivering
|
||||||
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
||||||
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
||||||
|
// Silence is measured on THIS socket: the primary's last frame says
|
||||||
|
// nothing about a second terminal's connection, and the reverse.
|
||||||
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
||||||
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
|
||||||
if (stale && silent) {
|
if (stale && silent) {
|
||||||
try {
|
try {
|
||||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
sock.ws.close(); // half-open: never recovers on its own — force reconnect
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -4507,6 +4588,10 @@ class CodemanApp {
|
|||||||
// a give-up) nothing else would re-establish it from here.
|
// a give-up) nothing else would re-establish it from here.
|
||||||
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
||||||
void this._onSessionNeedsRefresh({ id: restoreId });
|
void this._onSessionNeedsRefresh({ id: restoreId });
|
||||||
|
// The split pane's second terminal reconnects on its own backoff (up to
|
||||||
|
// 10 s between tries); the server is back now, so skip the wait. A no-op
|
||||||
|
// while its socket is open, stopped for good, or destroyed.
|
||||||
|
this._splitPane?.reconnectNow?.();
|
||||||
} else {
|
} else {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
// `auto`: the app is restoring a session on load, not a human opening
|
// `auto`: the app is restoring a session on load, not a human opening
|
||||||
@@ -5695,6 +5780,8 @@ class CodemanApp {
|
|||||||
_fullRenderSessionTabs() {
|
_fullRenderSessionTabs() {
|
||||||
this.closeTabRailActionMenu?.();
|
this.closeTabRailActionMenu?.();
|
||||||
if (this._inlineRenameActive) return;
|
if (this._inlineRenameActive) return;
|
||||||
|
// The group menu's trigger is about to be replaced.
|
||||||
|
this.closeTabGroupMenu();
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
|
|
||||||
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
||||||
@@ -5724,9 +5811,11 @@ class CodemanApp {
|
|||||||
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
||||||
// back on the same item (by identity) so a background render or a keyboard
|
// back on the same item (by identity) so a background render or a keyboard
|
||||||
// collapse does not drop a keyboard user to <body>.
|
// collapse does not drop a keyboard user to <body>.
|
||||||
const focusWasInside = container.contains(document.activeElement);
|
// An edit made from a menu (focus now on <body>) asks to land back in the rail.
|
||||||
|
const focusWasInside = container.contains(document.activeElement) || this._tabRefocusAfterEdit === true;
|
||||||
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
||||||
this._tabFocusIdentity = null;
|
this._tabFocusIdentity = null;
|
||||||
|
this._tabRefocusAfterEdit = false;
|
||||||
|
|
||||||
// Build tabs HTML using array for better string concatenation performance.
|
// Build tabs HTML using array for better string concatenation performance.
|
||||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||||
@@ -5887,6 +5976,9 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Set up drag-and-drop handlers for tab reordering
|
// Set up drag-and-drop handlers for tab reordering
|
||||||
this.setupTabDragHandlers();
|
this.setupTabDragHandlers();
|
||||||
|
// The grouped rail drags with its own pointer model (rows across groups,
|
||||||
|
// group reorder); bound once, inert unless the rail is grouped.
|
||||||
|
this._bindTabLayoutPointerDrag(container);
|
||||||
|
|
||||||
// Set up keyboard navigation for tabs
|
// Set up keyboard navigation for tabs
|
||||||
this.setupTabKeyboardNavigation(container);
|
this.setupTabKeyboardNavigation(container);
|
||||||
@@ -6211,18 +6303,22 @@ class CodemanApp {
|
|||||||
if (groupId) toggle();
|
if (groupId) toggle();
|
||||||
else this._activateTabRow(current);
|
else this._activateTabRow(current);
|
||||||
break;
|
break;
|
||||||
|
case 'F2':
|
||||||
|
if (!groupId || !this.startTabGroupRename(groupId)) return;
|
||||||
|
break;
|
||||||
case 'F10':
|
case 'F10':
|
||||||
case 'ContextMenu':
|
case 'ContextMenu': {
|
||||||
if (e.key === 'F10' && !e.shiftKey) return;
|
if (e.key === 'F10' && !e.shiftKey) return;
|
||||||
if (current.dataset.id) {
|
const synthetic = { preventDefault() {}, stopPropagation() {}, currentTarget: current };
|
||||||
this.openTabRailActionMenu?.(
|
if (groupId) {
|
||||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
this.openTabGroupMenu(synthetic, groupId);
|
||||||
current.dataset.id
|
} else if (current.dataset.id) {
|
||||||
);
|
this.openTabRailActionMenu?.(synthetic, current.dataset.id);
|
||||||
} else if (current.dataset.webviewId) {
|
} else if (current.dataset.webviewId) {
|
||||||
this.showWebviewModal?.(current.dataset.webviewId);
|
this.openTabWebviewMenu(synthetic, current.dataset.webviewId);
|
||||||
} else return;
|
} else return;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6312,7 +6408,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Owner tab layout: grouped vertical rail (read-only)
|
// Owner tab layout: grouped vertical rail (reading and drawing)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
//
|
//
|
||||||
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
||||||
@@ -6320,7 +6416,7 @@ class CodemanApp {
|
|||||||
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
||||||
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
||||||
// collapse. With no groups (or any read failure) the rail is the flat list it
|
// collapse. With no groups (or any read failure) the rail is the flat list it
|
||||||
// has always been.
|
// has always been. Editing the groups is the next block.
|
||||||
|
|
||||||
_ensureTabLayoutCoordinator() {
|
_ensureTabLayoutCoordinator() {
|
||||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||||
@@ -6356,6 +6452,12 @@ class CodemanApp {
|
|||||||
// owner-scoped server-side, so this is a saved request, not a guard.)
|
// owner-scoped server-side, so this is a saved request, not a guard.)
|
||||||
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
||||||
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
||||||
|
// Our own write is in flight: its response is the newer truth, and a read
|
||||||
|
// racing it could repaint the pre-edit layout. Re-read once it settles.
|
||||||
|
if (this._tabLayoutEditor?.hasPending()) {
|
||||||
|
this._tabLayoutReloadPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._loadTabLayout();
|
this._loadTabLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6372,6 +6474,27 @@ class CodemanApp {
|
|||||||
// An overtaken response is already dropped by the coordinator; this guards a
|
// An overtaken response is already dropped by the coordinator; this guards a
|
||||||
// reordering between the coordinator and an SSE-triggered reload.
|
// reordering between the coordinator and an SSE-triggered reload.
|
||||||
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
||||||
|
const editor = this._tabLayoutEditor;
|
||||||
|
if (editor) {
|
||||||
|
// A failed read says nothing about the layout, and dropping the editor now
|
||||||
|
// would lose the edit outright: a write in flight would never get its 409
|
||||||
|
// rebased. Keep the held layout and the editor; read again once it settles.
|
||||||
|
if (!next && editor.hasPending()) {
|
||||||
|
this._tabLayoutReloadPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next && editor.isWriting()) {
|
||||||
|
// The write's own response decides; read again after it.
|
||||||
|
this._tabLayoutReloadPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unsaved edits are rebased onto the read (adoptExternal repaints); with
|
||||||
|
// none, the editor is simply rebuilt from the new layout on next use.
|
||||||
|
if (next && editor.hasPending() && editor.adoptExternal(next)) return;
|
||||||
|
if (editor.hasPending()) this.showToast?.('Your tab group edit was not saved.', 'error');
|
||||||
|
editor.dispose();
|
||||||
|
this._tabLayoutEditor = null;
|
||||||
|
}
|
||||||
this.tabLayout = next;
|
this.tabLayout = next;
|
||||||
const storage = this._getTabCollapseStorage();
|
const storage = this._getTabCollapseStorage();
|
||||||
const collapsed = storage && next
|
const collapsed = storage && next
|
||||||
@@ -6384,6 +6507,11 @@ class CodemanApp {
|
|||||||
// rail (always so on the flat rail, which is every owner without groups).
|
// rail (always so on the flat rail, which is every owner without groups).
|
||||||
// Rebuild only when what the rail would draw actually changed.
|
// Rebuild only when what the rail would draw actually changed.
|
||||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||||
|
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
|
||||||
|
if (next && !this._tabLayoutRestoreChecked) {
|
||||||
|
this._tabLayoutRestoreChecked = true;
|
||||||
|
this._restorePendingTabLayoutEdits();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||||
@@ -6446,6 +6574,629 @@ class CodemanApp {
|
|||||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Owner tab layout: editing groups from the vertical rail
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Every edit is a named operation (tab-layout-browser.js) applied to the rail
|
||||||
|
// at once and saved by ONE serialized PUT /api/tab-layout at a time, with the
|
||||||
|
// version the server last returned. A 409 is rebased onto the server's layout
|
||||||
|
// and retried; a failure re-reads. Editing is a vertical-rail feature: the
|
||||||
|
// header strip, the sidebar and phones never offer it.
|
||||||
|
|
||||||
|
_tabLayoutEditable() {
|
||||||
|
return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** child session id -> parent session id, so a moved session takes the sessions that follow it. */
|
||||||
|
_tabLayoutParents() {
|
||||||
|
const parents = {};
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session?.parentSessionId && session.parentSessionId !== session.id) parents[session.id] = session.parentSessionId;
|
||||||
|
}
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _putTabLayout({ baseVersion, layout }) {
|
||||||
|
const body = { baseVersion, layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } };
|
||||||
|
const response = await this._api('/api/tab-layout', { method: 'PUT', body });
|
||||||
|
if (!response) return { ok: false, status: 0, layout: null };
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {}
|
||||||
|
return { ok: response.ok, status: response.status, layout: data?.data?.layout || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureTabLayoutEditor() {
|
||||||
|
if (this._tabLayoutEditor || !this.tabLayout) return this._tabLayoutEditor || null;
|
||||||
|
this._tabLayoutEditor = window.CodemanTabLayout.createEditCoordinator({
|
||||||
|
initialLayout: this.tabLayout,
|
||||||
|
put: (request) => this._putTabLayout(request),
|
||||||
|
fetchLayout: async () => {
|
||||||
|
const data = await this._apiJson('/api/tab-layout');
|
||||||
|
if (!data?.layout) throw new Error('Tab layout unavailable');
|
||||||
|
return data.layout;
|
||||||
|
},
|
||||||
|
applyLayout: (layout) => this._adoptEditedTabLayout(layout),
|
||||||
|
reportError: (message) => this.showToast?.(message, 'error'),
|
||||||
|
onFailure: () => {
|
||||||
|
// The rail may still show an edit the server refused: read the truth.
|
||||||
|
this._tabLayoutReloadPending = true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (!this._tabLayoutReloadPending) return;
|
||||||
|
this._tabLayoutReloadPending = false;
|
||||||
|
this._loadTabLayout();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this._tabLayoutEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editor's view of the layout (optimistic or confirmed) becomes the rail. */
|
||||||
|
_adoptEditedTabLayout(layout) {
|
||||||
|
this.tabLayout = layout;
|
||||||
|
const storage = this._getTabCollapseStorage();
|
||||||
|
if (storage) {
|
||||||
|
// Forget collapse state for groups that no longer exist.
|
||||||
|
const collapsed = window.CodemanTabLayout.loadCollapsedGroupIds(storage, layout.groups.map((group) => group.id));
|
||||||
|
if (collapsed.ok) this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||||
|
}
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one edit. `focusIdentity` names the tree item that should hold focus
|
||||||
|
* afterwards (the moved row, the renamed group), so a keyboard user who acted
|
||||||
|
* from a menu lands back in the rail rather than on <body>.
|
||||||
|
*/
|
||||||
|
editTabLayout(operation, focusIdentity) {
|
||||||
|
if (!this._tabLayoutEditable()) return false;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const rail = this.$('sessionTabs');
|
||||||
|
if (focusIdentity && (!active || active === document.body || rail?.contains(active))) {
|
||||||
|
this._tabFocusIdentity = focusIdentity;
|
||||||
|
this._tabRefocusAfterEdit = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._ensureTabLayoutEditor().enqueue(operation);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this._tabRefocusAfterEdit = false;
|
||||||
|
this.showToast?.(error?.message || 'Could not save tab groups.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_newTabGroupId() {
|
||||||
|
return globalThis.crypto?.randomUUID?.() || `group-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New group (optionally holding `ref`), then straight into renaming it. */
|
||||||
|
createTabGroup({ ref = null, index } = {}) {
|
||||||
|
if (!this._tabLayoutEditable()) return false;
|
||||||
|
const id = this._newTabGroupId();
|
||||||
|
const name = window.CodemanI18n?.t?.('New group') || 'New group';
|
||||||
|
if (!this.editTabLayout({ type: 'createGroup', id, name, ...(Number.isInteger(index) ? { index } : {}) }, `group:${id}`)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ref) this.editTabLayout({ type: 'moveRef', ref, groupId: id, index: 0, parents: this._tabLayoutParents() });
|
||||||
|
this.startTabGroupRename(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTabGroup(groupId) {
|
||||||
|
const groups = this.tabLayout?.groups || [];
|
||||||
|
const index = groups.findIndex((group) => group.id === groupId);
|
||||||
|
if (index < 0) return false;
|
||||||
|
if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) {
|
||||||
|
// The menu that asked is gone; put the keyboard back on the group.
|
||||||
|
this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`)?.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const neighbour = groups[index + 1] || groups[index - 1];
|
||||||
|
return this.editTabLayout({ type: 'deleteGroup', groupId }, neighbour ? `group:${neighbour.id}` : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTabGroup(groupId, delta) {
|
||||||
|
const groups = this.tabLayout?.groups || [];
|
||||||
|
const from = groups.findIndex((group) => group.id === groupId);
|
||||||
|
const to = from + delta;
|
||||||
|
if (from < 0 || to < 0 || to >= groups.length) return false;
|
||||||
|
return this.editTabLayout({ type: 'reorderGroup', groupId, index: to }, `group:${groupId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a ref is stored: { groupId (null = Ungrouped), refs, index } or null. */
|
||||||
|
_tabRefLocation(ref) {
|
||||||
|
const same = (candidate) => candidate.kind === ref.kind && candidate.id === ref.id;
|
||||||
|
for (const group of this.tabLayout?.groups || []) {
|
||||||
|
const index = group.refs.findIndex(same);
|
||||||
|
if (index >= 0) return { groupId: group.id, refs: group.refs, index };
|
||||||
|
}
|
||||||
|
const index = this.tabLayout?.ungrouped?.findIndex(same) ?? -1;
|
||||||
|
return index >= 0 ? { groupId: null, refs: this.tabLayout.ungrouped, index } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTabRef(ref, groupId, anchor = null, placement = 'before') {
|
||||||
|
const parents = this._tabLayoutParents();
|
||||||
|
let destination;
|
||||||
|
try {
|
||||||
|
destination = window.CodemanTabLayout.moveDestination(this.tabLayout, ref, groupId, anchor, placement, parents);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.editTabLayout({ type: 'moveRef', ref, ...destination, parents }, `${ref.kind}:${ref.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group placement actions for a row's action menu: reorder within its
|
||||||
|
* container, move to another group, out to Ungrouped, or into a new group.
|
||||||
|
* Empty outside the vertical rail, so the header strip's menu is unchanged.
|
||||||
|
*/
|
||||||
|
_tabRefMoveActions(ref) {
|
||||||
|
if (!this._tabLayoutEditable()) return [];
|
||||||
|
const location = this._tabRefLocation(ref);
|
||||||
|
if (!location) return [];
|
||||||
|
const actions = [];
|
||||||
|
const grouped = this.tabLayout.groups.length > 0;
|
||||||
|
// Up/down follow the STORED order, which is what the rail paints unless a
|
||||||
|
// sort is on (then the sort decides and there is nothing to reorder).
|
||||||
|
if (grouped && !this.isTabRailSorted()) {
|
||||||
|
// The sessions that follow this one move with it, so "down" means past
|
||||||
|
// the first row that is not part of that block.
|
||||||
|
const moving = new Set(window.CodemanTabLayout.movingRefKeys(this.tabLayout, ref, this._tabLayoutParents()));
|
||||||
|
const previous = location.refs[location.index - 1];
|
||||||
|
const next = location.refs.slice(location.index + 1).find((candidate) => !moving.has(`${candidate.kind}:${candidate.id}`));
|
||||||
|
if (previous) actions.push({ label: 'Move up', run: () => this.moveTabRef(ref, location.groupId, previous, 'before') });
|
||||||
|
if (next) actions.push({ label: 'Move down', run: () => this.moveTabRef(ref, location.groupId, next, 'after') });
|
||||||
|
}
|
||||||
|
for (const group of this.tabLayout.groups) {
|
||||||
|
if (group.id === location.groupId) continue;
|
||||||
|
// Quoted: a group may be NAMED "New group" or "ungrouped", which unquoted
|
||||||
|
// would read (and, case-insensitively, translate) exactly like the
|
||||||
|
// fixed "Move to new group" / "Move to Ungrouped" entries next to it.
|
||||||
|
actions.push({ label: `Move to "${group.name}"`, run: () => this.moveTabRef(ref, group.id) });
|
||||||
|
}
|
||||||
|
if (location.groupId !== null) actions.push({ label: 'Move to Ungrouped', run: () => this.moveTabRef(ref, null) });
|
||||||
|
// At the server's group cap a new group can only fail, so it is not offered.
|
||||||
|
if (this._canCreateTabGroup()) actions.push({ label: 'Move to new group', run: () => this.createTabGroup({ ref }) });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the open group / web-tab menu. Every dismissal path lands here:
|
||||||
|
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
|
||||||
|
* action, and any full re-render of the rail (which would orphan its trigger).
|
||||||
|
*/
|
||||||
|
closeTabGroupMenu({ restoreFocus = false } = {}) {
|
||||||
|
const menu = this._tabGroupMenu;
|
||||||
|
if (!menu) return;
|
||||||
|
const trigger = this._tabGroupMenuTrigger;
|
||||||
|
const identity = this._tabGroupMenuKey;
|
||||||
|
this._tabGroupMenu = null;
|
||||||
|
this._tabGroupMenuTrigger = null;
|
||||||
|
this._tabGroupMenuKey = null;
|
||||||
|
document.removeEventListener('pointerdown', this._tabGroupMenuOutside, true);
|
||||||
|
document.removeEventListener('keydown', this._tabGroupMenuKeydown, true);
|
||||||
|
window.removeEventListener('resize', this._tabGroupMenuResize);
|
||||||
|
this._tabGroupMenuOutside = this._tabGroupMenuKeydown = this._tabGroupMenuResize = null;
|
||||||
|
menu.remove();
|
||||||
|
if (!restoreFocus) return;
|
||||||
|
// The trigger may have been re-rendered while the menu was open; find the
|
||||||
|
// live tree item by identity.
|
||||||
|
const rail = this.$('sessionTabs');
|
||||||
|
const item =
|
||||||
|
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
|
||||||
|
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
|
||||||
|
item?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_canCreateTabGroup() {
|
||||||
|
const max = window.CodemanTabLayout?.MAX_GROUPS;
|
||||||
|
return !max || (this.tabLayout?.groups?.length || 0) < max;
|
||||||
|
}
|
||||||
|
|
||||||
|
openTabGroupMenu(event, groupId) {
|
||||||
|
const groups = this.tabLayout?.groups || [];
|
||||||
|
const index = groups.findIndex((group) => group.id === groupId);
|
||||||
|
if (index < 0) return false;
|
||||||
|
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
|
||||||
|
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
|
||||||
|
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
|
||||||
|
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
|
||||||
|
...(index < groups.length - 1 ? [{ label: 'Move group down', run: () => this.moveTabGroup(groupId, 1) }] : []),
|
||||||
|
{ label: 'Delete group', className: 'danger', run: () => this.deleteTabGroup(groupId) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
|
||||||
|
openTabWebviewMenu(event, webviewId) {
|
||||||
|
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
|
||||||
|
if (!moves.length) {
|
||||||
|
this.showWebviewModal?.(webviewId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this._openTabLayoutMenu(event, `webview:${webviewId}`, 'Web tab actions', [
|
||||||
|
{ label: 'Web tab settings', run: () => this.showWebviewModal?.(webviewId) },
|
||||||
|
...moves,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
const trigger = event?.currentTarget || null;
|
||||||
|
// Opening the same menu again closes it (a toggle, like the row menu).
|
||||||
|
if (this._tabGroupMenu && this._tabGroupMenuKey === identity) {
|
||||||
|
this.closeTabGroupMenu();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.closeTabGroupMenu();
|
||||||
|
this.closeTabRailActionMenu?.();
|
||||||
|
if (!this._tabLayoutEditable()) return false;
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.setAttribute('aria-label', ariaLabel);
|
||||||
|
for (const action of actions) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute('role', 'menuitem');
|
||||||
|
button.textContent = action.label;
|
||||||
|
if (action.className) button.className = action.className;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.closeTabGroupMenu();
|
||||||
|
action.run();
|
||||||
|
});
|
||||||
|
menu.appendChild(button);
|
||||||
|
}
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
const anchor = (trigger?.getBoundingClientRect ? trigger : null) || this.$('sessionTabs');
|
||||||
|
const rect = anchor?.getBoundingClientRect?.() || { left: 8, bottom: 8, right: 8 };
|
||||||
|
const menuRect = menu.getBoundingClientRect();
|
||||||
|
const left = event?.clientX && event.type === 'contextmenu' ? event.clientX : rect.left;
|
||||||
|
menu.style.left = `${Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8))}px`;
|
||||||
|
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
|
||||||
|
|
||||||
|
this._tabGroupMenu = menu;
|
||||||
|
this._tabGroupMenuTrigger = trigger;
|
||||||
|
this._tabGroupMenuKey = identity;
|
||||||
|
this._tabGroupMenuOutside = (pointerEvent) => {
|
||||||
|
if (menu.contains(pointerEvent.target) || (trigger && trigger.contains?.(pointerEvent.target))) return;
|
||||||
|
this.closeTabGroupMenu();
|
||||||
|
};
|
||||||
|
// Capture on document, so Escape closes THIS menu and nothing else (the
|
||||||
|
// global Escape handler defers to it, see the keydown listener in init).
|
||||||
|
this._tabGroupMenuKeydown = (keyEvent) => {
|
||||||
|
if (keyEvent.key !== 'Escape') return;
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
keyEvent.stopImmediatePropagation();
|
||||||
|
this.closeTabGroupMenu({ restoreFocus: true });
|
||||||
|
};
|
||||||
|
this._tabGroupMenuResize = () => this.closeTabGroupMenu();
|
||||||
|
document.addEventListener('pointerdown', this._tabGroupMenuOutside, true);
|
||||||
|
document.addEventListener('keydown', this._tabGroupMenuKeydown, true);
|
||||||
|
window.addEventListener('resize', this._tabGroupMenuResize);
|
||||||
|
menu.addEventListener('keydown', (keyEvent) => {
|
||||||
|
const buttons = [...menu.querySelectorAll('button')];
|
||||||
|
const at = buttons.indexOf(document.activeElement);
|
||||||
|
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
buttons[(at + (keyEvent.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
||||||
|
} else if (keyEvent.key === 'Home' || keyEvent.key === 'End') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
buttons[keyEvent.key === 'Home' ? 0 : buttons.length - 1]?.focus();
|
||||||
|
} else if (keyEvent.key === 'Tab') {
|
||||||
|
// Tab would walk out and leave the popup on screen: dismiss to the row.
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
this.closeTabGroupMenu({ restoreFocus: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Focus leaving by any other route (a click elsewhere, a programmatic move).
|
||||||
|
// Hops between the menu's own items are not a departure.
|
||||||
|
menu.addEventListener('focusout', (focusEvent) => {
|
||||||
|
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
|
||||||
|
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
|
||||||
|
});
|
||||||
|
menu.querySelector('button')?.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inline group rename ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a group in place. Shares the session rename's ownership handle
|
||||||
|
* (`_activeRename`), so starting one cancels the other and only the CURRENT
|
||||||
|
* editor may release the render guard. Enter or blur commits, Escape cancels,
|
||||||
|
* IME composition keys belong to the IME. The commit goes through the edit
|
||||||
|
* coordinator, so it is serialized behind any write already in flight.
|
||||||
|
*/
|
||||||
|
startTabGroupRename(groupId) {
|
||||||
|
if (!this.tabLayout?.groups?.some((candidate) => candidate.id === groupId)) return false;
|
||||||
|
// Cancelling another editor re-renders the rail, so look the header up after.
|
||||||
|
this._activeRename?.cancel();
|
||||||
|
const group = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
|
||||||
|
const header = this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||||
|
const label = header?.querySelector('.tab-layout-group-name');
|
||||||
|
if (!group || !label) return false;
|
||||||
|
this._inlineRenameActive = true;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'tab-layout-group-rename-input';
|
||||||
|
input.value = group.name;
|
||||||
|
input.maxLength = 60;
|
||||||
|
input.setAttribute('aria-label', 'Group name');
|
||||||
|
label.classList.add('tab-layout-group-name--renaming');
|
||||||
|
label.replaceChildren(input);
|
||||||
|
// The header toggles collapse on click and opens its menu on right-click;
|
||||||
|
// neither may fire from inside the editor.
|
||||||
|
for (const type of ['click', 'contextmenu', 'pointerdown']) input.addEventListener(type, (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const handle = { groupId, cancel: () => settle(false) };
|
||||||
|
// `fromBlur`: focus already moved somewhere the user chose (the terminal,
|
||||||
|
// another control). Pulling it back to the header from inside the blur
|
||||||
|
// handler wins over that move, so a blur commit never asks for refocus.
|
||||||
|
const settle = (commit, { fromBlur = false } = {}) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
const name = input.value.trim();
|
||||||
|
// Only the current editor owns the guard: a newer rename keeps it.
|
||||||
|
if (this._activeRename !== handle) return;
|
||||||
|
this._activeRename = null;
|
||||||
|
this._inlineRenameActive = false;
|
||||||
|
const current = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
|
||||||
|
const focusIdentity = fromBlur ? null : `group:${groupId}`;
|
||||||
|
if (commit && current && name && name !== current.name) {
|
||||||
|
if (this.editTabLayout({ type: 'renameGroup', groupId, name }, focusIdentity)) return;
|
||||||
|
}
|
||||||
|
if (focusIdentity) {
|
||||||
|
this._tabFocusIdentity = focusIdentity;
|
||||||
|
this._tabRefocusAfterEdit = true;
|
||||||
|
}
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
};
|
||||||
|
this._activeRename = handle;
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.isComposing || e.keyCode === 229) return;
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
settle(true);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
settle(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => settle(true, { fromBlur: true }));
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pointer drag in the grouped rail ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag rows between groups and reorder groups, in the GROUPED rail only.
|
||||||
|
* Pointer Events (mouse and pen; touch keeps scrolling the rail), bound once
|
||||||
|
* on the container, which survives every re-render. The flat rail and the
|
||||||
|
* header strip keep the HTML5 drag in setupTabDragHandlers() untouched.
|
||||||
|
* Keyboard equivalents live in the row and group menus.
|
||||||
|
*/
|
||||||
|
_bindTabLayoutPointerDrag(container) {
|
||||||
|
if (!container || container._tabLayoutDragBound) return;
|
||||||
|
container._tabLayoutDragBound = true;
|
||||||
|
container.addEventListener('pointerdown', (e) => this._onTabLayoutPointerDown(e, container));
|
||||||
|
container.addEventListener('pointermove', (e) => this._onTabLayoutPointerMove(e, container));
|
||||||
|
container.addEventListener('pointerup', (e) => this._finishTabLayoutPointerDrag(e, container));
|
||||||
|
container.addEventListener('pointercancel', () => this._cancelTabLayoutPointerDrag(container));
|
||||||
|
container.addEventListener('lostpointercapture', () => this._cancelTabLayoutPointerDrag(container));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTabLayoutPointerDown(e, container) {
|
||||||
|
// A press whose release never reached us (let go outside the rail, or
|
||||||
|
// outside the window) must not survive into this one: a stale pending
|
||||||
|
// press turned into a phantom drag on the next hover, and a replaced drag
|
||||||
|
// left its Escape listener behind for good.
|
||||||
|
if (this._tabLayoutDrag) this._cancelTabLayoutPointerDrag(container);
|
||||||
|
if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return;
|
||||||
|
if (this._inlineRenameActive || !this._tabLayoutEditable()) return;
|
||||||
|
// Controls keep their own click; only the row body or the header drags.
|
||||||
|
if (e.target.closest('.tab-actions, .tab-badge, .tab-layout-group-menu, button, input, [onclick*="stopPropagation"]')) return;
|
||||||
|
const header = e.target.closest('[data-tab-group-header]');
|
||||||
|
const row = header ? null : e.target.closest('.session-tab');
|
||||||
|
let source = null;
|
||||||
|
if (header) source = { type: 'group', groupId: header.dataset.tabGroupHeader };
|
||||||
|
else if (row?.dataset.webviewId) source = { type: 'ref', ref: { kind: 'webview', id: row.dataset.webviewId } };
|
||||||
|
else if (row?.dataset.id) source = { type: 'ref', ref: { kind: 'session', id: row.dataset.id } };
|
||||||
|
if (!source) return;
|
||||||
|
const drag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null };
|
||||||
|
// Capture is only taken once the press becomes a drag, so until then the
|
||||||
|
// release can land outside the rail: hear it on window.
|
||||||
|
drag.windowUp = (upEvent) => this._finishTabLayoutPointerDrag(upEvent, container);
|
||||||
|
drag.windowCancel = () => this._cancelTabLayoutPointerDrag(container);
|
||||||
|
window.addEventListener('pointerup', drag.windowUp, true);
|
||||||
|
window.addEventListener('pointercancel', drag.windowCancel, true);
|
||||||
|
this._tabLayoutDrag = drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a pointer at (x, y) would drop onto, from the rendered rail. */
|
||||||
|
_tabLayoutDropTarget(x, y, container) {
|
||||||
|
const hit = document.elementFromPoint(x, y);
|
||||||
|
if (!hit || !container.contains(hit)) return null;
|
||||||
|
const row = hit.closest('.session-tab');
|
||||||
|
const section = hit.closest('.tab-layout-group');
|
||||||
|
const sectionGroup = section ? section.dataset.tabGroupId || null : undefined;
|
||||||
|
// A sorted rail paints its own order, so a row can only be dropped INTO a
|
||||||
|
// group, never between two rows.
|
||||||
|
if (row && section && !this.isTabRailSorted()) {
|
||||||
|
const ref = row.dataset.webviewId ? { kind: 'webview', id: row.dataset.webviewId } : { kind: 'session', id: row.dataset.id };
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return { type: 'ref', ref, groupId: sectionGroup, placement: y >= rect.top + rect.height / 2 ? 'after' : 'before', element: row };
|
||||||
|
}
|
||||||
|
if (sectionGroup === undefined) return null;
|
||||||
|
const element = section.querySelector(':scope > .tab-layout-group-header');
|
||||||
|
return sectionGroup === null ? { type: 'ungrouped', element } : { type: 'group', groupId: sectionGroup, element };
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearTabLayoutDropMarks(container) {
|
||||||
|
container.querySelectorAll('.tab-layout-drop-before, .tab-layout-drop-after, .tab-layout-drop-into').forEach((el) =>
|
||||||
|
el.classList.remove('tab-layout-drop-before', 'tab-layout-drop-after', 'tab-layout-drop-into')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTabLayoutPointerMove(e, container) {
|
||||||
|
const drag = this._tabLayoutDrag;
|
||||||
|
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||||
|
// The primary button is up, so the release went somewhere we never heard.
|
||||||
|
if ((e.buttons & 1) === 0) {
|
||||||
|
this._cancelTabLayoutPointerDrag(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drag.active) {
|
||||||
|
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 6) return;
|
||||||
|
drag.active = true;
|
||||||
|
drag.origin.classList.add('tab-layout-dragging');
|
||||||
|
container.classList.add('tab-layout-drag-active');
|
||||||
|
this.closeTabGroupMenu();
|
||||||
|
this.closeTabRailActionMenu?.();
|
||||||
|
try {
|
||||||
|
container.setPointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||||
|
this._tabLayoutDragKeydown = (keyEvent) => {
|
||||||
|
if (keyEvent.key !== 'Escape') return;
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
keyEvent.stopImmediatePropagation();
|
||||||
|
this._cancelTabLayoutPointerDrag(container);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const target = this._tabLayoutDropTarget(e.clientX, e.clientY, container);
|
||||||
|
this._clearTabLayoutDropMarks(container);
|
||||||
|
drag.target = target;
|
||||||
|
if (!target?.element) return;
|
||||||
|
const cls = target.type === 'ref' && drag.source.type === 'ref' ? `tab-layout-drop-${target.placement}` : 'tab-layout-drop-into';
|
||||||
|
target.element.classList.add(cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelTabLayoutPointerDrag(container) {
|
||||||
|
const drag = this._tabLayoutDrag;
|
||||||
|
if (!drag) return;
|
||||||
|
this._tabLayoutDrag = null;
|
||||||
|
if (drag.windowUp) window.removeEventListener('pointerup', drag.windowUp, true);
|
||||||
|
if (drag.windowCancel) window.removeEventListener('pointercancel', drag.windowCancel, true);
|
||||||
|
drag.origin?.classList.remove('tab-layout-dragging');
|
||||||
|
container?.classList.remove('tab-layout-drag-active');
|
||||||
|
if (container) this._clearTabLayoutDropMarks(container);
|
||||||
|
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||||
|
this._tabLayoutDragKeydown = null;
|
||||||
|
if (drag.active) {
|
||||||
|
// The click that ends a drag must not also select the row or toggle the header.
|
||||||
|
const swallow = (clickEvent) => {
|
||||||
|
clickEvent.stopPropagation();
|
||||||
|
clickEvent.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('click', swallow, { capture: true, once: true });
|
||||||
|
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_finishTabLayoutPointerDrag(e, container) {
|
||||||
|
const drag = this._tabLayoutDrag;
|
||||||
|
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||||
|
const target = drag.active ? this._tabLayoutDropTarget(e.clientX, e.clientY, container) || drag.target : null;
|
||||||
|
this._cancelTabLayoutPointerDrag(container);
|
||||||
|
if (!target) return;
|
||||||
|
const operation = window.CodemanTabLayout.dropOperation(this.tabLayout, drag.source, target, this._tabLayoutParents());
|
||||||
|
if (!operation) return;
|
||||||
|
const identity = drag.source.type === 'group' ? `group:${drag.source.groupId}` : `${drag.source.ref.kind}:${drag.source.ref.id}`;
|
||||||
|
this.editTabLayout(operation, identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Unsaved edits across a reload ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page is going away with edits not yet confirmed: send them with a
|
||||||
|
* keepalive PUT (it outlives the page) AND keep a copy in sessionStorage. If
|
||||||
|
* the keepalive lands, the copy replays to no change after reload; if it lost
|
||||||
|
* a race, the copy is rebased onto the fresh layout and saved properly.
|
||||||
|
*/
|
||||||
|
_persistPendingTabLayoutEdits() {
|
||||||
|
const editor = this._tabLayoutEditor;
|
||||||
|
const operations = editor?.pendingOperations?.() || [];
|
||||||
|
if (!operations.length) return false;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'codeman:tab-layout-pending',
|
||||||
|
JSON.stringify({ owner: this._tabLayoutOwnerKey(), baseVersion: editor.baseVersion(), savedAt: Date.now(), operations })
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const layout = editor.getLayout();
|
||||||
|
void fetch('/api/tab-layout', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ baseVersion: editor.baseVersion(), layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } }),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whose layout this page edits, as the server keys it (`@single` without multi-user). */
|
||||||
|
_tabLayoutOwnerKey() {
|
||||||
|
const me = window.__codemanUser;
|
||||||
|
if (!me) return null;
|
||||||
|
return me.multiUser ? me.username : '@single';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the previous page's unsaved edits, but only that page's: the copy is
|
||||||
|
* ignored when it belongs to another owner (a different login in this tab),
|
||||||
|
* is older than a reload could explain, or names a layout newer than the one
|
||||||
|
* just read (a different server behind the same origin).
|
||||||
|
*/
|
||||||
|
_restorePendingTabLayoutEdits() {
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem('codeman:tab-layout-pending');
|
||||||
|
if (!raw) return false;
|
||||||
|
saved = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const owner = this._tabLayoutOwnerKey();
|
||||||
|
if (owner === null) {
|
||||||
|
// Who we are is not known yet (/api/me still loading): decide once it is.
|
||||||
|
if (!this._tabLayoutRestoreWaiting) {
|
||||||
|
this._tabLayoutRestoreWaiting = true;
|
||||||
|
document.addEventListener(
|
||||||
|
'codeman:me',
|
||||||
|
() => {
|
||||||
|
this._tabLayoutRestoreWaiting = false;
|
||||||
|
this._restorePendingTabLayoutEdits();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem('codeman:tab-layout-pending');
|
||||||
|
} catch {}
|
||||||
|
const operations = saved?.operations;
|
||||||
|
if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false;
|
||||||
|
if (saved.owner !== owner) return false;
|
||||||
|
const age = Date.now() - saved.savedAt;
|
||||||
|
if (!Number.isFinite(age) || age < 0 || age > TAB_LAYOUT_PENDING_MAX_AGE_MS) return false;
|
||||||
|
if (!Number.isSafeInteger(saved.baseVersion) || saved.baseVersion > this.tabLayout.version) return false;
|
||||||
|
return this._ensureTabLayoutEditor().restore(operations);
|
||||||
|
}
|
||||||
|
|
||||||
// Set up drag-and-drop handlers on tab elements
|
// Set up drag-and-drop handlers on tab elements
|
||||||
setupTabDragHandlers() {
|
setupTabDragHandlers() {
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
@@ -6457,9 +7208,9 @@ class CodemanApp {
|
|||||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||||
// order this list is no longer showing.
|
// order this list is no longer showing.
|
||||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
// The grouped rail opts out too: a flat-order drag cannot express "move
|
||||||
// "move into that group", and the server would re-rank it within its old
|
// into that group", and the server would re-rank it within its old group
|
||||||
// group anyway. Grouped editing comes with its own drag model.
|
// anyway. It has its own pointer drag (_bindTabLayoutPointerDrag).
|
||||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||||
return;
|
return;
|
||||||
@@ -6557,7 +7308,9 @@ class CodemanApp {
|
|||||||
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
||||||
* session:orderChanged comes back, and this client would keep a swapped
|
* session:orderChanged comes back, and this client would keep a swapped
|
||||||
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
|
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
|
||||||
* drag is off in the grouped rail. Any other layout: always allowed.
|
* the HTML5 flat-order drag is off in the grouped rail (its own pointer drag
|
||||||
|
* and the row menu's moves go through moveRef instead, which can cross a
|
||||||
|
* group). Any other layout: always allowed.
|
||||||
*/
|
*/
|
||||||
_canSwapActiveTabWith(neighbourId) {
|
_canSwapActiveTabWith(neighbourId) {
|
||||||
const projection = this._projectTabGroups();
|
const projection = this._projectTabGroups();
|
||||||
@@ -8388,6 +9141,9 @@ class CodemanApp {
|
|||||||
const fmtBindings = (s) => {
|
const fmtBindings = (s) => {
|
||||||
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||||
if (!s.bindings) return '';
|
if (!s.bindings) return '';
|
||||||
|
// An action with no key (Close Session by default) is still listed, so the
|
||||||
|
// overlay says so instead of showing an empty key column.
|
||||||
|
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
|
||||||
return s.bindings.map((b) => {
|
return s.bindings.map((b) => {
|
||||||
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||||
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||||
|
|||||||
@@ -1614,7 +1614,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
if (id === excludeId) continue;
|
if (id === excludeId) continue;
|
||||||
// A detached (popped-out) session's own window already yields its PTY
|
// A detached (popped-out) session's own window already yields its PTY
|
||||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
// Pane B's TerminalTile._sendResize() has no such check, so letting
|
||||||
// one into the picker put its detached window and Pane B in a fight over
|
// one into the picker put its detached window and Pane B in a fight over
|
||||||
// the same PTY's dimensions.
|
// the same PTY's dimensions.
|
||||||
if (detachedIds?.has?.(id)) continue;
|
if (detachedIds?.has?.(id)) continue;
|
||||||
@@ -1622,7 +1622,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
if (!session) continue;
|
if (!session) continue;
|
||||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
// reading its tmux pane. TerminalTile never does selectSession()'s
|
||||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||||
// no terminal events, and Session.write() silently drops every keystroke
|
// no terminal events, and Session.write() silently drops every keystroke
|
||||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
|
||||||
|
*
|
||||||
|
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
|
||||||
|
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
|
||||||
|
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
|
||||||
|
* Files window listing exactly which files are uncommitted and which commits are not pushed.
|
||||||
|
*
|
||||||
|
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
|
||||||
|
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
|
||||||
|
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
|
||||||
|
* session changes or the window regains focus). The route is read-only and offline: it never fetches
|
||||||
|
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
|
||||||
|
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
|
||||||
|
*
|
||||||
|
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
|
||||||
|
* only ever written with `textContent`, never `innerHTML`.
|
||||||
|
*
|
||||||
|
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
|
||||||
|
* tablet only by construction.
|
||||||
|
*
|
||||||
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
|
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||||
|
* @dependency panels-ui.js (openFilePreview)
|
||||||
|
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often the active session's repository is re-read while the indicator is on. */
|
||||||
|
const GIT_STATUS_POLL_MS = 15000;
|
||||||
|
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
|
||||||
|
const GIT_STATUS_TICK_MS = 2000;
|
||||||
|
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
|
||||||
|
const GIT_STATUS_MIN_REFRESH_MS = 3000;
|
||||||
|
|
||||||
|
const GIT_STATUS_BADGE_TITLE = {
|
||||||
|
M: 'Modified',
|
||||||
|
A: 'Added',
|
||||||
|
D: 'Deleted',
|
||||||
|
R: 'Renamed',
|
||||||
|
C: 'Copied',
|
||||||
|
T: 'Type changed',
|
||||||
|
U: 'Unmerged',
|
||||||
|
'?': 'Untracked',
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(CodemanApp.prototype, {
|
||||||
|
/** Per-device setting, default OFF. */
|
||||||
|
isGitStatusEnabled() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
|
||||||
|
* runs on boot and after every settings save, so a live toggle needs no reload.
|
||||||
|
*/
|
||||||
|
applyGitStatusVisibility() {
|
||||||
|
const on = this.isGitStatusEnabled();
|
||||||
|
if (on && !this._gitStatusTimer) {
|
||||||
|
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
|
||||||
|
this._gitStatusOnVisible = () => {
|
||||||
|
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||||
|
window.addEventListener('focus', this._gitStatusOnVisible);
|
||||||
|
this.refreshGitStatus();
|
||||||
|
} else if (!on && this._gitStatusTimer) {
|
||||||
|
clearInterval(this._gitStatusTimer);
|
||||||
|
this._gitStatusTimer = null;
|
||||||
|
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
|
||||||
|
window.removeEventListener('focus', this._gitStatusOnVisible);
|
||||||
|
this._gitStatusOnVisible = null;
|
||||||
|
}
|
||||||
|
if (!on) {
|
||||||
|
this._gitStatus = null;
|
||||||
|
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
|
||||||
|
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
|
||||||
|
// turning the setting back on would skip every refresh for this session until a reload.
|
||||||
|
this._gitStatusInFlight = false;
|
||||||
|
this.closeGitStatusPanel();
|
||||||
|
}
|
||||||
|
this._renderGitStatusButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
_gitStatusTick() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const sid = this.activeSessionId || null;
|
||||||
|
if (sid !== this._gitStatusSessionId) {
|
||||||
|
// The active session changed (or the first one opened): show nothing stale, read now.
|
||||||
|
this._gitStatus = null;
|
||||||
|
this._renderGitStatusButton();
|
||||||
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
|
||||||
|
this.refreshGitStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
|
||||||
|
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
|
||||||
|
if (!this.isGitStatusEnabled()) return;
|
||||||
|
const sid = this.activeSessionId || null;
|
||||||
|
this._gitStatusSessionId = sid;
|
||||||
|
if (!sid) {
|
||||||
|
this._gitStatus = null;
|
||||||
|
this._renderGitStatusButton();
|
||||||
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
|
||||||
|
// A read for THIS session is already running: let it finish. One for another session is not worth
|
||||||
|
// waiting for (its answer is dropped below), so a session switch is never left blank.
|
||||||
|
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
|
||||||
|
this._gitStatusInFlight = true;
|
||||||
|
this._gitStatusInFlightSid = sid;
|
||||||
|
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||||
|
this._gitStatusFetchedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
||||||
|
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||||
|
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||||
|
} catch {
|
||||||
|
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
|
||||||
|
} finally {
|
||||||
|
// Only the newest request owns the flag: an older one finishing late must not clear it.
|
||||||
|
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
|
||||||
|
}
|
||||||
|
if (epoch !== this._gitStatusEpoch) return;
|
||||||
|
this._renderGitStatusButton();
|
||||||
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||||
|
isGitStatusTree() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
|
||||||
|
_currentGitStatus() {
|
||||||
|
const s = this._gitStatus;
|
||||||
|
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
|
||||||
|
_gitStatusSummary(overview) {
|
||||||
|
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
|
||||||
|
let uncommitted = 0;
|
||||||
|
let unpushed = 0;
|
||||||
|
let conflicted = 0;
|
||||||
|
for (const r of overview.repos) {
|
||||||
|
uncommitted += r.status.counts.uncommitted;
|
||||||
|
unpushed += r.status.unpushedCount;
|
||||||
|
conflicted += r.status.counts.conflicted;
|
||||||
|
}
|
||||||
|
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
||||||
|
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** One sentence for the tooltip and the screen-reader label. */
|
||||||
|
_gitStatusSentence(overview) {
|
||||||
|
const sum = this._gitStatusSummary(overview);
|
||||||
|
if (!sum) return '';
|
||||||
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
||||||
|
const bits = [];
|
||||||
|
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||||
|
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||||
|
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||||
|
if (!bits.length) bits.push('everything is committed and pushed');
|
||||||
|
let where;
|
||||||
|
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||||
|
else {
|
||||||
|
const d = overview.repos[0].status;
|
||||||
|
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||||
|
}
|
||||||
|
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderGitStatusButton() {
|
||||||
|
const btn = this.$('gitStatusBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
|
||||||
|
const sum = this._gitStatusSummary(data);
|
||||||
|
btn.hidden = !sum;
|
||||||
|
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
|
||||||
|
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
|
||||||
|
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
|
||||||
|
const label = btn.querySelector('.git-status-label');
|
||||||
|
if (!sum) {
|
||||||
|
if (label) label.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||||
|
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||||
|
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||||
|
if (!parts.length) parts.push('✓');
|
||||||
|
if (label) label.textContent = parts.join(' ');
|
||||||
|
const sentence = this._gitStatusSentence(data);
|
||||||
|
btn.title = sentence;
|
||||||
|
btn.setAttribute('aria-label', sentence);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_isGitStatusPanelOpen() {
|
||||||
|
return !!this.$('gitStatusPanel')?.classList.contains('visible');
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleGitStatusPanel() {
|
||||||
|
if (this._isGitStatusPanelOpen()) {
|
||||||
|
this.closeGitStatusPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const panel = this.$('gitStatusPanel');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.classList.add('visible');
|
||||||
|
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
|
||||||
|
this._ensureGitStatusPanelDrag();
|
||||||
|
this._renderGitStatusPanel();
|
||||||
|
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
|
||||||
|
},
|
||||||
|
|
||||||
|
closeGitStatusPanel() {
|
||||||
|
this._gitDiffView = null;
|
||||||
|
const panel = this.$('gitStatusPanel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('visible');
|
||||||
|
// Reset a dragged position so it reopens at the default spot.
|
||||||
|
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||||
|
}
|
||||||
|
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshGitStatusNow() {
|
||||||
|
this._gitStatusFetchedAt = 0;
|
||||||
|
this._gitStatusInFlight = false;
|
||||||
|
return this.refreshGitStatus({ fresh: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
|
||||||
|
_ensureGitStatusPanelDrag() {
|
||||||
|
const panel = this.$('gitStatusPanel');
|
||||||
|
const handle = panel?.querySelector('.git-status-header');
|
||||||
|
if (!panel || !handle || handle._dragReady) return;
|
||||||
|
handle._dragReady = true;
|
||||||
|
let drag = null;
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||||
|
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||||
|
panel.style.left = `${rect.left}px`;
|
||||||
|
panel.style.top = `${rect.top}px`;
|
||||||
|
panel.style.right = 'auto';
|
||||||
|
panel.style.bottom = 'auto';
|
||||||
|
handle.setPointerCapture?.(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
handle.addEventListener('pointermove', (e) => {
|
||||||
|
if (!drag) return;
|
||||||
|
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||||
|
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||||
|
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||||
|
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||||
|
});
|
||||||
|
const end = (e) => {
|
||||||
|
drag = null;
|
||||||
|
handle.releasePointerCapture?.(e.pointerId);
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointerup', end);
|
||||||
|
handle.addEventListener('pointercancel', end);
|
||||||
|
},
|
||||||
|
|
||||||
|
_gitEl(tag, className, text) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (className) el.className = className;
|
||||||
|
if (text !== undefined) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderGitStatusPanel() {
|
||||||
|
const body = this.$('gitStatusBody');
|
||||||
|
const head = this.$('gitStatusBranch');
|
||||||
|
const foot = this.$('gitStatusFooter');
|
||||||
|
if (!body) return;
|
||||||
|
const overview = this._currentGitStatus();
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
|
||||||
|
const focusKey = body.contains(document.activeElement)
|
||||||
|
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
|
||||||
|
: null;
|
||||||
|
const view = this._gitDiffView;
|
||||||
|
if (view && view.sessionId === this.activeSessionId) {
|
||||||
|
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
|
||||||
|
this._renderGitDiffView(body, view);
|
||||||
|
if (head) head.textContent = '';
|
||||||
|
if (foot) foot.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._gitDiffView = null;
|
||||||
|
body.replaceChildren();
|
||||||
|
const clearChrome = () => {
|
||||||
|
if (head) head.textContent = '';
|
||||||
|
if (foot) foot.textContent = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.activeSessionId) {
|
||||||
|
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
|
||||||
|
clearChrome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overview) {
|
||||||
|
body.append(
|
||||||
|
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overview.state !== 'ok') {
|
||||||
|
const why =
|
||||||
|
overview.state === 'not-a-repo'
|
||||||
|
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||||
|
: overview.state === 'unsupported'
|
||||||
|
? overview.reason === 'docker'
|
||||||
|
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||||
|
: 'Git status is not available for remote (SSH) sessions.'
|
||||||
|
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||||
|
body.append(el('div', 'git-status-empty', why));
|
||||||
|
clearChrome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = overview.repos;
|
||||||
|
if (repos.length === 1) {
|
||||||
|
// One repository: the panel is that repository, as it always was.
|
||||||
|
const d = repos[0].status;
|
||||||
|
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||||
|
this._renderGitRepoInto(body, d);
|
||||||
|
} else {
|
||||||
|
if (head) head.textContent = `${repos.length} repositories`;
|
||||||
|
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||||
|
if (overview.reposTruncated) {
|
||||||
|
body.append(
|
||||||
|
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foot) {
|
||||||
|
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
|
||||||
|
}
|
||||||
|
if (focusKey) {
|
||||||
|
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
|
||||||
|
again?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One repository of several: a collapsible section, collapsed by default (the summary line already
|
||||||
|
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
|
||||||
|
*/
|
||||||
|
_gitRepoSection(r) {
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
const d = r.status;
|
||||||
|
const section = el('details', 'git-status-repo');
|
||||||
|
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||||
|
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
|
const repoKey = `repo|${d.repoRoot || r.path}`;
|
||||||
|
section.open = openRepos.has(repoKey);
|
||||||
|
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
|
||||||
|
const summary = el('summary', 'git-status-repo-summary');
|
||||||
|
summary.append(el('span', 'git-status-repo-name', r.name));
|
||||||
|
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
|
||||||
|
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
|
||||||
|
const bits = [];
|
||||||
|
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
|
||||||
|
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
|
||||||
|
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
|
||||||
|
const state = el(
|
||||||
|
'span',
|
||||||
|
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
|
||||||
|
bits.join(' ') || '✓'
|
||||||
|
);
|
||||||
|
summary.append(state);
|
||||||
|
section.append(summary);
|
||||||
|
const inner = el('div', 'git-status-repo-body');
|
||||||
|
this._renderGitRepoInto(inner, d);
|
||||||
|
section.append(inner);
|
||||||
|
return section;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
|
||||||
|
_renderGitRepoInto(body, data) {
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
|
||||||
|
// Branch / upstream line.
|
||||||
|
const line = el('div', 'git-status-branchline');
|
||||||
|
if (data.upstream && data.upstreamGone) {
|
||||||
|
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||||
|
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
|
||||||
|
gone.title =
|
||||||
|
'The upstream branch does not exist on the remote (never pushed, or deleted and pruned), so the commits below are on no remote.';
|
||||||
|
line.append(gone);
|
||||||
|
} else if (data.upstream) {
|
||||||
|
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
|
||||||
|
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
|
||||||
|
if (data.behind) {
|
||||||
|
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
|
||||||
|
behind.title = 'As of the last git fetch: Codeman never fetches.';
|
||||||
|
line.append(behind);
|
||||||
|
}
|
||||||
|
} else if (data.hasRemote) {
|
||||||
|
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
|
||||||
|
} else {
|
||||||
|
line.append(el('span', 'git-status-chip', 'No remote configured'));
|
||||||
|
}
|
||||||
|
if (data.counts.stashes) {
|
||||||
|
line.append(
|
||||||
|
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body.append(line);
|
||||||
|
|
||||||
|
// Uncommitted changes.
|
||||||
|
const filesSection = el('section', 'git-status-section');
|
||||||
|
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
|
||||||
|
if (!data.files.length) {
|
||||||
|
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
|
||||||
|
} else {
|
||||||
|
const groups = [
|
||||||
|
['conflicted', 'Merge conflicts'],
|
||||||
|
['staged', 'Staged'],
|
||||||
|
['unstaged', 'Not staged'],
|
||||||
|
['untracked', 'Untracked'],
|
||||||
|
];
|
||||||
|
for (const [kind, label] of groups) {
|
||||||
|
const rows = data.files.filter((f) => f.kind === kind);
|
||||||
|
if (!rows.length) continue;
|
||||||
|
const group = el('div', `git-status-group git-status-group--${kind}`);
|
||||||
|
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
|
||||||
|
if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind));
|
||||||
|
else for (const f of rows) group.append(this._gitFileRow(f, data));
|
||||||
|
filesSection.append(group);
|
||||||
|
}
|
||||||
|
if (data.filesTruncated) {
|
||||||
|
filesSection.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-more',
|
||||||
|
`Showing the first ${data.files.length} entries; the counts above include every file.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.append(filesSection);
|
||||||
|
|
||||||
|
// Commits not pushed.
|
||||||
|
const pushSection = el('section', 'git-status-section');
|
||||||
|
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
|
||||||
|
if (!data.unpushedCount) {
|
||||||
|
pushSection.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-ok',
|
||||||
|
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!data.upstream || data.upstreamGone) {
|
||||||
|
pushSection.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-note',
|
||||||
|
data.upstreamGone
|
||||||
|
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
|
||||||
|
: 'This branch has no upstream, so these commits are on no remote yet.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
|
||||||
|
if (data.unpushedCount > data.unpushed.length) {
|
||||||
|
pushSection.append(
|
||||||
|
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.append(pushSection);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and
|
||||||
|
* nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five.
|
||||||
|
* Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder).
|
||||||
|
*/
|
||||||
|
_gitFileTree(rows, data, kind) {
|
||||||
|
const root = { dirs: new Map(), files: [] };
|
||||||
|
for (const f of rows) {
|
||||||
|
const trailing = f.path.endsWith('/');
|
||||||
|
const parts = f.path.replace(/\/$/, '').split('/');
|
||||||
|
const leaf = parts.pop() + (trailing ? '/' : '');
|
||||||
|
let node = root;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
|
||||||
|
node = node.dirs.get(part);
|
||||||
|
}
|
||||||
|
node.files.push({ f, leaf });
|
||||||
|
}
|
||||||
|
const open = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
|
const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0);
|
||||||
|
const build = (node, prefix) => {
|
||||||
|
const out = [];
|
||||||
|
for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
let name = name0;
|
||||||
|
let child = child0;
|
||||||
|
while (child.files.length === 0 && child.dirs.size === 1) {
|
||||||
|
const [n, c] = [...child.dirs][0];
|
||||||
|
name += `/${n}`;
|
||||||
|
child = c;
|
||||||
|
}
|
||||||
|
const key = `${data.repoRoot}|${kind}|${prefix}${name}`;
|
||||||
|
const dir = this._gitEl('details', 'git-tree-dir');
|
||||||
|
dir.open = open.has(key);
|
||||||
|
dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key)));
|
||||||
|
const summary = this._gitEl('summary', 'git-tree-summary');
|
||||||
|
summary.append(this._gitEl('span', 'git-tree-name', `${name}/`));
|
||||||
|
summary.append(this._gitEl('span', 'git-tree-count', String(count(child))));
|
||||||
|
dir.append(summary);
|
||||||
|
const inner = this._gitEl('div', 'git-tree-children');
|
||||||
|
inner.append(...build(child, `${prefix}${name}/`));
|
||||||
|
dir.append(inner);
|
||||||
|
out.push(dir);
|
||||||
|
}
|
||||||
|
for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) {
|
||||||
|
out.push(this._gitFileRow(f, data, leaf));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return build(root, '');
|
||||||
|
},
|
||||||
|
|
||||||
|
_gitFileRow(f, data, displayName) {
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
const row = el('div', 'git-status-file');
|
||||||
|
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
|
||||||
|
const letter =
|
||||||
|
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
|
||||||
|
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
|
||||||
|
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
|
||||||
|
row.dataset.gitKey = `${f.kind}|${f.path}`;
|
||||||
|
row.append(badge);
|
||||||
|
const name = el('span', 'git-status-path', displayName ?? f.path);
|
||||||
|
if (displayName) name.title = f.path;
|
||||||
|
row.append(name);
|
||||||
|
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
|
||||||
|
|
||||||
|
// An untracked folder has no single diff; every other row opens its changes.
|
||||||
|
if (!f.path.endsWith('/') && data.repoRoot) {
|
||||||
|
row.classList.add('git-status-file--clickable');
|
||||||
|
row.tabIndex = 0;
|
||||||
|
row.setAttribute('role', 'button');
|
||||||
|
row.title = 'Show what changed';
|
||||||
|
const open = () => this.openGitDiff(data.repoRoot, f, letter);
|
||||||
|
row.addEventListener('click', open);
|
||||||
|
row.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Diff view ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Show `file`'s changes in the panel (a Back button returns to the list). */
|
||||||
|
async openGitDiff(repoRoot, file, letter) {
|
||||||
|
const sessionId = this.activeSessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||||
|
this._gitDiffView = view;
|
||||||
|
this._renderGitStatusPanel();
|
||||||
|
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
||||||
|
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||||
|
// Back, another file or another session while this was in flight: drop the answer.
|
||||||
|
if (this._gitDiffView !== view) return;
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = res ? await res.json() : null;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
if (this._gitDiffView !== view) return;
|
||||||
|
if (res && res.ok && body?.success) {
|
||||||
|
view.state = 'ok';
|
||||||
|
view.result = body.data;
|
||||||
|
} else {
|
||||||
|
view.state = 'error';
|
||||||
|
view.error = body?.error || 'Could not read the diff.';
|
||||||
|
}
|
||||||
|
this._renderGitStatusPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeGitDiff() {
|
||||||
|
this._gitDiffView = null;
|
||||||
|
this._renderGitStatusPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderGitDiffView(body, view) {
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
body.replaceChildren();
|
||||||
|
const bar = el('div', 'git-diff-bar');
|
||||||
|
const back = el('button', 'btn-toolbar btn-sm', '← Back');
|
||||||
|
back.type = 'button';
|
||||||
|
back.addEventListener('click', () => this.closeGitDiff());
|
||||||
|
bar.append(back);
|
||||||
|
bar.append(el('span', 'git-diff-path', view.file.path));
|
||||||
|
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
|
||||||
|
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
|
||||||
|
if (view.letter !== 'D') {
|
||||||
|
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
|
||||||
|
open.type = 'button';
|
||||||
|
open.addEventListener('click', () =>
|
||||||
|
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
|
||||||
|
);
|
||||||
|
bar.append(open);
|
||||||
|
}
|
||||||
|
body.append(bar);
|
||||||
|
|
||||||
|
if (view.state === 'loading') {
|
||||||
|
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (view.state === 'error') {
|
||||||
|
body.append(el('div', 'git-status-empty', view.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { diff, truncated, binary } = view.result;
|
||||||
|
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
|
||||||
|
if (!diff.trim()) {
|
||||||
|
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pre = el('pre', 'git-diff');
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
let cls = 'git-diff-line';
|
||||||
|
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||||
|
else if (
|
||||||
|
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
|
||||||
|
)
|
||||||
|
cls += ' git-diff-line--meta';
|
||||||
|
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||||
|
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||||
|
frag.append(el('span', cls, line + '\n'));
|
||||||
|
}
|
||||||
|
pre.append(frag);
|
||||||
|
body.append(pre);
|
||||||
|
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
|
||||||
|
},
|
||||||
|
|
||||||
|
_gitCommitRow(c) {
|
||||||
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
|
const row = el('div', 'git-status-commit');
|
||||||
|
row.append(el('span', 'git-status-hash', c.hash));
|
||||||
|
row.append(el('span', 'git-status-subject', c.subject));
|
||||||
|
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
|
||||||
|
row.append(el('span', 'git-status-commit-meta', meta));
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -68,6 +68,24 @@
|
|||||||
'Session Manager': '会话管理器',
|
'Session Manager': '会话管理器',
|
||||||
'Session actions': '会话操作',
|
'Session actions': '会话操作',
|
||||||
Ungrouped: '未分组',
|
Ungrouped: '未分组',
|
||||||
|
'Group actions': '分组操作',
|
||||||
|
'Group name': '分组名称',
|
||||||
|
'Web tab actions': '网页标签操作',
|
||||||
|
'Web tab settings': '网页标签设置',
|
||||||
|
'New group': '新建分组',
|
||||||
|
'Rename group': '重命名分组',
|
||||||
|
'Move group up': '上移分组',
|
||||||
|
'Move group down': '下移分组',
|
||||||
|
'Delete group': '删除分组',
|
||||||
|
'Move up': '上移',
|
||||||
|
'Move down': '下移',
|
||||||
|
'Move to Ungrouped': '移到未分组',
|
||||||
|
'Move to new group': '移到新分组',
|
||||||
|
'Could not save tab groups.': '无法保存标签分组。',
|
||||||
|
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||||
|
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||||
|
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
|
||||||
|
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
|
||||||
'Open session manager': '打开会话管理器',
|
'Open session manager': '打开会话管理器',
|
||||||
Attachments: '附件',
|
Attachments: '附件',
|
||||||
'Open attachment history': '打开附件历史',
|
'Open attachment history': '打开附件历史',
|
||||||
@@ -770,6 +788,22 @@
|
|||||||
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
|
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
|
||||||
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
|
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
|
||||||
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
|
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
|
||||||
|
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
|
||||||
|
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
|
||||||
|
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
|
||||||
|
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
|
||||||
|
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
|
||||||
|
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
|
||||||
|
'Create in a custom folder': '在自定义文件夹中创建',
|
||||||
|
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
|
||||||
|
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
|
||||||
|
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
|
||||||
|
'Parent Folder': '父文件夹',
|
||||||
|
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
|
||||||
|
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
|
||||||
|
'Not available for a Docker case': 'Docker 案例不可用',
|
||||||
|
'Not available with a custom folder': '使用自定义文件夹时不可用',
|
||||||
|
'Browse…': '浏览…',
|
||||||
'Docker exports': 'Docker 导出',
|
'Docker exports': 'Docker 导出',
|
||||||
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
|
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
|
||||||
'Runs inside an isolated container. Multiple sessions can share the same container.':
|
'Runs inside an isolated container. Multiple sessions can share the same container.':
|
||||||
@@ -934,6 +968,13 @@
|
|||||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||||
|
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||||
|
// Group names are user text: they pass through untranslated.
|
||||||
|
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||||
|
[
|
||||||
|
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||||
|
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [pattern, replacement] of patterns) {
|
for (const [pattern, replacement] of patterns) {
|
||||||
const match = source.match(pattern);
|
const match = source.match(pattern);
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||||
_handleImagePaste() {
|
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||||
|
// default to the primary pane. A second terminal (the split pane) passes its
|
||||||
|
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||||
|
_handleImagePaste(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const sessionId = target.sessionId || this.activeSessionId;
|
||||||
|
|
||||||
// Create a hidden contenteditable div to receive the paste
|
// Create a hidden contenteditable div to receive the paste
|
||||||
const trap = document.createElement('div');
|
const trap = document.createElement('div');
|
||||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||||
// Refocus the terminal
|
// Refocus the terminal
|
||||||
if (self.terminal) self.terminal.focus();
|
if (terminal) terminal.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
self._uploadAndInsertImages(imageFiles);
|
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||||
} else {
|
} else {
|
||||||
// No image -- route text through xterm's paste() so bracketed-paste
|
// No image -- route text through xterm's paste() so bracketed-paste
|
||||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||||
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// indistinguishable from typed input, weakening the CLI's
|
// indistinguishable from typed input, weakening the CLI's
|
||||||
// prompt-injection defenses.
|
// prompt-injection defenses.
|
||||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
if (text && self.terminal) self.terminal.paste(text);
|
if (text && terminal) terminal.paste(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/** Upload a batch and normally insert its paths into the active terminal.
|
/** Upload a batch and normally insert its paths into the active terminal.
|
||||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||||
* into its textarea instead. Returns successful paths in selection order. */
|
* into its textarea instead. `options.sessionId` names the session to upload
|
||||||
|
* to (default: the active one). Returns successful paths in selection order. */
|
||||||
async _uploadAndInsertImages(fileList, options = {}) {
|
async _uploadAndInsertImages(fileList, options = {}) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = options.sessionId || this.activeSessionId;
|
||||||
if (!sessionId) return [];
|
if (!sessionId) return [];
|
||||||
|
|
||||||
let files = Array.from(fileList || []);
|
let files = Array.from(fileList || []);
|
||||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paths = results.filter(Boolean);
|
const paths = results.filter(Boolean);
|
||||||
if (paths.length > 0 && options.insert !== false) {
|
if (paths.length > 0 && options.insert !== false) {
|
||||||
// Insert all paths in one shot, space-separated, in selection order.
|
// Insert all paths in one shot, space-separated, in selection order, into
|
||||||
await this.sendInput(paths.join(' '));
|
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
||||||
|
// activeSessionId, and after the awaits above that is whatever tab the
|
||||||
|
// user switched to mid-upload, so the paths landed in the wrong session.
|
||||||
|
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
||||||
|
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
|
|||||||
@@ -567,6 +567,19 @@
|
|||||||
<div class="file-browser-status" id="fileBrowserStatus"></div>
|
<div class="file-browser-status" id="fileBrowserStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
|
||||||
|
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
|
||||||
|
<div class="git-status-header">
|
||||||
|
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
|
||||||
|
<div class="git-status-actions">
|
||||||
|
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button>
|
||||||
|
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
|
||||||
|
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- File Preview Overlay -->
|
<!-- File Preview Overlay -->
|
||||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||||
<div class="file-preview-window">
|
<div class="file-preview-window">
|
||||||
@@ -771,6 +784,13 @@
|
|||||||
<!-- Orchestrator button hidden until feature is ready -->
|
<!-- Orchestrator button hidden until feature is ready -->
|
||||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||||
|
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
|
||||||
|
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
|
||||||
|
setting is on AND the session is a local git repository. -->
|
||||||
|
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
|
||||||
|
<span class="git-status-label" data-i18n-skip></span>
|
||||||
|
</button>
|
||||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -787,7 +807,6 @@
|
|||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
<h4>Session</h4>
|
<h4>Session</h4>
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
|
||||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||||
@@ -1935,6 +1954,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-head"><h4>Bottom bar</h4></div>
|
||||||
|
<div class="set-group-body">
|
||||||
|
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" data-search="git status folders tree flat list collapsed expand files">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status: group files by folder <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-head"><h4>Subagent windows</h4></div>
|
<div class="set-group-head"><h4>Subagent windows</h4></div>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
@@ -2829,6 +2868,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
|
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
|
||||||
|
|
||||||
|
<div class="set-group" id="doctorGroup">
|
||||||
|
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
|
||||||
|
<div class="set-group-body">
|
||||||
|
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Check this machine</span>
|
||||||
|
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
|
||||||
|
</div>
|
||||||
|
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
|
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
@@ -2969,18 +3022,30 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
|
||||||
<h2>Create New</h2>
|
<h2>Create New</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
|
<p class="set-section-blurb" id="newCaseBlurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Case Name</label>
|
<label>Case Name</label>
|
||||||
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
|
||||||
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
<span class="form-hint" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Description (optional)</label>
|
<label>Description (optional)</label>
|
||||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row" id="newCaseCustomPathToggleRow">
|
||||||
|
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
|
||||||
|
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
|
||||||
|
<label>Parent Folder</label>
|
||||||
|
<div class="path-input-group">
|
||||||
|
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
|
||||||
|
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
|
||||||
|
</div>
|
||||||
<div class="form-row docker-quick-row">
|
<div class="form-row docker-quick-row">
|
||||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
|
||||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||||
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
|
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -3853,6 +3918,7 @@
|
|||||||
<script defer src="app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
<script defer src="tab-rail-resize.js"></script>
|
<script defer src="tab-rail-resize.js"></script>
|
||||||
<script defer src="terminal-ui.js"></script>
|
<script defer src="terminal-ui.js"></script>
|
||||||
|
<script defer src="terminal-tile.js"></script>
|
||||||
<script defer src="terminal-split.js"></script>
|
<script defer src="terminal-split.js"></script>
|
||||||
<script defer src="respawn-ui.js"></script>
|
<script defer src="respawn-ui.js"></script>
|
||||||
<script defer src="ralph-panel.js"></script>
|
<script defer src="ralph-panel.js"></script>
|
||||||
@@ -3870,6 +3936,7 @@
|
|||||||
<script defer src="webview-tabs.js"></script>
|
<script defer src="webview-tabs.js"></script>
|
||||||
<script defer src="mobile-overview.js"></script>
|
<script defer src="mobile-overview.js"></script>
|
||||||
<script defer src="home-sessions.js"></script>
|
<script defer src="home-sessions.js"></script>
|
||||||
|
<script defer src="git-status-ui.js"></script>
|
||||||
<script defer src="entrance-animations.js"></script>
|
<script defer src="entrance-animations.js"></script>
|
||||||
<script defer src="ralph-wizard.js"></script>
|
<script defer src="ralph-wizard.js"></script>
|
||||||
<script defer src="api-client.js"></script>
|
<script defer src="api-client.js"></script>
|
||||||
|
|||||||
+177
-23
@@ -1874,10 +1874,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
try {
|
try {
|
||||||
// Get case path first
|
// Get case path first
|
||||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||||
let caseData = (await caseRes.json())?.data ?? {};
|
const caseLookup = await caseRes.json();
|
||||||
|
let caseData = caseLookup?.data ?? {};
|
||||||
|
|
||||||
// Create the case if it doesn't exist
|
// Create the case only when the server says it does not exist. Any other
|
||||||
|
// failure (a linked folder on a mount that is not answering) must not
|
||||||
|
// scaffold a same-name local case that would then shadow the real one.
|
||||||
if (!caseData.path) {
|
if (!caseData.path) {
|
||||||
|
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||||
const createCaseRes = await fetch('/api/cases', {
|
const createCaseRes = await fetch('/api/cases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2084,10 +2088,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
try {
|
try {
|
||||||
// Get the case path
|
// Get the case path
|
||||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||||
let caseData = (await caseRes.json())?.data ?? {};
|
const caseLookup = await caseRes.json();
|
||||||
|
let caseData = caseLookup?.data ?? {};
|
||||||
|
|
||||||
// Create the case if it doesn't exist
|
// Create the case only when the server says it does not exist. Any other
|
||||||
|
// failure (a linked folder on a mount that is not answering) must not
|
||||||
|
// scaffold a same-name local case that would then shadow the real one.
|
||||||
if (!caseData.path) {
|
if (!caseData.path) {
|
||||||
|
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||||
const createCaseRes = await fetch('/api/cases', {
|
const createCaseRes = await fetch('/api/cases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2589,6 +2597,62 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return typeof confirmed === 'string' ? confirmed : name;
|
return typeof confirmed === 'string' ? confirmed : name;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an inline rename, one PUT per session at a time, in the order the
|
||||||
|
* user made them. The editor can be reopened (or cancelled, or replaced by a
|
||||||
|
* group rename) while a PUT is in flight, so the write lives here rather than
|
||||||
|
* in the editor: a confirmed name is applied locally even after its editor is
|
||||||
|
* gone, and the "already that name" check runs only once the earlier writes
|
||||||
|
* have landed, so confirming the name still on screen is a real write.
|
||||||
|
* Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects,
|
||||||
|
* and reports a failed write itself, since its editor may be gone by then.
|
||||||
|
* `_inlineRenamePending` holds the newest queued name per session, so an
|
||||||
|
* editor reopened over a write in flight starts from that name rather than
|
||||||
|
* the one the server has not replaced yet.
|
||||||
|
*/
|
||||||
|
_queueInlineSessionName(sessionId, desiredName) {
|
||||||
|
this._inlineRenameWrites ??= new Map();
|
||||||
|
this._inlineRenamePending ??= new Map();
|
||||||
|
const writes = this._inlineRenameWrites;
|
||||||
|
const pending = this._inlineRenamePending;
|
||||||
|
pending.set(sessionId, desiredName);
|
||||||
|
// Chained from a settled promise, so one rejected write cannot stop the
|
||||||
|
// writes queued behind it.
|
||||||
|
const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {});
|
||||||
|
const task = prev.then(async () => {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return { status: 'deleted' };
|
||||||
|
if (session.name === desiredName) return { status: 'confirmed' };
|
||||||
|
let confirmed = null;
|
||||||
|
try {
|
||||||
|
confirmed = await this._putSessionName(sessionId, desiredName);
|
||||||
|
} catch {
|
||||||
|
// A failure is a value, so a later write in the chain still runs.
|
||||||
|
}
|
||||||
|
if (!this.sessions.has(sessionId)) return { status: 'deleted' };
|
||||||
|
if (confirmed === null) {
|
||||||
|
this.showToast('Failed to rename', 'error');
|
||||||
|
return { status: 'failed' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._applyLocalSessionName(sessionId, confirmed);
|
||||||
|
this.renderSessionTabs();
|
||||||
|
} catch (err) {
|
||||||
|
// The server holds the name; a local repaint failing is not a failed write.
|
||||||
|
console.error('[rename] applying the confirmed name failed', err);
|
||||||
|
}
|
||||||
|
return { status: 'confirmed' };
|
||||||
|
});
|
||||||
|
writes.set(sessionId, task);
|
||||||
|
const cleanup = () => {
|
||||||
|
if (writes.get(sessionId) !== task) return;
|
||||||
|
writes.delete(sessionId);
|
||||||
|
pending.delete(sessionId);
|
||||||
|
};
|
||||||
|
task.then(cleanup, cleanup);
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
|
||||||
async saveSessionName() {
|
async saveSessionName() {
|
||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
// Captured: the modal can be closed (or switched to another session) while
|
// Captured: the modal can be closed (or switched to another session) while
|
||||||
@@ -2877,7 +2941,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabName.classList.add('tab-name-renaming');
|
tabName.classList.add('tab-name-renaming');
|
||||||
|
|
||||||
const currentName = this.getSessionName(session);
|
const currentName = this.getSessionName(session);
|
||||||
const parsed = parseSessionPrefix(session.name);
|
// A rename still in flight is the user's last word, not the name the
|
||||||
|
// server has yet to replace: start from it, and compare against it below.
|
||||||
|
const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name;
|
||||||
|
const renameInFlight = shownName !== session.name;
|
||||||
|
const parsed = parseSessionPrefix(shownName);
|
||||||
const originalContent = tabName.textContent;
|
const originalContent = tabName.textContent;
|
||||||
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
||||||
const restoreOriginalChildren = () => {
|
const restoreOriginalChildren = () => {
|
||||||
@@ -2898,13 +2966,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
input.value = parsed ? parsed.suffix : (shownName || '');
|
||||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||||
input.className = 'tab-rename-input';
|
input.className = 'tab-rename-input';
|
||||||
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
||||||
// should give the whole line to the input.
|
// should give the whole line to the input. The header editor may shrink to
|
||||||
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
|
// nothing, while a rail or sidebar row always keeps room to type.
|
||||||
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
const inRail = !!tabName.closest('.tab-rail');
|
||||||
|
const inSidebar = !inRail && !!this.isSessionSidebarActive?.();
|
||||||
|
const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px';
|
||||||
|
const renameMinWidth = inRail || inSidebar ? '4rem' : '0';
|
||||||
|
input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
||||||
|
|
||||||
tabName.appendChild(input);
|
tabName.appendChild(input);
|
||||||
input.focus();
|
input.focus();
|
||||||
@@ -2954,22 +3026,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const suffix = input.value.trim();
|
const suffix = input.value.trim();
|
||||||
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
||||||
if (fullName === session.name) restoreOriginalChildren();
|
// An unchanged confirm puts the old label back, unless the editor opened
|
||||||
|
// over a rename in flight: that label was repainted from the server's
|
||||||
|
// older name, so show the in-flight name rather than make it look lost.
|
||||||
|
if (fullName === shownName && !renameInFlight) restoreOriginalChildren();
|
||||||
else tabName.textContent = fullName || originalContent;
|
else tabName.textContent = fullName || originalContent;
|
||||||
|
|
||||||
// Skip the API call if the session vanished between focus and blur.
|
// Skip the API call if the session vanished between focus and blur. The
|
||||||
const stillExists = this.sessions.has(sessionId);
|
// queue applies the confirmed name to this.sessions before the re-render
|
||||||
if (stillExists && fullName !== session.name) {
|
// below repaints from it (see _applyLocalSessionName()).
|
||||||
const confirmed = await this._putSessionName(sessionId, fullName);
|
if (this.sessions.has(sessionId)) {
|
||||||
|
const result = await this._queueInlineSessionName(sessionId, fullName);
|
||||||
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
|
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
|
||||||
if (confirmed === null) {
|
// The queue reports a failure itself; the editor only puts its label back.
|
||||||
restoreOriginalChildren();
|
if (result.status === 'failed') restoreOriginalChildren();
|
||||||
this.showToast('Failed to rename', 'error');
|
|
||||||
} else {
|
|
||||||
// The re-render below repaints from this.sessions, so the new name has
|
|
||||||
// to be in the map before it runs (see _applyLocalSessionName()).
|
|
||||||
this._applyLocalSessionName(sessionId, confirmed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Re-render tabs to restore full tab structure
|
// Re-render tabs to restore full tab structure
|
||||||
completeCurrentRename();
|
completeCurrentRename();
|
||||||
@@ -3098,6 +3168,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showCreateCaseModal() {
|
showCreateCaseModal() {
|
||||||
document.getElementById('newCaseName').value = '';
|
document.getElementById('newCaseName').value = '';
|
||||||
document.getElementById('newCaseDescription').value = '';
|
document.getElementById('newCaseDescription').value = '';
|
||||||
|
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
|
||||||
|
// server refuses it (it writes outside the cases directory and into the shared registry).
|
||||||
|
const customToggle = document.getElementById('newCaseCustomPathToggle');
|
||||||
|
if (customToggle) customToggle.checked = false;
|
||||||
|
const customPath = document.getElementById('newCasePath');
|
||||||
|
if (customPath) customPath.value = '';
|
||||||
|
const me = window.__codemanUser || {};
|
||||||
|
const customRow = document.getElementById('newCaseCustomPathToggleRow');
|
||||||
|
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||||
|
this.toggleNewCaseCustomPath();
|
||||||
document.getElementById('linkCaseName').value = '';
|
document.getElementById('linkCaseName').value = '';
|
||||||
document.getElementById('linkCasePath').value = '';
|
document.getElementById('linkCasePath').value = '';
|
||||||
const remoteFields = [
|
const remoteFields = [
|
||||||
@@ -3265,6 +3345,71 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom-folder row for Create New: shows or hides the parent-folder field, and keeps it and the
|
||||||
|
* Docker option mutually exclusive (a Docker case has its own workspace flow, and the quick-create
|
||||||
|
* route has no `path`).
|
||||||
|
*/
|
||||||
|
toggleNewCaseCustomPath() {
|
||||||
|
const custom = document.getElementById('newCaseCustomPathToggle');
|
||||||
|
const docker = document.getElementById('newCaseDocker');
|
||||||
|
const row = document.getElementById('newCaseCustomPathRow');
|
||||||
|
if (!custom || !row) return;
|
||||||
|
row.style.display = custom.checked ? '' : 'none';
|
||||||
|
// The "under ~/codeman-cases" wording is wrong while a custom folder is picked.
|
||||||
|
const blurb = document.getElementById('newCaseBlurb');
|
||||||
|
if (blurb) {
|
||||||
|
blurb.textContent = custom.checked
|
||||||
|
? 'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.'
|
||||||
|
: 'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.';
|
||||||
|
}
|
||||||
|
const nameHint = document.getElementById('newCaseNameHint');
|
||||||
|
if (nameHint) {
|
||||||
|
nameHint.textContent = custom.checked
|
||||||
|
? 'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.'
|
||||||
|
: 'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/';
|
||||||
|
}
|
||||||
|
custom.disabled = !!docker?.checked;
|
||||||
|
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
|
||||||
|
if (docker) {
|
||||||
|
docker.disabled = custom.checked;
|
||||||
|
docker.title = custom.checked ? 'Not available with a custom folder' : '';
|
||||||
|
}
|
||||||
|
this.updateNewCasePathPreview();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The folder the case would be created in: the parent field plus the case name. */
|
||||||
|
_newCaseTargetPath() {
|
||||||
|
const rawParent = (document.getElementById('newCasePath')?.value || '').trim();
|
||||||
|
const name = (document.getElementById('newCaseName')?.value || '').trim();
|
||||||
|
if (!rawParent || !name) return '';
|
||||||
|
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
|
||||||
|
const parent = rawParent.replace(/\/+$/, '');
|
||||||
|
return `${parent}/${name}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNewCasePathPreview() {
|
||||||
|
const hint = document.getElementById('newCasePathPreview');
|
||||||
|
if (!hint) return;
|
||||||
|
const target = this._newCaseTargetPath();
|
||||||
|
hint.textContent = target ? `Will create: ${target}` : 'Pick the folder the new case folder should be created inside.';
|
||||||
|
},
|
||||||
|
|
||||||
|
openNewCasePathPicker() {
|
||||||
|
const input = document.getElementById('newCasePath');
|
||||||
|
PathPicker.open({
|
||||||
|
title: 'Choose the folder to create the case in',
|
||||||
|
initialPath: input.value.trim(),
|
||||||
|
directoriesOnly: true,
|
||||||
|
onSelect: (path) => {
|
||||||
|
input.value = path;
|
||||||
|
this.updateNewCasePathPreview();
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(path.length, path.length);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async createCase() {
|
async createCase() {
|
||||||
const name = document.getElementById('newCaseName').value.trim();
|
const name = document.getElementById('newCaseName').value.trim();
|
||||||
const description = document.getElementById('newCaseDescription').value.trim();
|
const description = document.getElementById('newCaseDescription').value.trim();
|
||||||
@@ -3282,9 +3427,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// One-click "Run in Docker": create the case folder AND a container, then start
|
// One-click "Run in Docker": create the case folder AND a container, then start
|
||||||
// a session inside it. Optional expandable settings override the defaults.
|
// a session inside it. Optional expandable settings override the defaults.
|
||||||
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
||||||
|
const customFolder = !inDocker && document.getElementById('newCaseCustomPathToggle')?.checked;
|
||||||
|
if (customFolder && !(document.getElementById('newCasePath')?.value || '').trim()) {
|
||||||
|
this.showToast('Choose the folder to create the case in', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
||||||
const payload = inDocker
|
const payload = inDocker
|
||||||
? { name, description, ...this._collectDockerQuickSettings() }
|
? { name, description, ...this._collectDockerQuickSettings() }
|
||||||
|
: customFolder
|
||||||
|
? { name, description, path: this._newCaseTargetPath() }
|
||||||
: { name, description };
|
: { name, description };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3307,7 +3459,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Start a session INSIDE the container (routes through quick-start).
|
// Start a session INSIDE the container (routes through quick-start).
|
||||||
await this.runClaude();
|
await this.runClaude();
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Case "${name}" created`, 'success');
|
// The server's path is the folder actually created (~ expanded, symlinks resolved).
|
||||||
|
const createdIn = data.data?.case?.path || payload.path;
|
||||||
|
this.showToast(customFolder ? `Case "${name}" created in ${createdIn}` : `Case "${name}" created`, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.showToast(data.error || 'Failed to create case', 'error');
|
this.showToast(data.error || 'Failed to create case', 'error');
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||||
this.applyMcpSyncVisibility();
|
this.applyMcpSyncVisibility();
|
||||||
|
this._applyDoctorAdminGate();
|
||||||
this.loadWebhook();
|
this.loadWebhook();
|
||||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||||
@@ -450,6 +451,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||||
|
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||||
|
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
||||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||||
@@ -1192,6 +1195,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a
|
||||||
|
* non-admin gets no Diagnostics group instead of a button that can only answer 403. Also
|
||||||
|
* wired to `codeman:me` for the same late-resolving role as the groups above.
|
||||||
|
*/
|
||||||
|
_applyDoctorAdminGate() {
|
||||||
|
const group = document.getElementById('doctorGroup');
|
||||||
|
if (!group) return;
|
||||||
|
const me = window.__codemanUser || {};
|
||||||
|
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||||
|
},
|
||||||
|
|
||||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||||
async mcpSync(apply) {
|
async mcpSync(apply) {
|
||||||
const out = this.$('mcpSyncResult');
|
const out = this.$('mcpSyncResult');
|
||||||
@@ -1364,6 +1379,69 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||||
|
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||||
|
*/
|
||||||
|
async runDoctor() {
|
||||||
|
const out = document.getElementById('doctorResult');
|
||||||
|
const btn = document.getElementById('doctorRunBtn');
|
||||||
|
if (!out) return;
|
||||||
|
const say = (text) => {
|
||||||
|
out.replaceChildren(document.createTextNode(text));
|
||||||
|
out.style.display = 'block';
|
||||||
|
};
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
say('Checking…');
|
||||||
|
try {
|
||||||
|
const res = await this._api('/api/doctor');
|
||||||
|
let body = null;
|
||||||
|
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||||
|
if (!res || !res.ok || !body || body.success === false) {
|
||||||
|
say(body?.error || 'The check failed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tools, summary, platform } = body.data;
|
||||||
|
const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.style.margin = '0';
|
||||||
|
list.style.paddingLeft = '1.2em';
|
||||||
|
for (const t of tools) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const strong = document.createElement('b');
|
||||||
|
// As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗.
|
||||||
|
const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?';
|
||||||
|
strong.textContent = `${mark} ${t.label}`;
|
||||||
|
li.append(strong);
|
||||||
|
const bits = [t.status];
|
||||||
|
if (t.version) bits.push(t.version);
|
||||||
|
if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
|
||||||
|
if (t.reason) bits.push(t.reason);
|
||||||
|
li.append(document.createTextNode(` ${bits.join(' · ')}`));
|
||||||
|
if (t.path) {
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'mono';
|
||||||
|
p.textContent = t.path;
|
||||||
|
li.append(p);
|
||||||
|
}
|
||||||
|
if (t.status === 'missing' && t.installHint) {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.textContent = `Install: ${t.installHint}`;
|
||||||
|
li.append(h);
|
||||||
|
}
|
||||||
|
list.append(li);
|
||||||
|
}
|
||||||
|
const head = document.createElement('p');
|
||||||
|
head.textContent =
|
||||||
|
`${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
|
||||||
|
` (${platform.environment})`;
|
||||||
|
out.replaceChildren(head, list);
|
||||||
|
out.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_setUpdateResult(html) {
|
_setUpdateResult(html) {
|
||||||
const el = this.$('updateResult');
|
const el = this.$('updateResult');
|
||||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||||
@@ -2422,6 +2500,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
|
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||||
|
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
@@ -2674,6 +2754,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showSessionButton: _ssb,
|
showSessionButton: _ssb,
|
||||||
showAwayDigestButton: _adb,
|
showAwayDigestButton: _adb,
|
||||||
showCronButton: _crb,
|
showCronButton: _crb,
|
||||||
|
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||||
|
showGitStatus: _sgs,
|
||||||
|
gitStatusTree: _gst,
|
||||||
showTabDetachButton: _tdb,
|
showTabDetachButton: _tdb,
|
||||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
mobileOverviewEnabled: _mov,
|
mobileOverviewEnabled: _mov,
|
||||||
@@ -3613,6 +3696,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||||
|
// match the setting, so a live toggle needs no reload.
|
||||||
|
this.applyGitStatusVisibility?.();
|
||||||
|
|
||||||
// Notification bell is retired (notifications live in Settings → Notifications
|
// Notification bell is retired (notifications live in Settings → Notifications
|
||||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||||
const notifBtn = document.querySelector('.btn-notifications');
|
const notifBtn = document.querySelector('.btn-notifications');
|
||||||
@@ -3961,7 +4048,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection', 'copyStripMargin',
|
'autoCopySelection', 'copyStripMargin',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
@@ -4373,4 +4460,5 @@ document.addEventListener?.('codeman:me', () => {
|
|||||||
window.app?._applyCustomModelAdminGate?.();
|
window.app?._applyCustomModelAdminGate?.();
|
||||||
window.app?._applyCliManagementAdminGate?.();
|
window.app?._applyCliManagementAdminGate?.();
|
||||||
window.app?._applyMcpSyncAdminGate?.();
|
window.app?._applyMcpSyncAdminGate?.();
|
||||||
|
window.app?._applyDoctorAdminGate?.();
|
||||||
});
|
});
|
||||||
|
|||||||
+522
-2
@@ -736,6 +736,86 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v
|
|||||||
content: '\25B8';
|
content: '\25B8';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Group editing: the header's menu glyph, the inline rename editor, and the
|
||||||
|
pointer-drag marks. All of it lives inside the grouped rail only. */
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:hover .tab-layout-group-menu,
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:focus-visible .tab-layout-group-menu {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No hover on a touch tablet, and a long press there is not a contextmenu
|
||||||
|
event: the glyph is the only way into the group menu, so keep it shown. */
|
||||||
|
@media (hover: none) {
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-tertiary, var(--bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The label is a nowrap ellipsis box; while it holds the editor it becomes a
|
||||||
|
plain flex slot so the input gets the whole width and repaints as you type. */
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-name--renaming {
|
||||||
|
display: flex;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-rename-input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: normal;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .session-tab,
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layout-group-header {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-before {
|
||||||
|
box-shadow: inset 0 2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-after {
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-into {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-tab-orientation='vertical'] .header-right {
|
html[data-tab-orientation='vertical'] .header-right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -1768,22 +1848,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
|||||||
-webkit-line-clamp: unset;
|
-webkit-line-clamp: unset;
|
||||||
line-clamp: unset;
|
line-clamp: unset;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
/* An input's intrinsic width counts toward the label's min-content, so with
|
||||||
|
the default min-width:auto a long `w<n>-<case>` prefix plus the input
|
||||||
|
pushed the label past its row: the prefix slid out of view and, in the
|
||||||
|
compact rail, the input was clipped mid-word. */
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The prefix gives way first, down to a stub with an ellipsis. */
|
||||||
:is(
|
:is(
|
||||||
html[data-tab-orientation='vertical'] .tab-rail,
|
html[data-tab-orientation='vertical'] .tab-rail,
|
||||||
html[data-session-list='sidebar'] .session-sidebar
|
html[data-session-list='sidebar'] .session-sidebar
|
||||||
) .tab-name-renaming .tab-rename-prefix {
|
) .tab-name-renaming .tab-rename-prefix {
|
||||||
flex: 0 0 auto;
|
flex: 0 1 auto;
|
||||||
|
min-width: 2rem;
|
||||||
|
max-width: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The input takes the rest of the row. Its 4rem floor is the editor's own
|
||||||
|
inline min-width (startInlineRename picks it per layout). */
|
||||||
:is(
|
:is(
|
||||||
html[data-tab-orientation='vertical'] .tab-rail,
|
html[data-tab-orientation='vertical'] .tab-rail,
|
||||||
html[data-session-list='sidebar'] .session-sidebar
|
html[data-session-list='sidebar'] .session-sidebar
|
||||||
) .tab-name-renaming .tab-rename-input {
|
) .tab-name-renaming .tab-rename-input {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
}
|
||||||
|
|
||||||
|
/* A compact rail row has no room for both the editor and its adornments, so
|
||||||
|
they step aside while the name is being edited (the re-render that ends the
|
||||||
|
edit brings them back). */
|
||||||
|
html[data-tab-orientation='vertical'].tab-rail-compact
|
||||||
|
.tab-rail
|
||||||
|
.session-tab:has(.tab-name-renaming)
|
||||||
|
:is(
|
||||||
|
.tab-mode,
|
||||||
|
.tab-exited-badge,
|
||||||
|
.tab-detached-badge,
|
||||||
|
.tab-badge,
|
||||||
|
.tab-subagent-badge,
|
||||||
|
.tab-ultracode-badge,
|
||||||
|
.tab-actions
|
||||||
|
) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||||
@@ -8411,6 +8520,12 @@ kbd {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An action with no key bound (Close Session by default): say so, quietly. */
|
||||||
|
.shortcut-overlay-unbound {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-overlay-footer {
|
.shortcut-overlay-footer {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
@@ -18815,6 +18930,13 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
|||||||
line-clamp: 3;
|
line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ...except while it is being edited: this rule outranks the shared renaming
|
||||||
|
rule's unclamp, so restate it (the editor is a flex row, never clamped). */
|
||||||
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name.tab-name-renaming {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
line-clamp: unset;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
|
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
@@ -19274,3 +19396,401 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||||
|
|
||||||
|
/* .btn-toolbar sets display:flex, which beats the [hidden] attribute's UA display:none. */
|
||||||
|
.btn-git-status[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-git-status {
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-git-status .git-status-label {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The skin block in styles.css nests every .btn-toolbar rule under html:not([data-skin="og"]), which
|
||||||
|
out-ranks a bare .btn-git-status.<tone> rule, so the tones carry the same `html .toolbar` prefix
|
||||||
|
(specificity (0,3,1) against the skin's (0,2,1)). Without it the amber never shows. */
|
||||||
|
html .toolbar .btn-git-status.git-status--clean {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .toolbar .btn-git-status.git-status--clean svg {
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .toolbar .btn-git-status.git-status--dirty {
|
||||||
|
color: #e0a030;
|
||||||
|
border-color: rgba(224, 160, 48, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .toolbar .btn-git-status.git-status--conflict {
|
||||||
|
color: #e5534b;
|
||||||
|
border-color: rgba(229, 83, 75, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||||
|
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same look as the Files window. Default spot is just left of it so both can be open. */
|
||||||
|
.git-status-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--header-height) + 10px);
|
||||||
|
/* Just left of the Files window; on a narrow viewport slide right so the left edge never leaves the screen. */
|
||||||
|
right: clamp(8px, calc(100vw - 388px), 320px);
|
||||||
|
width: 380px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||||
|
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||||
|
max-height: 600px;
|
||||||
|
min-width: 260px;
|
||||||
|
min-height: 220px;
|
||||||
|
background: var(--floating-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-panel.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-branch {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-footer {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-empty,
|
||||||
|
.git-status-ok,
|
||||||
|
.git-status-note,
|
||||||
|
.git-status-more {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-ok {
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-branchline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-chip {
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-chip--warn {
|
||||||
|
color: #e0a030;
|
||||||
|
border-color: rgba(224, 160, 48, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-section {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-section-title {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-group {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-group-title {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-file,
|
||||||
|
.git-status-commit {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-file--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-file--clickable:hover,
|
||||||
|
.git-status-file--clickable:focus-visible {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge {
|
||||||
|
flex: 0 0 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0a030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge--A,
|
||||||
|
.git-status-badge--new {
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge--D,
|
||||||
|
.git-status-badge--U {
|
||||||
|
color: #e5534b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-path,
|
||||||
|
.git-status-subject {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-subject {
|
||||||
|
font-family: inherit;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-orig,
|
||||||
|
.git-status-commit-meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-hash {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: #e0a030;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One section per repository when a session's folder holds several (git-status-ui.js). */
|
||||||
|
.git-status-repo {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-input);
|
||||||
|
list-style: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-path,
|
||||||
|
.git-status-repo-branch {
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-state {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-state--dirty {
|
||||||
|
color: #e0a030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-body {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git status panel: per-file diff view */
|
||||||
|
.git-diff-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-kind {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-line {
|
||||||
|
display: block;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-line--add {
|
||||||
|
background: color-mix(in srgb, var(--green) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-line--del {
|
||||||
|
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-line--hunk {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-line--meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git status panel: files grouped under collapsible folders */
|
||||||
|
.git-tree-dir > .git-tree-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tree-dir > .git-tree-summary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tree-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tree-count {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-tree-children {
|
||||||
|
margin-left: 0.7rem;
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Read-only browser projection of the owner tab layout.
|
* @fileoverview Browser projection and editing of the owner tab layout.
|
||||||
*
|
*
|
||||||
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
||||||
* is the server model). Browser assets cannot import that TypeScript, so this
|
* is the server model). Browser assets cannot import that TypeScript, so this
|
||||||
* module is a small, dependency-free mirror that owns three things:
|
* module is a small, dependency-free mirror that owns four things:
|
||||||
*
|
*
|
||||||
* 1. Projection: which live sessions and open web tabs land in which group,
|
* 1. Projection: which live sessions and open web tabs land in which group,
|
||||||
* and which rows a collapsed group hides.
|
* and which rows a collapsed group hides.
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
* byte-identical to the flat rail's row.
|
* byte-identical to the flat rail's row.
|
||||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||||
* read degrades to the flat rail with a capped, backed-off retry.
|
* read degrades to the flat rail with a capped, backed-off retry.
|
||||||
|
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
|
||||||
|
* row) applied optimistically and saved through ONE serialized
|
||||||
|
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||||
|
* version conflict.
|
||||||
*
|
*
|
||||||
* The server stays the only authority for layout content. Collapse is a
|
* The server stays the only authority for layout content. Collapse is a
|
||||||
* per-device view preference and lives in localStorage only.
|
* per-device view preference and lives in localStorage only.
|
||||||
@@ -34,8 +38,16 @@
|
|||||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||||
const stableIds = (value) => [...new Set(asIds(value))];
|
const stableIds = (value) => [...new Set(asIds(value))];
|
||||||
const copyRefs = (value) =>
|
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
// layouts back, and dropping it would re-attach a hand-placed child session to
|
||||||
|
// its parent's subtree on the next save.
|
||||||
|
const copyRef = (r) =>
|
||||||
|
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
|
||||||
|
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
|
||||||
|
|
||||||
|
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
|
||||||
|
const MAX_GROUPS = 32;
|
||||||
|
const MAX_NAME_LENGTH = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
||||||
@@ -46,6 +58,7 @@
|
|||||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||||
return {
|
return {
|
||||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||||
|
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
|
||||||
groups: groups
|
groups: groups
|
||||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
@@ -276,10 +289,17 @@
|
|||||||
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
||||||
return (
|
return (
|
||||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
|
||||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||||
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||||
`<span class="tab-layout-group-count">${section.count}</span></div>` +
|
`<span class="tab-layout-group-count">${section.count}</span>` +
|
||||||
|
// Pointer path to the group menu (right-click on the header works too).
|
||||||
|
// Deliberately NOT a button and not focusable: a treeitem holds no
|
||||||
|
// interactive children, and the keyboard path is Shift+F10 /
|
||||||
|
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
|
||||||
|
// the header's accessible name.
|
||||||
|
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
|
||||||
|
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">⋯</span></div>' +
|
||||||
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -340,6 +360,375 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Editing ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The browser edits through NAMED operations, not by diffing arrays: a write
|
||||||
|
// that loses a version race (409) is rebased by replaying the same operations
|
||||||
|
// on the layout the server returned, so a concurrent edit elsewhere survives.
|
||||||
|
// The server stays the authority: it re-validates and normalizes every PUT.
|
||||||
|
|
||||||
|
function editError(message) {
|
||||||
|
throw new Error(`Tab layout edit failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
|
||||||
|
|
||||||
|
function groupName(value) {
|
||||||
|
const name = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refLocations(layout) {
|
||||||
|
return [
|
||||||
|
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
|
||||||
|
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerRefs(layout, groupId) {
|
||||||
|
if (groupId === null) return layout.ungrouped;
|
||||||
|
const group = layout.groups.find((candidate) => candidate.id === groupId);
|
||||||
|
if (!group) editError('unknown group');
|
||||||
|
return group.refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows that move together with `ref`: the session plus every descendant
|
||||||
|
* that still follows its parent (non-manual, parent stored). Mirrors the
|
||||||
|
* server's moveRef block so the optimistic rail matches what it will store.
|
||||||
|
* `parents` maps a session id to its parent session id.
|
||||||
|
*/
|
||||||
|
function lineageBlock(layout, ref, parents) {
|
||||||
|
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
|
||||||
|
const children = new Map();
|
||||||
|
for (const [childId, parentId] of Object.entries(parents || {})) {
|
||||||
|
const child = stored.get(`session:${childId}`);
|
||||||
|
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
|
||||||
|
if (!children.has(parentId)) children.set(parentId, []);
|
||||||
|
children.get(parentId).push(childId);
|
||||||
|
}
|
||||||
|
const keys = new Set();
|
||||||
|
const visit = (key) => {
|
||||||
|
if (keys.has(key)) return;
|
||||||
|
keys.add(key);
|
||||||
|
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
|
||||||
|
};
|
||||||
|
visit(refKey(ref));
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a moved row lands, as the server's `index` (counted AFTER the moved
|
||||||
|
* block is taken out): before or after `anchor` in that container, or at its
|
||||||
|
* end when there is no anchor.
|
||||||
|
*/
|
||||||
|
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
|
||||||
|
const layout = normalizeLayout(layoutInput);
|
||||||
|
const block = lineageBlock(layout, ref, parents);
|
||||||
|
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
|
||||||
|
// No anchor means "at the end", and an operation with no index keeps
|
||||||
|
// meaning that when it is replayed onto a layout that has changed since.
|
||||||
|
if (!anchor) return { groupId };
|
||||||
|
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
|
||||||
|
if (at < 0) return { groupId };
|
||||||
|
return { groupId, index: placement === 'after' ? at + 1 : at };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a finished drag to ONE operation (or null for a drop that changes
|
||||||
|
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
|
||||||
|
*
|
||||||
|
* source: { type: 'ref', ref } | { type: 'group', groupId }
|
||||||
|
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
|
||||||
|
* | { type: 'group', groupId } (a named group's header or empty body)
|
||||||
|
* | { type: 'ungrouped' }
|
||||||
|
*
|
||||||
|
* A group dropped on another group (or any row in it) takes that group's slot;
|
||||||
|
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
|
||||||
|
* before/after it, on a header it is appended to that group.
|
||||||
|
*/
|
||||||
|
function dropOperation(layoutInput, source, target, parents) {
|
||||||
|
const layout = normalizeLayout(layoutInput);
|
||||||
|
if (!source || !target) return null;
|
||||||
|
if (source.type === 'group') {
|
||||||
|
const from = layout.groups.findIndex((group) => group.id === source.groupId);
|
||||||
|
if (from < 0) return null;
|
||||||
|
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
|
||||||
|
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
|
||||||
|
if (to < 0 || to === from) return null;
|
||||||
|
return { type: 'reorderGroup', groupId: source.groupId, index: to };
|
||||||
|
}
|
||||||
|
if (source.type !== 'ref' || !validRef(source.ref)) return null;
|
||||||
|
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
|
||||||
|
if (!location) return null;
|
||||||
|
let groupId;
|
||||||
|
let anchor = null;
|
||||||
|
let placement = 'before';
|
||||||
|
if (target.type === 'ref' && validRef(target.ref)) {
|
||||||
|
// Onto itself or onto a row that moves with it: nowhere to go.
|
||||||
|
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
|
||||||
|
groupId = target.groupId ?? null;
|
||||||
|
anchor = target.ref;
|
||||||
|
placement = target.placement === 'after' ? 'after' : 'before';
|
||||||
|
} else if (target.type === 'group') {
|
||||||
|
groupId = target.groupId ?? null;
|
||||||
|
if (groupId === location.groupId) return null;
|
||||||
|
} else if (target.type === 'ungrouped') {
|
||||||
|
groupId = null;
|
||||||
|
if (location.groupId === null) return null;
|
||||||
|
} else return null;
|
||||||
|
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
|
||||||
|
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
|
||||||
|
const operation = {
|
||||||
|
type: 'moveRef',
|
||||||
|
ref: { kind: source.ref.kind, id: source.ref.id },
|
||||||
|
groupId: destination.groupId,
|
||||||
|
...(destination.index === undefined ? {} : { index: destination.index }),
|
||||||
|
parents: parents || {},
|
||||||
|
};
|
||||||
|
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one operation to a copy of the layout. Throws when the operation no
|
||||||
|
* longer makes sense (an unknown group or row); a rebase drops that one
|
||||||
|
* operation and keeps the rest. Replays are idempotent where it matters for
|
||||||
|
* recovery: creating a group that already exists is a no-op.
|
||||||
|
*/
|
||||||
|
function applyOperation(layoutInput, operation) {
|
||||||
|
const layout = normalizeLayout(layoutInput);
|
||||||
|
const op = operation || {};
|
||||||
|
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
|
||||||
|
switch (op.type) {
|
||||||
|
case 'createGroup': {
|
||||||
|
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
|
||||||
|
const name = groupName(op.name);
|
||||||
|
if (layout.groups.some((group) => group.id === op.id)) return layout;
|
||||||
|
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
|
||||||
|
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
case 'renameGroup':
|
||||||
|
if (groupIndex < 0) editError('unknown group');
|
||||||
|
layout.groups[groupIndex].name = groupName(op.name);
|
||||||
|
return layout;
|
||||||
|
case 'deleteGroup': {
|
||||||
|
// Already gone (deleted elsewhere): nothing left to do.
|
||||||
|
if (groupIndex < 0) return layout;
|
||||||
|
const [removed] = layout.groups.splice(groupIndex, 1);
|
||||||
|
layout.ungrouped.push(...removed.refs);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
case 'reorderGroup': {
|
||||||
|
if (groupIndex < 0) editError('unknown group');
|
||||||
|
const [moved] = layout.groups.splice(groupIndex, 1);
|
||||||
|
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
case 'moveRef': {
|
||||||
|
if (!validRef(op.ref)) editError('invalid row');
|
||||||
|
const targetKey = refKey(op.ref);
|
||||||
|
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
|
||||||
|
const destinationId = op.groupId ?? null;
|
||||||
|
containerRefs(layout, destinationId);
|
||||||
|
const keys = lineageBlock(layout, op.ref, op.parents);
|
||||||
|
const block = refLocations(layout)
|
||||||
|
.filter((item) => keys.has(refKey(item.ref)))
|
||||||
|
.map((item) => copyRef(item.ref));
|
||||||
|
// A hand-moved child stops following its parent (server moveRef does the same).
|
||||||
|
const head = block.find((item) => refKey(item) === targetKey);
|
||||||
|
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
|
||||||
|
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
|
||||||
|
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
|
||||||
|
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
|
||||||
|
const destination = containerRefs(layout, destinationId);
|
||||||
|
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return editError(`unknown operation ${op.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout content without version metadata: equal keys mean "nothing to save". */
|
||||||
|
function contentKey(layoutInput) {
|
||||||
|
const layout = normalizeLayout(layoutInput);
|
||||||
|
return JSON.stringify([layout.groups, layout.ungrouped]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replay operations, dropping (and counting) the ones that no longer apply. */
|
||||||
|
function replayOperations(base, operations) {
|
||||||
|
let layout = normalizeLayout(base);
|
||||||
|
const kept = [];
|
||||||
|
let dropped = 0;
|
||||||
|
for (const operation of operations) {
|
||||||
|
try {
|
||||||
|
layout = applyOperation(layout, operation);
|
||||||
|
kept.push(operation);
|
||||||
|
} catch (_error) {
|
||||||
|
dropped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layout, kept, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized, optimistic writer for `PUT /api/tab-layout`.
|
||||||
|
*
|
||||||
|
* - enqueue() applies an operation at once (the rail repaints optimistically)
|
||||||
|
* and schedules a flush; operations enqueued in the same turn share a PUT.
|
||||||
|
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
|
||||||
|
* sent on top of the version that write returns.
|
||||||
|
* - A 409 carries the server's current layout: the in-flight operations are
|
||||||
|
* replayed onto it and re-sent with its version (bounded attempts). A 400
|
||||||
|
* (a row vanished between read and write) re-reads and rebases the same way.
|
||||||
|
* - Anything else, or attempts exhausted, drops the batch and reports it; the
|
||||||
|
* caller re-reads so the rail shows the server's truth.
|
||||||
|
*
|
||||||
|
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
|
||||||
|
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
|
||||||
|
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
|
||||||
|
*/
|
||||||
|
function createEditCoordinator(options) {
|
||||||
|
let authoritative = normalizeLayout(options.initialLayout);
|
||||||
|
let optimistic = authoritative;
|
||||||
|
let pending = [];
|
||||||
|
let inFlight = [];
|
||||||
|
let writing = false;
|
||||||
|
let timer = null;
|
||||||
|
let disposed = false;
|
||||||
|
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
|
||||||
|
const cancel = options.cancel || ((handle) => clearTimeout(handle));
|
||||||
|
const maxAttempts = options.maxAttempts || 3;
|
||||||
|
const report = (message) => options.reportError?.(message);
|
||||||
|
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
|
||||||
|
const queue = () => {
|
||||||
|
if (timer === null) timer = schedule(flush);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
timer = null;
|
||||||
|
if (disposed || writing || pending.length === 0) return;
|
||||||
|
writing = true;
|
||||||
|
inFlight = pending;
|
||||||
|
pending = [];
|
||||||
|
let failed = false;
|
||||||
|
let reportedDrop = false;
|
||||||
|
let rereadFor400 = false;
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
|
||||||
|
const desired = replayOperations(authoritative, inFlight);
|
||||||
|
inFlight = desired.kept;
|
||||||
|
if (desired.dropped && !reportedDrop) {
|
||||||
|
reportedDrop = true;
|
||||||
|
report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||||
|
}
|
||||||
|
// Nothing left to change (dropped, or already true on the server).
|
||||||
|
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
|
||||||
|
inFlight = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
|
||||||
|
if (disposed) return;
|
||||||
|
if (response?.ok && response.layout) {
|
||||||
|
authoritative = normalizeLayout(response.layout);
|
||||||
|
inFlight = [];
|
||||||
|
} else if (response?.status === 409 && response.layout) {
|
||||||
|
authoritative = normalizeLayout(response.layout);
|
||||||
|
} else if (response?.status === 400 && options.fetchLayout && !rereadFor400) {
|
||||||
|
// Maybe our base was stale in a way the server reports as invalid:
|
||||||
|
// re-read once. A 400 that survives that is a refusal, not a race.
|
||||||
|
rereadFor400 = true;
|
||||||
|
authoritative = normalizeLayout(await options.fetchLayout());
|
||||||
|
if (disposed) return;
|
||||||
|
} else {
|
||||||
|
throw new Error('Tab layout save failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inFlight.length) {
|
||||||
|
failed = true;
|
||||||
|
report('Tab groups kept changing elsewhere; your edit was not saved.');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
failed = true;
|
||||||
|
report('Could not save tab groups.');
|
||||||
|
} finally {
|
||||||
|
inFlight = [];
|
||||||
|
writing = false;
|
||||||
|
if (!disposed) {
|
||||||
|
const rebased = replayOperations(authoritative, pending);
|
||||||
|
// Edits made while the write was in flight are rebased here, so one the
|
||||||
|
// conflict made inapplicable is dropped here too, and says so (once).
|
||||||
|
if (rebased.dropped && !failed && !reportedDrop) {
|
||||||
|
report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||||
|
}
|
||||||
|
pending = rebased.kept;
|
||||||
|
optimistic = rebased.layout;
|
||||||
|
publish({ authoritative: true });
|
||||||
|
if (failed) options.onFailure?.();
|
||||||
|
if (pending.length) queue();
|
||||||
|
else options.onSettled?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
|
||||||
|
enqueue(operation) {
|
||||||
|
optimistic = applyOperation(optimistic, operation);
|
||||||
|
pending.push(operation);
|
||||||
|
publish({ optimistic: true });
|
||||||
|
queue();
|
||||||
|
return normalizeLayout(optimistic);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Re-apply operations recovered after a reload. Returns false (and queues
|
||||||
|
* nothing) when the layout already reflects them, e.g. the keepalive save
|
||||||
|
* landed before the page went away.
|
||||||
|
*/
|
||||||
|
restore(operations) {
|
||||||
|
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
|
||||||
|
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
|
||||||
|
optimistic = replayed.layout;
|
||||||
|
pending.push(...replayed.kept);
|
||||||
|
publish({ optimistic: true });
|
||||||
|
queue();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Adopt a layout read from the server (SSE reload). Pending operations are
|
||||||
|
* rebased onto it. Refused while a write is in flight (its result decides)
|
||||||
|
* and for a layout older than the one already held.
|
||||||
|
*/
|
||||||
|
adoptExternal(layout) {
|
||||||
|
if (disposed || writing) return false;
|
||||||
|
const next = normalizeLayout(layout);
|
||||||
|
if (next.version < authoritative.version) return false;
|
||||||
|
authoritative = next;
|
||||||
|
const rebased = replayOperations(next, pending);
|
||||||
|
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||||
|
pending = rebased.kept;
|
||||||
|
optimistic = rebased.layout;
|
||||||
|
publish({ authoritative: true, external: true });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
isWriting: () => writing,
|
||||||
|
hasPending: () => writing || pending.length > 0,
|
||||||
|
/** Every operation not yet confirmed by the server, oldest first. */
|
||||||
|
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
|
||||||
|
baseVersion: () => authoritative.version,
|
||||||
|
getLayout: () => normalizeLayout(optimistic),
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
if (timer !== null) cancel(timer);
|
||||||
|
timer = null;
|
||||||
|
pending = [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
global.CodemanTabLayout = {
|
global.CodemanTabLayout = {
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
hasGroups,
|
hasGroups,
|
||||||
@@ -347,8 +736,15 @@
|
|||||||
hiddenGroupAlerts,
|
hiddenGroupAlerts,
|
||||||
structureKey,
|
structureKey,
|
||||||
renderProjection,
|
renderProjection,
|
||||||
|
applyOperation,
|
||||||
|
moveDestination,
|
||||||
|
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||||
|
dropOperation,
|
||||||
|
contentKey,
|
||||||
|
createEditCoordinator,
|
||||||
createLoadCoordinator,
|
createLoadCoordinator,
|
||||||
loadCollapsedGroupIds,
|
loadCollapsedGroupIds,
|
||||||
saveCollapsedGroupIds,
|
saveCollapsedGroupIds,
|
||||||
|
MAX_GROUPS,
|
||||||
};
|
};
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closeTabRailActionMenu(options = {}) {
|
closeTabRailActionMenu(options = {}) {
|
||||||
const menu = document.querySelector('.tab-rail-action-menu');
|
// The group menu borrows this class for its look but has its own owner
|
||||||
|
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
|
||||||
|
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
|
||||||
const trigger = this._tabRailActionMenuTrigger;
|
const trigger = this._tabRailActionMenuTrigger;
|
||||||
menu?.remove();
|
menu?.remove();
|
||||||
if (this._tabRailActionMenuOutside) {
|
if (this._tabRailActionMenuOutside) {
|
||||||
@@ -325,6 +327,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||||
|
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||||
|
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -1,640 +1,16 @@
|
|||||||
// src/web/public/terminal-split.js
|
// src/web/public/terminal-split.js
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
* @fileoverview Split-pane orchestration: opens a second live session
|
||||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
* a draggable divider, a session picker, and auto-collapse when either
|
||||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
|
||||||
*
|
*
|
||||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
* @dependency terminal-tile.js (window.TerminalTile)
|
||||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js
|
||||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
|
||||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
||||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
|
||||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
|
||||||
* giant synchronous write that blocks the main thread while parsing a long
|
|
||||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
|
||||||
* (terminal-ui.js): that one is wired into session-switch generation
|
|
||||||
* counters and the live-output gate this simpler, independently
|
|
||||||
* created/destroyed pane has no equivalent of.
|
|
||||||
*/
|
|
||||||
function writeChunked(terminal, buffer, isDestroyed) {
|
|
||||||
if (!buffer) return Promise.resolve();
|
|
||||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
|
||||||
terminal.write(buffer);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
|
||||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
|
||||||
// across the whole replay rather than just the fetch that precedes it.
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let offset = 0;
|
|
||||||
const writeNext = () => {
|
|
||||||
if (isDestroyed() || !terminal) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
|
||||||
offset += chunk.length;
|
|
||||||
terminal.write(chunk);
|
|
||||||
if (offset < buffer.length) {
|
|
||||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
|
||||||
else setTimeout(writeNext, 16);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class SplitTerminalPane {
|
|
||||||
constructor(sessionId, mountEl, opts = {}) {
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.mountEl = mountEl;
|
|
||||||
this.sessionMode = opts.mode;
|
|
||||||
this.fontSettings = opts.fontSettings || {};
|
|
||||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
|
||||||
// detaching this session AFTER the split is already open must still be
|
|
||||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
|
||||||
// fight the split picker already refuses to open at pick time.
|
|
||||||
this.detachedSessions = opts.detachedSessions;
|
|
||||||
this.terminal = null;
|
|
||||||
this.fitAddon = null;
|
|
||||||
this.ws = null;
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = false;
|
|
||||||
this._destroyed = false;
|
|
||||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
|
||||||
this._bufferLoading = false;
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
||||||
// `_liveQueue` is non-null from the pull's response until its finally
|
|
||||||
// block: live frames are held there with their arrival time instead of
|
|
||||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
|
||||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
|
||||||
this._historyPullAt = 0;
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
this._liveQueue = null;
|
|
||||||
this._markerOwed = false;
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
theme: { ...global.codemanCurrentXtermTheme() },
|
|
||||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
|
||||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
|
||||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
cursorBlink: false,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
||||||
scrollback: DEFAULT_SCROLLBACK,
|
|
||||||
allowTransparency: true,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
|
||||||
this.terminal.open(this.mountEl);
|
|
||||||
this.fitAddon.fit();
|
|
||||||
|
|
||||||
this._installWheelListener();
|
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pane B has no gates of its own by default, so every app-level chord
|
|
||||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
|
||||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
|
||||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
|
||||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
|
||||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
|
||||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
|
||||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
|
||||||
// registry-aware predicates so a rebind or a disable restores plain
|
|
||||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
|
||||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
|
||||||
// to, so intercepting it here would only break paste.
|
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
|
||||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
|
||||||
if (
|
|
||||||
ev.altKey &&
|
|
||||||
!ev.ctrlKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
|
||||||
// swallow — in a plain shell session this is the user's own
|
|
||||||
// job-control tool and must reach the PTY, but in every other mode
|
|
||||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
|
||||||
// loop dead. Pane B has its own PTY/session and must not send a
|
|
||||||
// suspend into a non-shell one just because the primary pane's own
|
|
||||||
// gate lives elsewhere.
|
|
||||||
if (
|
|
||||||
ev.type === 'keydown' &&
|
|
||||||
ev.key.toLowerCase() === 'z' &&
|
|
||||||
ev.ctrlKey &&
|
|
||||||
!ev.altKey &&
|
|
||||||
!ev.metaKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
this.sessionMode !== 'shell'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
|
||||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
|
||||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
|
||||||
// newline from a submit. Without this gate, Pane B's onData would
|
|
||||||
// send that bare \r straight over the WS and submit an incomplete
|
|
||||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
|
||||||
// session (this.sessionId), never the primary pane's
|
|
||||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
|
||||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
|
||||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
|
||||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
|
||||||
if (ev.type === 'keydown') {
|
|
||||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
|
||||||
}).catch(() => {
|
|
||||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
|
||||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
|
||||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
|
||||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
|
||||||
// explicit, never-falls-through copy chord, and the predicate above
|
|
||||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
|
||||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
|
||||||
// a branch that assigns c.key only for a couple of special cases
|
|
||||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
|
||||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
|
||||||
// interrupt byte reaching the PTY (verified live: it does not).
|
|
||||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
|
||||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
|
||||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
|
||||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
|
||||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
|
||||||
// Re-implemented against this.terminal rather than reusing
|
|
||||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
|
||||||
// and would copy the wrong pane's selection.
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
|
||||||
const raw = this.terminal?.getSelection?.() || '';
|
|
||||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
|
||||||
// Both clean options are read for THIS pane, never the primary one:
|
|
||||||
// the gutter width comes from this.sessionId's own run mode, and the
|
|
||||||
// partial-first-line flag from this terminal's own selection range.
|
|
||||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
|
||||||
// same split and the same keystroke.
|
|
||||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
|
||||||
const selection = isColumnSelection
|
|
||||||
? raw
|
|
||||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
|
||||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
|
||||||
}) ?? raw);
|
|
||||||
if (selection.trim()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
void global.app._copyText?.(selection).then((ok) => {
|
|
||||||
this.terminal?.clearSelection?.();
|
|
||||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Nothing worth copying — clear for feedback (a padding-only
|
|
||||||
// selection cleans to '' and this press still falls through to the
|
|
||||||
// PTY as 0x03, matching the primary pane's own rule).
|
|
||||||
if (this.terminal?.hasSelection?.()) {
|
|
||||||
this.terminal.clearSelection?.();
|
|
||||||
global.app.showToast?.('Nothing to copy', 'warning');
|
|
||||||
}
|
|
||||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
|
||||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing scrollback before going live. The WS below is
|
|
||||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
||||||
// 'terminal' events), so without this Pane B stays blank until the
|
|
||||||
// target session happens to produce new output. It LOOKED
|
|
||||||
// intermittent rather than always-broken because _sendResize() below
|
|
||||||
// often nudges the shared session's real tmux window to a new size,
|
|
||||||
// and tmux repaints its current screen on resize — that repaint was
|
|
||||||
// getting captured and streamed here, incidentally populating the
|
|
||||||
// pane. When Pane B's computed dimensions happened to already match
|
|
||||||
// the session's last-known size, Session.resize() (session.ts) skips
|
|
||||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
|
||||||
// The await covers the whole chunked replay, not just the fetch, so a
|
|
||||||
// live frame from the socket below can never land in the middle of it.
|
|
||||||
await this._loadBuffer();
|
|
||||||
if (this._destroyed) return;
|
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._wsReady = true;
|
|
||||||
this._sendResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.t === 'o') {
|
|
||||||
this._onLiveOutput(msg.d);
|
|
||||||
} else if (msg.t === 'c') {
|
|
||||||
this._onLiveClear();
|
|
||||||
} else if (msg.t === 'r') {
|
|
||||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
|
||||||
// data was dropped). The primary pane routes this to
|
|
||||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
|
||||||
// buffer loader for the same reason connect() does.
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
||||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
||||||
// closed socket per the WebSocket spec (no exception, no log). No
|
|
||||||
// reconnect logic here — Pane B is deliberately plainer than the
|
|
||||||
// primary pane (see the fileoverview above); a drop just stops
|
|
||||||
// resizing until the parent recreates the pane. But onData already
|
|
||||||
// silently drops keystrokes while _wsReady is false (below), so
|
|
||||||
// without a visible marker a dropped socket left Pane B looking
|
|
||||||
// normal while it quietly ate everything typed into it. v1 scope is
|
|
||||||
// "say so", not reconnect — collapsing the split would lose the
|
|
||||||
// user's place in Pane B's scrollback for a transient blip.
|
|
||||||
this.ws.onclose = () => this._onSocketClosed();
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
// onclose fires after onerror — cleanup happens there.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The socket's close, split out of connect() so the tests can drive it.
|
|
||||||
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
|
||||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
|
||||||
// written now, it would sit above the output a pull is still holding (flushed
|
|
||||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
|
||||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
|
||||||
// response holds the marker too, for as long as the request takes (up to its
|
|
||||||
// budget, see _pullHistory()).
|
|
||||||
_onSocketClosed() {
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = true;
|
|
||||||
if (this._bufferLoading) this._markerOwed = true;
|
|
||||||
else this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
|
||||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
|
||||||
// a closed socket. Called from each load's own finally, just before
|
|
||||||
// _endBufferLoad() starts any trailing refresh.
|
|
||||||
_stampMarkerIfOwed() {
|
|
||||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
|
||||||
// a write() on a later tick: a marker written here would land in the
|
|
||||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
|
||||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
|
||||||
const owed = this._markerOwed;
|
|
||||||
this._markerOwed = false;
|
|
||||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
|
||||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
|
||||||
_writeDisconnectedMarker() {
|
|
||||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches and writes the session's current scrollback. Used both by
|
|
||||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
|
||||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
|
||||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
|
||||||
// terminal, neither of which applies to this independent pane, so this is
|
|
||||||
// a standalone equivalent rather than a call into it.
|
|
||||||
//
|
|
||||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
|
||||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
|
||||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
|
||||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
|
||||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
|
||||||
// get one full replay. `fetch` here goes through the global wrapper
|
|
||||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
|
||||||
// WebSocket URL above, which does not.
|
|
||||||
//
|
|
||||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
|
||||||
// (writeChunked resolves after its last chunk), so two replays can never
|
|
||||||
// interleave their chunks into one terminal. A second call while one is
|
|
||||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
|
||||||
// a trailing re-run instead.
|
|
||||||
async _loadBuffer() {
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
this._bufferLoading = true;
|
|
||||||
try {
|
|
||||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
|
||||||
const payload = (await res.json())?.data ?? {};
|
|
||||||
if (payload.terminalBuffer && this.terminal) {
|
|
||||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output still arrives once the socket connects. */
|
|
||||||
} finally {
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
||||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
||||||
_endBufferLoad() {
|
|
||||||
this._bufferLoading = false;
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) {
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live terminal output. Written straight through, except while a history
|
|
||||||
// pull is replaying: a capture is current only up to the instant tmux took
|
|
||||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
||||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
||||||
// _finishBufferLoad `since` rule), never written underneath it.
|
|
||||||
_onLiveOutput(data) {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
||||||
else this.terminal?.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
||||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
||||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
||||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
||||||
_onLiveClear() {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
||||||
else this.terminal?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
||||||
// event it consumes, so a bubbling listener here would never see the wheel
|
|
||||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
||||||
// xterm keeps doing the scrolling.
|
|
||||||
_installWheelListener() {
|
|
||||||
this._onWheel = (ev) => {
|
|
||||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
||||||
};
|
|
||||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
||||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
||||||
// so this pane's xterm ends up with about one screen of scrollback while
|
|
||||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
||||||
// history was unreachable. The primary pane has the same pull
|
|
||||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
||||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
||||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
||||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
||||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
||||||
// tmux the browser xterm never enters the alternate buffer.
|
|
||||||
_maybeLoadMoreHistory() {
|
|
||||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
||||||
// _sendResize(): a detached session's own window already owns its PTY
|
|
||||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const active = this.terminal.buffer.active;
|
|
||||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
||||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
||||||
// rather than latch; a pull that could only have downgraded the pane
|
|
||||||
// waits far longer.
|
|
||||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._historyPullAt < cooldown) return;
|
|
||||||
this._historyPullAt = now;
|
|
||||||
void this._pullHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
||||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
||||||
// main thread) and replays it under the reader's current place. Holds the
|
|
||||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
||||||
async _pullHistory() {
|
|
||||||
this._bufferLoading = true;
|
|
||||||
let replayed = false;
|
|
||||||
let capturedAt = 0;
|
|
||||||
// Two budgets on one signal. The request itself gets the primary pane's
|
|
||||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
|
||||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
|
||||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
|
||||||
// for that whole budget. Once the headers land live output IS held, so the
|
|
||||||
// body read gets the short one instead: a body that hangs would otherwise
|
|
||||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
|
||||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
|
||||||
// be re-armed, hence the controller; without AbortController the pull
|
|
||||||
// simply has no deadline.
|
|
||||||
const controller = global.AbortController ? new global.AbortController() : null;
|
|
||||||
let abortTimer = null;
|
|
||||||
const armDeadline = (ms) => {
|
|
||||||
if (!controller) return;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
||||||
// for the instant tmux took the capture, which lies somewhere in the
|
|
||||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
||||||
// by one round trip and not closable without a server-side capture time.
|
|
||||||
capturedAt = performance.now();
|
|
||||||
// Opened only now: a frame from before the response is either replaced by
|
|
||||||
// the capture or written unchanged, so holding it for the round trip
|
|
||||||
// bought nothing and froze the pane for as long as the fetch took.
|
|
||||||
this._liveQueue = [];
|
|
||||||
const payload = (await res.json())?.data;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const buffer = payload?.terminalBuffer;
|
|
||||||
const term = this.terminal;
|
|
||||||
if (!buffer || !term || this._destroyed) return;
|
|
||||||
const rowsBefore = term.buffer.active.length;
|
|
||||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
||||||
// lines, so a window of short lines can carry more rows than this pane
|
|
||||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
||||||
const scrollbackCap = term.options?.scrollback || 0;
|
|
||||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
||||||
// Nothing to gain (this also covers a downgrade, which would delete
|
|
||||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
||||||
// untruncated window IS all of tmux's history and the next burst can add
|
|
||||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
||||||
// what the pane shows, and every ask costs the server a capture-pane of
|
|
||||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
||||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
||||||
// off too, since no window can ever fit in it.
|
|
||||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
||||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
term.write('\x1bc');
|
|
||||||
replayed = true;
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
await writeChunked(term, buffer, () => this._destroyed);
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// xterm parses asynchronously: an empty write's callback fires only
|
|
||||||
// after everything before it, so the row count below is the settled one.
|
|
||||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
||||||
// rows down; land there and the recovered history sits above it.
|
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
||||||
else this.terminal.scrollToTop();
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
||||||
} finally {
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const queued = this._liveQueue ?? [];
|
|
||||||
this._liveQueue = null;
|
|
||||||
// After a replay, only frames that arrived after the capture are news;
|
|
||||||
// earlier ones are already in it. With no replay, every held frame is.
|
|
||||||
const cutoff = replayed ? capturedAt : 0;
|
|
||||||
for (const entry of queued) {
|
|
||||||
if (entry.at < cutoff) continue;
|
|
||||||
if (entry.clear) this.terminal?.clear();
|
|
||||||
else this.terminal?.write(entry.data);
|
|
||||||
}
|
|
||||||
// Settled after the queue flush so the marker is the last thing on
|
|
||||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
|
||||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
|
||||||
// one written before it, which would paint a fresh, current-looking
|
|
||||||
// history while onData keeps silently dropping every keystroke on the
|
|
||||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
|
||||||
// is left to that refresh, which writes it below its own replay.
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
|
||||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
|
||||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
|
||||||
// and no further frame is coming to correct stale content.
|
|
||||||
_refreshBuffer() {
|
|
||||||
if (this._bufferLoading) {
|
|
||||||
this._bufferRefreshPending = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.terminal?.clear();
|
|
||||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
|
||||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
|
||||||
// so a refresh on a closed socket owes it back once its replay is written.
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
void this._loadBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
|
||||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
|
||||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
|
||||||
// pane's own convention (throttledResize in terminal-ui.js).
|
|
||||||
localFit() {
|
|
||||||
if (!this.fitAddon) return;
|
|
||||||
this.fitAddon.fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fit() {
|
|
||||||
this.localFit();
|
|
||||||
this._sendResize();
|
|
||||||
}
|
|
||||||
|
|
||||||
_sendResize() {
|
|
||||||
if (!this._wsReady || !this.fitAddon) return;
|
|
||||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
|
||||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
|
||||||
// to its own window AFTER this split was opened, so its own window now
|
|
||||||
// owns the PTY's size and Pane B must stand aside.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const dims = this.fitAddon.proposeDimensions();
|
|
||||||
if (!dims) return;
|
|
||||||
// Send the real proposed dimensions unclamped, matching the primary
|
|
||||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
||||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
||||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
||||||
// divider's own reachable 20% floor position, causing real
|
|
||||||
// output-wrapping bugs.
|
|
||||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._destroyed = true;
|
|
||||||
if (this._onWheel) {
|
|
||||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
// onclose fires asynchronously AFTER close(); without this it ran
|
|
||||||
// its "disconnected" write against a pane already torn down.
|
|
||||||
this.ws.onclose = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.dispose();
|
|
||||||
this.terminal = null;
|
|
||||||
}
|
|
||||||
this.fitAddon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.SplitTerminalPane = SplitTerminalPane;
|
|
||||||
})(window);
|
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||||
@@ -777,7 +153,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// B's own session tab while split can otherwise land here with
|
// B's own session tab while split can otherwise land here with
|
||||||
// sessionId === activeSessionId: two live WebSockets to the same
|
// sessionId === activeSessionId: two live WebSockets to the same
|
||||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
// resize frame. Refuse before creating any DOM or TerminalTile.
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.activeSessionId) return;
|
||||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||||
// re-applied here: the menu can sit open while a listed session's CLI
|
// re-applied here: the menu can sit open while a listed session's CLI
|
||||||
@@ -818,7 +194,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.appendChild(paneB);
|
container.appendChild(paneB);
|
||||||
paneB.style.flexBasis = '50%';
|
paneB.style.flexBasis = '50%';
|
||||||
|
|
||||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||||
mode: session?.mode,
|
mode: session?.mode,
|
||||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||||
detachedSessions: this.detachedSessions,
|
detachedSessions: this.detachedSessions,
|
||||||
@@ -900,7 +276,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// frame). Coalesced to one call per animation frame below — a raw
|
// frame). Coalesced to one call per animation frame below — a raw
|
||||||
// mousemove stream fires far faster than the browser repaints, and
|
// mousemove stream fires far faster than the browser repaints, and
|
||||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
// panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has
|
||||||
// no client-side "dims unchanged" skip), which fanned out into a
|
// no client-side "dims unchanged" skip), which fanned out into a
|
||||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||||
// of each dragging across half a wide viewport.
|
// of each dragging across half a wide viewport.
|
||||||
|
|||||||
@@ -0,0 +1,887 @@
|
|||||||
|
// src/web/public/terminal-tile.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview TerminalTile: one independent live terminal pane bound to one
|
||||||
|
* session, with its own xterm instance and its own
|
||||||
|
* `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js)
|
||||||
|
* uses one as its second pane ("Pane B"); the tile grid planned in
|
||||||
|
* docs/tile-grid-plan.md reuses the same class for every tile.
|
||||||
|
*
|
||||||
|
* Deliberately plainer than the primary pane (this.terminal/this._ws in
|
||||||
|
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
|
||||||
|
* handlers, no keyboard accessory bar. Desktop-only by nature; see
|
||||||
|
* docs/split-pane-sessions-plan.md.
|
||||||
|
*
|
||||||
|
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||||
|
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||||
|
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||||
|
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||||
|
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||||
|
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||||
|
* giant synchronous write that blocks the main thread while parsing a long
|
||||||
|
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||||
|
* (terminal-ui.js): that one is wired into session-switch generation
|
||||||
|
* counters and the live-output gate this simpler, independently
|
||||||
|
* created/destroyed pane has no equivalent of.
|
||||||
|
*/
|
||||||
|
function writeChunked(terminal, buffer, isDestroyed) {
|
||||||
|
if (!buffer) return Promise.resolve();
|
||||||
|
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||||
|
terminal.write(buffer);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||||
|
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||||
|
// across the whole replay rather than just the fetch that precedes it.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let offset = 0;
|
||||||
|
const writeNext = () => {
|
||||||
|
if (isDestroyed() || !terminal) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||||
|
offset += chunk.length;
|
||||||
|
terminal.write(chunk);
|
||||||
|
if (offset < buffer.length) {
|
||||||
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||||
|
else setTimeout(writeNext, 16);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
writeNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TerminalTile {
|
||||||
|
constructor(sessionId, mountEl, opts = {}) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.mountEl = mountEl;
|
||||||
|
this.sessionMode = opts.mode;
|
||||||
|
this.fontSettings = opts.fontSettings || {};
|
||||||
|
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||||
|
// detaching this session AFTER the split is already open must still be
|
||||||
|
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||||
|
// fight the split picker already refuses to open at pick time.
|
||||||
|
this.detachedSessions = opts.detachedSessions;
|
||||||
|
this.terminal = null;
|
||||||
|
this.fitAddon = null;
|
||||||
|
this.ws = null;
|
||||||
|
this._wsReady = false;
|
||||||
|
this._wsClosed = false;
|
||||||
|
this._destroyed = false;
|
||||||
|
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||||
|
this._bufferLoading = false;
|
||||||
|
this._bufferRefreshPending = false;
|
||||||
|
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||||
|
// `_liveQueue` is non-null from the pull's response until its finally
|
||||||
|
// block: live frames are held there with their arrival time instead of
|
||||||
|
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
||||||
|
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
||||||
|
this._historyPullAt = 0;
|
||||||
|
this._historyPullUseless = false;
|
||||||
|
this._liveQueue = null;
|
||||||
|
this._markerOwed = false;
|
||||||
|
this._onWheel = null;
|
||||||
|
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while
|
||||||
|
// this pane's socket is open, so the exactly-once input queue delivers this
|
||||||
|
// session's keystrokes over it (app.js _inputSocketFor). Null otherwise.
|
||||||
|
this._inputHandle = null;
|
||||||
|
// Reconnect state. `_socketUrl` is set once connect() opens the first
|
||||||
|
// socket: a pane that never connected has nothing to reconnect to.
|
||||||
|
// `_reconnectAttempts` counts consecutive failed opens and is reset ONLY by
|
||||||
|
// a successful open (resetting it per attempt is the tight-loop bug the
|
||||||
|
// primary pane's _disconnectWs documents). `_stoppedCode` is the close code
|
||||||
|
// that ended the pane for good; `onExit(code)` tells the owner once.
|
||||||
|
this._socketUrl = null;
|
||||||
|
this._reconnectAttempts = 0;
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._stoppedCode = null;
|
||||||
|
this._markerText = TerminalTile.MARKER_RECONNECTING;
|
||||||
|
this.onExit = typeof opts.onExit === 'function' ? opts.onExit : null;
|
||||||
|
// The `{ cols, rows }` last sent in a `{t:'z'}` frame, so an unchanged size
|
||||||
|
// is not resent (each one costs a `tmux resize-window` and a SIGWINCH).
|
||||||
|
// Cleared on every open: a fresh socket must announce its size, which is
|
||||||
|
// also what registers it as a desktop viewer server-side.
|
||||||
|
this._lastSentDims = null;
|
||||||
|
// Whether the pointer is over a link in THIS pane (the primary pane's own
|
||||||
|
// flag, app._linkHovered, belongs to its terminal alone).
|
||||||
|
this._linkHovered = false;
|
||||||
|
this._onFocusIn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||||
|
this.terminal = new Terminal({
|
||||||
|
theme: { ...global.codemanCurrentXtermTheme() },
|
||||||
|
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||||
|
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||||
|
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
cursorBlink: false,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||||
|
scrollback: DEFAULT_SCROLLBACK,
|
||||||
|
allowTransparency: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fitAddon = new FitAddon.FitAddon();
|
||||||
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
|
this.terminal.open(this.mountEl);
|
||||||
|
this.fitAddon.fit();
|
||||||
|
|
||||||
|
// File paths and URLs printed here are clickable, through the SAME
|
||||||
|
// provider as the primary pane (registerFilePathLinkProvider,
|
||||||
|
// terminal-ui.js), and open against THIS pane's session.
|
||||||
|
global.app?.registerFilePathLinkProvider?.({
|
||||||
|
terminal: this.terminal,
|
||||||
|
getSessionId: () => this.sessionId,
|
||||||
|
setHovered: (hovered) => {
|
||||||
|
this._linkHovered = hovered;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._installWheelListener();
|
||||||
|
|
||||||
|
// Focusing this terminal makes it the pane the keyboard is in, so the
|
||||||
|
// app-level shortcuts, voice and paste act on it (app._focusedPane).
|
||||||
|
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
|
||||||
|
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
|
||||||
|
|
||||||
|
this.terminal.onData((data) => this._onTerminalData(data));
|
||||||
|
|
||||||
|
// Pane B has no gates of its own by default, so every app-level chord
|
||||||
|
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||||
|
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||||
|
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||||
|
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||||
|
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||||
|
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||||
|
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||||
|
// registry-aware predicates so a rebind or a disable restores plain
|
||||||
|
// terminal behavior here too. Ctrl+V goes through the primary pane's
|
||||||
|
// paste trap (image-input.js), aimed at this pane (below).
|
||||||
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
|
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||||
|
if (
|
||||||
|
ev.altKey &&
|
||||||
|
!ev.ctrlKey &&
|
||||||
|
!ev.shiftKey &&
|
||||||
|
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||||
|
// a pasted image uploads to this pane's session and its path is typed
|
||||||
|
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||||
|
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
|
||||||
|
// xterm's default only ever pasted text.
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||||
|
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||||
|
// swallow — in a plain shell session this is the user's own
|
||||||
|
// job-control tool and must reach the PTY, but in every other mode
|
||||||
|
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||||
|
// loop dead. Pane B has its own PTY/session and must not send a
|
||||||
|
// suspend into a non-shell one just because the primary pane's own
|
||||||
|
// gate lives elsewhere.
|
||||||
|
if (
|
||||||
|
ev.type === 'keydown' &&
|
||||||
|
ev.key.toLowerCase() === 'z' &&
|
||||||
|
ev.ctrlKey &&
|
||||||
|
!ev.altKey &&
|
||||||
|
!ev.metaKey &&
|
||||||
|
!ev.shiftKey &&
|
||||||
|
this.sessionMode !== 'shell'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||||
|
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||||
|
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||||
|
// newline from a submit. Without this gate, Pane B's onData would
|
||||||
|
// send that bare \r straight over the WS and submit an incomplete
|
||||||
|
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||||
|
// session (this.sessionId), never the primary pane's
|
||||||
|
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||||
|
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||||
|
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
||||||
|
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||||
|
if (ev.type === 'keydown') {
|
||||||
|
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||||
|
}).catch(() => {
|
||||||
|
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||||
|
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||||
|
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||||
|
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||||
|
// explicit, never-falls-through copy chord, and the predicate above
|
||||||
|
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||||
|
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||||
|
// a branch that assigns c.key only for a couple of special cases
|
||||||
|
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||||
|
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||||
|
// interrupt byte reaching the PTY (verified live: it does not).
|
||||||
|
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||||
|
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||||
|
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||||
|
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||||
|
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||||
|
// Re-implemented against this.terminal rather than reusing
|
||||||
|
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||||
|
// and would copy the wrong pane's selection.
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||||
|
const raw = this.terminal?.getSelection?.() || '';
|
||||||
|
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||||
|
// Both clean options are read for THIS pane, never the primary one:
|
||||||
|
// the gutter width comes from this.sessionId's own run mode, and the
|
||||||
|
// partial-first-line flag from this terminal's own selection range.
|
||||||
|
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
||||||
|
// same split and the same keystroke.
|
||||||
|
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
||||||
|
const selection = isColumnSelection
|
||||||
|
? raw
|
||||||
|
: (global.CodemanCopySelection?.clean?.(raw, {
|
||||||
|
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
||||||
|
firstLinePartial: !!range && range.start.x > 0,
|
||||||
|
}) ?? raw);
|
||||||
|
if (selection.trim()) {
|
||||||
|
ev.preventDefault();
|
||||||
|
void global.app._copyText?.(selection).then((ok) => {
|
||||||
|
this.terminal?.clearSelection?.();
|
||||||
|
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Nothing worth copying — clear for feedback (a padding-only
|
||||||
|
// selection cleans to '' and this press still falls through to the
|
||||||
|
// PTY as 0x03, matching the primary pane's own rule).
|
||||||
|
if (this.terminal?.hasSelection?.()) {
|
||||||
|
this.terminal.clearSelection?.();
|
||||||
|
global.app.showToast?.('Nothing to copy', 'warning');
|
||||||
|
}
|
||||||
|
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||||
|
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load existing scrollback before going live. The WS below is
|
||||||
|
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||||
|
// 'terminal' events), so without this Pane B stays blank until the
|
||||||
|
// target session happens to produce new output. It LOOKED
|
||||||
|
// intermittent rather than always-broken because _sendResize() below
|
||||||
|
// often nudges the shared session's real tmux window to a new size,
|
||||||
|
// and tmux repaints its current screen on resize — that repaint was
|
||||||
|
// getting captured and streamed here, incidentally populating the
|
||||||
|
// pane. When Pane B's computed dimensions happened to already match
|
||||||
|
// the session's last-known size, Session.resize() (session.ts) skips
|
||||||
|
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||||
|
// The await covers the whole chunked replay, not just the fetch, so a
|
||||||
|
// live frame from the socket below can never land in the middle of it.
|
||||||
|
await this._loadBuffer();
|
||||||
|
if (this._destroyed) return;
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
// The tab's own connection identity plus a `:tile` suffix. The server
|
||||||
|
// supersedes a socket that reuses a cid on the same session (4010), so a
|
||||||
|
// pane must never share the primary pane's exact cid: were both ever on
|
||||||
|
// one session they would evict each other in a loop. Input frames still
|
||||||
|
// carry the BARE clientId, which is what the server dedups on.
|
||||||
|
const app = global.app;
|
||||||
|
const cid = app?._clientId ? `${app._clientId}:${app._wsTabNonce}:tile` : '';
|
||||||
|
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||||
|
this._socketUrl = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal${cidQuery}`;
|
||||||
|
this._openSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a socket and makes it THE socket. A previous one is detached first
|
||||||
|
// (handlers nulled, then closed), and every handler below checks it still
|
||||||
|
// belongs to the current socket: a replacement opened while the old socket
|
||||||
|
// still looked alive (a half-open connection whose close has not landed)
|
||||||
|
// makes the server supersede the old one with a 4010, and that late close
|
||||||
|
// must not stop a pane that is already running on its successor.
|
||||||
|
_openSocket() {
|
||||||
|
if (this._destroyed || !this._socketUrl) return;
|
||||||
|
this._detachSocket();
|
||||||
|
const ws = new WebSocket(this._socketUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
this._onSocketOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
if (this._inputHandle) this._inputHandle.lastRecvAt = Date.now();
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.t === 'o') {
|
||||||
|
this._onLiveOutput(msg.d);
|
||||||
|
} else if (msg.t === 'c') {
|
||||||
|
this._onLiveClear();
|
||||||
|
} else if (msg.t === 'r') {
|
||||||
|
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||||
|
// data was dropped). The primary pane routes this to
|
||||||
|
// _onSessionNeedsRefresh (app.js) — Pane B has its own
|
||||||
|
// buffer loader for the same reason connect() does.
|
||||||
|
this._refreshBuffer();
|
||||||
|
} else if (msg.t === 'ia') {
|
||||||
|
// Input ACK. The frame names no session, so it is this pane's.
|
||||||
|
global.app?._onWsInputAck?.(msg.seq, msg, this.sessionId);
|
||||||
|
} else if (msg.t === 'zc') {
|
||||||
|
this._onPtyGeometryReport(msg.c, msg.r);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// _wsReady must go false on a drop or fit()/_sendResize() silently
|
||||||
|
// no-op on a closed socket per the WebSocket spec (no exception, no log).
|
||||||
|
// Input is not lost meanwhile: it waits in the app's durable queue and
|
||||||
|
// goes out over HTTP or the next socket. The "disconnected" marker says
|
||||||
|
// so on screen, and a transient close reconnects (_onSocketClosed).
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
this._onSocketClosed(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onclose fires after onerror — cleanup happens there.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets go of the current socket without running its close handling.
|
||||||
|
_detachSocket() {
|
||||||
|
const ws = this.ws;
|
||||||
|
if (!ws) return;
|
||||||
|
ws.onopen = null;
|
||||||
|
ws.onmessage = null;
|
||||||
|
// onclose fires asynchronously AFTER close(); without this it ran its
|
||||||
|
// "disconnected" write against a pane already torn down or replaced.
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.onerror = null;
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* Already closed. */
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
this._wsReady = false;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A socket came up. After a drop this is a reconnect: the gap left nothing
|
||||||
|
// to replay (output frames carry no sequence number), so the buffer is
|
||||||
|
// refreshed. The closed state is reset FIRST, or the refresh would re-owe
|
||||||
|
// the "disconnected" marker (_refreshBuffer does on a closed socket) and
|
||||||
|
// stamp it under a healthy pane.
|
||||||
|
_onSocketOpen() {
|
||||||
|
const reconnected = this._wsClosed;
|
||||||
|
this._wsReady = true;
|
||||||
|
this._wsClosed = false;
|
||||||
|
this._markerOwed = false;
|
||||||
|
this._reconnectAttempts = 0;
|
||||||
|
this._lastSentDims = null;
|
||||||
|
this._registerInputSocket();
|
||||||
|
this._sendResize();
|
||||||
|
if (reconnected) this._refreshBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a replacement socket now instead of waiting out the backoff (for an
|
||||||
|
// owner that just learned the server is back). No-op while the current
|
||||||
|
// socket is open, after a permanent stop, or once destroyed.
|
||||||
|
reconnectNow() {
|
||||||
|
if (this._destroyed || this._stoppedCode !== null || !this._socketUrl) return;
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._openSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket's close, split out of connect() so the tests can drive it.
|
||||||
|
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
||||||
|
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
||||||
|
// written now, it would sit above the output a pull is still holding (flushed
|
||||||
|
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
||||||
|
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
||||||
|
// response holds the marker too, for as long as the request takes (up to its
|
||||||
|
// budget, see _pullHistory()).
|
||||||
|
//
|
||||||
|
// Then decides what comes next. Codes that cannot get better stop the pane
|
||||||
|
// for good and report once through `onExit(code)`: 4004/4009 (the session
|
||||||
|
// is gone), 4003 (refused: Host/Origin/owner, a retry gets the same answer)
|
||||||
|
// and 4010 (another socket with this pane's cid took over; only ever
|
||||||
|
// reaches here for the CURRENT socket, see _openSocket). Everything else,
|
||||||
|
// including the redelivery sweep force-closing a silent socket (1005), is
|
||||||
|
// transient and reconnects on the primary pane's backoff ladder
|
||||||
|
// (CodemanWsReconnect, constants.js) plus jitter.
|
||||||
|
_onSocketClosed(event) {
|
||||||
|
this._wsReady = false;
|
||||||
|
this._wsClosed = true;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
const code = event?.code;
|
||||||
|
const permanent = TerminalTile.STOP_MARKERS[code];
|
||||||
|
this._markerText = permanent || TerminalTile.MARKER_RECONNECTING;
|
||||||
|
if (this._bufferLoading) this._markerOwed = true;
|
||||||
|
else this._writeDisconnectedMarker();
|
||||||
|
if (this._destroyed) return;
|
||||||
|
if (permanent) {
|
||||||
|
this._stop(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._scheduleReconnect(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleReconnect(code) {
|
||||||
|
if (this._destroyed || !this._socketUrl || this._reconnectTimer) return;
|
||||||
|
const plan = global.CodemanWsReconnect?.plan?.(code ?? 1006, this._reconnectAttempts) || {
|
||||||
|
action: 'reconnect',
|
||||||
|
delayMs: 1000,
|
||||||
|
};
|
||||||
|
if (plan.action === 'give-up') {
|
||||||
|
this._stop(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._reconnectAttempts++;
|
||||||
|
const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter: tiles must not reconnect in lockstep
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._openSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stop(code) {
|
||||||
|
if (this._stoppedCode !== null) return;
|
||||||
|
this._stoppedCode = code ?? null;
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
if (!this._destroyed) this.onExit?.(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keystrokes and pastes go through the app's exactly-once input queue (seq,
|
||||||
|
// ACK, persisted until delivered, redelivered after a drop), over this
|
||||||
|
// pane's own socket while it is open and the HTTP fallback while it is not.
|
||||||
|
// What xterm GENERATES must not be queued: a query reply (DA/CPR/OSC) is
|
||||||
|
// dropped, as the primary pane drops it, because forwarding it types
|
||||||
|
// "0;276;0c" into the CLI, and replaying one after a reload would do so
|
||||||
|
// again into a later screen. A focus or mouse report is real input the
|
||||||
|
// program asked for, but nobody typed it: it goes out once, never
|
||||||
|
// persisted. Same predicates as the primary pane (terminal-ui.js onData).
|
||||||
|
_onTerminalData(data) {
|
||||||
|
const input = global.CodemanTerminalInput;
|
||||||
|
if (input?.shouldSuppressTerminalQueryResponse?.(data)) return;
|
||||||
|
const app = global.app;
|
||||||
|
if (input?.isTerminalFocusOrMouseReport?.(data)) {
|
||||||
|
app?._sendInputEphemeral?.(this.sessionId, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app?._sendInputAsync?.(this.sessionId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joins the app's input-socket map for this session and flushes anything
|
||||||
|
// already queued for it (typed while the socket was down, or left over from
|
||||||
|
// a reload) over the fresh socket. Called from onopen.
|
||||||
|
_registerInputSocket() {
|
||||||
|
const app = global.app;
|
||||||
|
if (!this.ws || !app?._registerInputSocket) return;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
this._inputHandle = { ws: this.ws, lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket(this.sessionId, this._inputHandle);
|
||||||
|
app._onWsReady?.(this.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves the map; only this pane's own handle is removed (a replacement
|
||||||
|
// socket's registration survives a late close of the old one).
|
||||||
|
_unregisterInputSocket() {
|
||||||
|
if (!this._inputHandle) return;
|
||||||
|
global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle);
|
||||||
|
this._inputHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settles a marker the pane owes: set when a close lands during a load (the
|
||||||
|
// replay would otherwise sit below it) or when a load wipes the terminal on
|
||||||
|
// a closed socket. Called from each load's own finally, just before
|
||||||
|
// _endBufferLoad() starts any trailing refresh.
|
||||||
|
_stampMarkerIfOwed() {
|
||||||
|
// A trailing refresh is about to clear() synchronously, while xterm parses
|
||||||
|
// a write() on a later tick: a marker written here would land in the
|
||||||
|
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
||||||
|
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
||||||
|
if (this._bufferRefreshPending && !this._destroyed) return;
|
||||||
|
const owed = this._markerOwed;
|
||||||
|
this._markerOwed = false;
|
||||||
|
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
||||||
|
// closed socket can write it (see _stampMarkerIfOwed()).
|
||||||
|
_writeDisconnectedMarker() {
|
||||||
|
this.terminal?.write(`\r\n\x1b[2m${this._markerText}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches and writes the session's current scrollback. Used both by
|
||||||
|
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||||
|
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||||
|
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||||
|
// terminal, neither of which applies to this independent pane, so this is
|
||||||
|
// a standalone equivalent rather than a call into it.
|
||||||
|
//
|
||||||
|
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||||
|
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||||
|
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||||
|
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||||
|
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||||
|
// get one full replay. `fetch` here goes through the global wrapper
|
||||||
|
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||||
|
// WebSocket URL above, which does not.
|
||||||
|
//
|
||||||
|
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||||
|
// (writeChunked resolves after its last chunk), so two replays can never
|
||||||
|
// interleave their chunks into one terminal. A second call while one is
|
||||||
|
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||||
|
// a trailing re-run instead.
|
||||||
|
async _loadBuffer() {
|
||||||
|
if (this._bufferLoading) return;
|
||||||
|
this._bufferLoading = true;
|
||||||
|
try {
|
||||||
|
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||||
|
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||||
|
const payload = (await res.json())?.data ?? {};
|
||||||
|
if (payload.terminalBuffer && this.terminal) {
|
||||||
|
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Best-effort — live output still arrives once the socket connects. */
|
||||||
|
} finally {
|
||||||
|
this._stampMarkerIfOwed();
|
||||||
|
this._endBufferLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||||
|
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||||
|
_endBufferLoad() {
|
||||||
|
this._bufferLoading = false;
|
||||||
|
if (this._bufferRefreshPending && !this._destroyed) {
|
||||||
|
this._bufferRefreshPending = false;
|
||||||
|
this._refreshBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live terminal output. Written straight through, except while a history
|
||||||
|
// pull is replaying: a capture is current only up to the instant tmux took
|
||||||
|
// it, so a frame arriving mid-replay is held with its arrival time and
|
||||||
|
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
||||||
|
// _finishBufferLoad `since` rule), never written underneath it.
|
||||||
|
_onLiveOutput(data) {
|
||||||
|
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||||
|
else this.terminal?.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||||
|
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||||
|
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||||
|
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||||
|
_onLiveClear() {
|
||||||
|
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||||
|
else this.terminal?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||||
|
// event it consumes, so a bubbling listener here would never see the wheel
|
||||||
|
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||||
|
// xterm keeps doing the scrolling.
|
||||||
|
_installWheelListener() {
|
||||||
|
this._onWheel = (ev) => {
|
||||||
|
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||||
|
};
|
||||||
|
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||||
|
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||||
|
// so this pane's xterm ends up with about one screen of scrollback while
|
||||||
|
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||||
|
// history was unreachable. The primary pane has the same pull
|
||||||
|
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||||
|
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||||
|
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||||
|
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||||
|
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||||
|
// tmux the browser xterm never enters the alternate buffer.
|
||||||
|
_maybeLoadMoreHistory() {
|
||||||
|
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||||
|
if (this._bufferLoading) return;
|
||||||
|
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||||
|
// _sendResize(): a detached session's own window already owns its PTY
|
||||||
|
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||||
|
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||||
|
const active = this.terminal.buffer.active;
|
||||||
|
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||||
|
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||||
|
// rather than latch; a pull that could only have downgraded the pane
|
||||||
|
// waits far longer.
|
||||||
|
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this._historyPullAt < cooldown) return;
|
||||||
|
this._historyPullAt = now;
|
||||||
|
void this._pullHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||||
|
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||||
|
// main thread) and replays it under the reader's current place. Holds the
|
||||||
|
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||||
|
async _pullHistory() {
|
||||||
|
this._bufferLoading = true;
|
||||||
|
let replayed = false;
|
||||||
|
let capturedAt = 0;
|
||||||
|
// Two budgets on one signal. The request itself gets the primary pane's
|
||||||
|
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
||||||
|
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
||||||
|
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
||||||
|
// for that whole budget. Once the headers land live output IS held, so the
|
||||||
|
// body read gets the short one instead: a body that hangs would otherwise
|
||||||
|
// freeze the pane for the long budget. Aborting lands in the catch below,
|
||||||
|
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
||||||
|
// be re-armed, hence the controller; without AbortController the pull
|
||||||
|
// simply has no deadline.
|
||||||
|
const controller = global.AbortController ? new global.AbortController() : null;
|
||||||
|
let abortTimer = null;
|
||||||
|
const armDeadline = (ms) => {
|
||||||
|
if (!controller) return;
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
abortTimer = setTimeout(() => controller.abort(), ms);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
||||||
|
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||||
|
signal: controller?.signal,
|
||||||
|
});
|
||||||
|
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
||||||
|
// The cutoff below is the response's arrival, the same `since` rule the
|
||||||
|
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||||
|
// for the instant tmux took the capture, which lies somewhere in the
|
||||||
|
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||||
|
// by one round trip and not closable without a server-side capture time.
|
||||||
|
capturedAt = performance.now();
|
||||||
|
// Opened only now: a frame from before the response is either replaced by
|
||||||
|
// the capture or written unchanged, so holding it for the round trip
|
||||||
|
// bought nothing and froze the pane for as long as the fetch took.
|
||||||
|
this._liveQueue = [];
|
||||||
|
const payload = (await res.json())?.data;
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
const buffer = payload?.terminalBuffer;
|
||||||
|
const term = this.terminal;
|
||||||
|
if (!buffer || !term || this._destroyed) return;
|
||||||
|
const rowsBefore = term.buffer.active.length;
|
||||||
|
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||||
|
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||||
|
// lines, so a window of short lines can carry more rows than this pane
|
||||||
|
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||||
|
const scrollbackCap = term.options?.scrollback || 0;
|
||||||
|
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||||
|
// Nothing to gain (this also covers a downgrade, which would delete
|
||||||
|
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||||
|
// untruncated window IS all of tmux's history and the next burst can add
|
||||||
|
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||||
|
// what the pane shows, and every ask costs the server a capture-pane of
|
||||||
|
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||||
|
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||||
|
// off too, since no window can ever fit in it.
|
||||||
|
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||||
|
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._historyPullUseless = false;
|
||||||
|
term.write('\x1bc');
|
||||||
|
replayed = true;
|
||||||
|
if (this._wsClosed) this._markerOwed = true;
|
||||||
|
await writeChunked(term, buffer, () => this._destroyed);
|
||||||
|
if (this._destroyed || !this.terminal) return;
|
||||||
|
// xterm parses asynchronously: an empty write's callback fires only
|
||||||
|
// after everything before it, so the row count below is the settled one.
|
||||||
|
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||||
|
if (this._destroyed || !this.terminal) return;
|
||||||
|
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||||
|
// rows down; land there and the recovered history sits above it.
|
||||||
|
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||||
|
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||||
|
else this.terminal.scrollToTop();
|
||||||
|
} catch {
|
||||||
|
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||||
|
} finally {
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
const queued = this._liveQueue ?? [];
|
||||||
|
this._liveQueue = null;
|
||||||
|
// After a replay, only frames that arrived after the capture are news;
|
||||||
|
// earlier ones are already in it. With no replay, every held frame is.
|
||||||
|
const cutoff = replayed ? capturedAt : 0;
|
||||||
|
for (const entry of queued) {
|
||||||
|
if (entry.at < cutoff) continue;
|
||||||
|
if (entry.clear) this.terminal?.clear();
|
||||||
|
else this.terminal?.write(entry.data);
|
||||||
|
}
|
||||||
|
// Settled after the queue flush so the marker is the last thing on
|
||||||
|
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
||||||
|
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
||||||
|
// one written before it, which would paint a fresh, current-looking
|
||||||
|
// history while onData keeps silently dropping every keystroke on the
|
||||||
|
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
||||||
|
// is left to that refresh, which writes it below its own replay.
|
||||||
|
this._stampMarkerIfOwed();
|
||||||
|
this._endBufferLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||||
|
// frames in a row used to start two concurrent replays, each clearing
|
||||||
|
// the terminal under the other's chunked write. A refresh that arrives
|
||||||
|
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||||
|
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||||
|
// and no further frame is coming to correct stale content.
|
||||||
|
_refreshBuffer() {
|
||||||
|
if (this._bufferLoading) {
|
||||||
|
this._bufferRefreshPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.terminal?.clear();
|
||||||
|
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
||||||
|
// trailing refresh behind a pull that the socket's close then interrupts),
|
||||||
|
// so a refresh on a closed socket owes it back once its replay is written.
|
||||||
|
if (this._wsClosed) this._markerOwed = true;
|
||||||
|
void this._loadBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||||
|
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||||
|
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||||
|
// pane's own convention (throttledResize in terminal-ui.js).
|
||||||
|
localFit() {
|
||||||
|
if (!this.fitAddon) return;
|
||||||
|
this.fitAddon.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflow to the container and tell the PTY, as one step: the xterm and the
|
||||||
|
// PTY must never disagree about size (#464), and a font change is a size
|
||||||
|
// change too, so the font setters call this rather than localFit().
|
||||||
|
// `force` resends an unchanged size.
|
||||||
|
fit({ force = false } = {}) {
|
||||||
|
this.localFit();
|
||||||
|
this._sendResize({ force });
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendResize({ force = false } = {}) {
|
||||||
|
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
|
||||||
|
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||||
|
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||||
|
// to its own window AFTER this split was opened, so its own window now
|
||||||
|
// owns the PTY's size and Pane B must stand aside.
|
||||||
|
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||||
|
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
|
||||||
|
// fit() then leaves the xterm alone: there is no size worth reporting.
|
||||||
|
const dims = this.fitAddon.proposeDimensions();
|
||||||
|
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
|
||||||
|
// Report what the xterm actually holds, so the PTY gets exactly the size
|
||||||
|
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
|
||||||
|
// a floor here misreported Pane B's width at the divider's reachable 20%
|
||||||
|
// position (about 28 columns), causing real output-wrapping bugs, and a
|
||||||
|
// floored xterm would be wider than its container. The server enforces
|
||||||
|
// its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||||
|
const cols = this.terminal.cols;
|
||||||
|
const rows = this.terminal.rows;
|
||||||
|
const last = this._lastSentDims;
|
||||||
|
if (!force && last && last.cols === cols && last.rows === rows) return;
|
||||||
|
this._lastSentDims = { cols, rows };
|
||||||
|
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to
|
||||||
|
// every resize). A PTY and a terminal that disagree on WIDTH render
|
||||||
|
// garbled, so a different column count is adopted; rows stay local, as in
|
||||||
|
// the primary pane (_onPtyGeometryReport in terminal-ui.js, #464). The
|
||||||
|
// pure verdict is the primary's too (reconcilePtyGeometry, constants.js).
|
||||||
|
_onPtyGeometryReport(cols, rows) {
|
||||||
|
const terminal = this.terminal;
|
||||||
|
if (!terminal) return;
|
||||||
|
const verdict = global.CodemanTerminalGeometry?.reconcilePtyGeometry?.(
|
||||||
|
{ cols: terminal.cols, rows: terminal.rows },
|
||||||
|
{ cols, rows }
|
||||||
|
);
|
||||||
|
if (!verdict?.adopt) return;
|
||||||
|
terminal.resize(verdict.cols, terminal.rows);
|
||||||
|
this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._destroyed = true;
|
||||||
|
// Anything still queued for this session stays in the app's queue and is
|
||||||
|
// delivered over HTTP by the redelivery sweep, so closing the pane mid-
|
||||||
|
// keystroke loses nothing.
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
if (this._onWheel) {
|
||||||
|
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||||
|
this._onWheel = null;
|
||||||
|
}
|
||||||
|
this._detachSocket();
|
||||||
|
if (this._onFocusIn) {
|
||||||
|
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||||
|
this._onFocusIn = null;
|
||||||
|
}
|
||||||
|
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
|
||||||
|
// primary terminal (_focusedPane also skips a destroyed tile on its own).
|
||||||
|
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
|
||||||
|
if (this.terminal) {
|
||||||
|
this.terminal.dispose();
|
||||||
|
this.terminal = null;
|
||||||
|
}
|
||||||
|
this.fitAddon = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marker a pane writes when its socket drops: a transient drop says it is
|
||||||
|
// reconnecting; a permanent stop says why, keyed by close code. All start
|
||||||
|
// with `[disconnected` so a reader (and a test) can tell any of them apart
|
||||||
|
// from session output.
|
||||||
|
TerminalTile.MARKER_RECONNECTING = '[disconnected, reconnecting…]';
|
||||||
|
TerminalTile.STOP_MARKERS = {
|
||||||
|
4003: '[disconnected: the server refused this connection]',
|
||||||
|
4004: '[disconnected: the session ended]',
|
||||||
|
4009: '[disconnected: the session ended]',
|
||||||
|
4010: '[disconnected: another connection took over this pane]',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.TerminalTile = TerminalTile;
|
||||||
|
})(window);
|
||||||
+113
-42
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
this._installShiftDragSelection();
|
this._installShiftDragSelection();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
// Focus coming back to the primary terminal ends a second pane's claim on
|
||||||
|
// the keyboard (see _focusedPane).
|
||||||
|
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
|
||||||
|
|
||||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||||
@@ -1416,7 +1419,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// without this call Pane B never learned about a window resize, an
|
// without this call Pane B never learned about a window resize, an
|
||||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||||
// stayed at whatever size it was last dragged to.
|
// stayed at whatever size it was last dragged to.
|
||||||
this._splitPane?.fit();
|
this._forEachTile?.((tile) => tile.fit());
|
||||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1827,9 +1830,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Register a custom link provider for xterm.js that detects file paths
|
* Register a custom link provider for xterm.js that detects file paths
|
||||||
* in terminal output and makes them clickable.
|
* in terminal output and makes them clickable.
|
||||||
* When clicked, opens a floating log viewer window with live streaming.
|
* When clicked, opens a floating log viewer window with live streaming.
|
||||||
|
*
|
||||||
|
* `target` defaults to the primary terminal and the active session. A second
|
||||||
|
* terminal (the split pane) passes its own `{ terminal, getSessionId,
|
||||||
|
* setHovered }`, so a path printed there opens against THAT pane's session and
|
||||||
|
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
|
||||||
|
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
|
||||||
|
* Returns the provider.
|
||||||
*/
|
*/
|
||||||
registerFilePathLinkProvider() {
|
registerFilePathLinkProvider(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const getSessionId = target.getSessionId || (() => this.activeSessionId);
|
||||||
|
const setHovered =
|
||||||
|
target.setHovered ||
|
||||||
|
((hovered) => {
|
||||||
|
this._linkHovered = hovered;
|
||||||
|
});
|
||||||
|
const isPrimary = terminal === this.terminal;
|
||||||
|
|
||||||
// Debug: Track if provider is being invoked
|
// Debug: Track if provider is being invoked
|
||||||
let lastInvokedLine = -1;
|
let lastInvokedLine = -1;
|
||||||
@@ -1842,7 +1860,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = self.terminal.buffer.active;
|
const buffer = terminal.buffer.active;
|
||||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||||
const line = buffer.getLine(bufferLineNumber - 1);
|
const line = buffer.getLine(bufferLineNumber - 1);
|
||||||
|
|
||||||
@@ -1866,7 +1884,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||||
buffer,
|
buffer,
|
||||||
bufferLineNumber - 1,
|
bufferLineNumber - 1,
|
||||||
self.terminal.cols,
|
terminal.cols,
|
||||||
MAX_STITCHED_ROWS
|
MAX_STITCHED_ROWS
|
||||||
);
|
);
|
||||||
if (!logical) {
|
if (!logical) {
|
||||||
@@ -1919,10 +1937,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
window.open(text, '_blank', 'noopener,noreferrer');
|
window.open(text, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1978,17 +1996,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// path clicked in the response viewer previewed fine. The preview
|
// path clicked in the response viewer previewed fine. The preview
|
||||||
// reads those through the guarded attachment routes, so external
|
// reads those through the guarded attachment routes, so external
|
||||||
// paths route there and the two surfaces agree.
|
// paths route there and the two surfaces agree.
|
||||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
const sessionId = getSessionId();
|
||||||
self.openFilePreview(text, self.activeSessionId);
|
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||||
|
self.openFilePreview(text, sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.openLogViewerWindow(text, self.activeSessionId);
|
self.openLogViewerWindow(text, sessionId);
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2031,10 +2050,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// produce), so the tap path asks this SAME provider what is under the finger
|
// produce), so the tap path asks this SAME provider what is under the finger
|
||||||
// rather than growing a second, driftable copy of the patterns.
|
// rather than growing a second, driftable copy of the patterns.
|
||||||
// See _terminalLinkAtPoint.
|
// See _terminalLinkAtPoint.
|
||||||
this._terminalLinkProvider = provider;
|
if (isPrimary) this._terminalLinkProvider = provider;
|
||||||
this.terminal.registerLinkProvider(provider);
|
terminal.registerLinkProvider(provider);
|
||||||
|
|
||||||
console.log('[LinkProvider] File path link provider registered');
|
console.log('[LinkProvider] File path link provider registered');
|
||||||
|
return provider;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4480,8 +4500,45 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Terminal Controls
|
// Terminal Controls
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
|
||||||
|
*
|
||||||
|
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||||
|
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||||
|
* primary pane. It answers with the pane whose terminal was focused LAST, not
|
||||||
|
* with `document.activeElement`: clicking the mic or a header button moves
|
||||||
|
* DOM focus to that button, and the dictation it starts still belongs to the
|
||||||
|
* pane the user was typing in. A second pane (the split pane's Pane B) claims
|
||||||
|
* it from its own terminal's focus; the primary terminal's focus gives it back.
|
||||||
|
*/
|
||||||
|
_focusedPane() {
|
||||||
|
const tile = this._focusedTile;
|
||||||
|
if (tile && !tile._destroyed && tile.terminal) {
|
||||||
|
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
|
||||||
|
}
|
||||||
|
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Record which second pane holds the keyboard (null: the primary terminal). */
|
||||||
|
_noteFocusedTile(tile) {
|
||||||
|
this._focusedTile = tile || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn(tile)` for every secondary terminal pane on screen: today the split
|
||||||
|
* pane's second terminal, when one is open. Font, weight, family and skin
|
||||||
|
* changes go through here so they reach every pane without a special case
|
||||||
|
* per pane kind. Agent Teams terminals size themselves and are not tiles.
|
||||||
|
*/
|
||||||
|
_forEachTile(fn) {
|
||||||
|
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clears the pane the keyboard is in. The chord itself also reaches that
|
||||||
|
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
|
||||||
|
// in the same pane whose display is cleared, never a different one.
|
||||||
clearTerminal() {
|
clearTerminal() {
|
||||||
this.terminal.clear();
|
this._focusedPane().terminal?.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Insert editable text at the active prompt without pressing Enter. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
@@ -4545,6 +4602,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||||
*/
|
*/
|
||||||
async restoreTerminalSize() {
|
async restoreTerminalSize() {
|
||||||
|
// A second pane owns its own geometry: refit it and force its PTY to the
|
||||||
|
// size it renders at (TerminalTile.fit), whatever another device set.
|
||||||
|
const pane = this._focusedPane();
|
||||||
|
if (!pane.isPrimary) {
|
||||||
|
pane.tile.fit({ force: true });
|
||||||
|
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.activeSessionId) {
|
if (!this.activeSessionId) {
|
||||||
this.showToast('No active session', 'warning');
|
this.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -4639,15 +4704,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||||
*/
|
*/
|
||||||
cleanedTerminalSelection(text) {
|
cleanedTerminalSelection(text, target = {}) {
|
||||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
// `target` names a second terminal (the split pane) and its session; both
|
||||||
|
// default to the primary pane, whose `this.terminal` this file otherwise reads.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||||
const clean = window.CodemanCopySelection?.clean;
|
const clean = window.CodemanCopySelection?.clean;
|
||||||
if (!clean) return raw;
|
if (!clean) return raw;
|
||||||
const range = this._normalisedSelectionRange();
|
const range = this._normalisedSelectionRange(terminal);
|
||||||
return clean(raw, {
|
return clean(raw, {
|
||||||
margin: this._cliGutterColumns(),
|
margin: this._cliGutterColumns(target.sessionId),
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
firstLinePartial: !!range && range.start.x > 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -4717,8 +4785,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||||
async copyTerminalSelection(text) {
|
async copyTerminalSelection(text, target = {}) {
|
||||||
const selection = this.cleanedTerminalSelection(text);
|
// Every terminal touched below is the TARGET one: clearing or refocusing the
|
||||||
|
// primary after copying from the split pane would hit the wrong pane.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const selection = this.cleanedTerminalSelection(text, target);
|
||||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||||
@@ -4727,7 +4798,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// selection, so a padding-only selection left set can no longer swallow a
|
// selection, so a padding-only selection left set can no longer swallow a
|
||||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||||
// clear avoids is a highlight that sits there having copied nothing.
|
// clear avoids is a highlight that sits there having copied nothing.
|
||||||
this.terminal?.clearSelection?.();
|
terminal?.clearSelection?.();
|
||||||
this.showToast('Nothing to copy', 'warning');
|
this.showToast('Nothing to copy', 'warning');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4735,14 +4806,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||||
// drops the selection on any keypress, so this matches existing feel).
|
// drops the selection on any keypress, so this matches existing feel).
|
||||||
this.terminal.clearSelection?.();
|
terminal.clearSelection?.();
|
||||||
this.showToast('Copied to clipboard', 'success');
|
this.showToast('Copied to clipboard', 'success');
|
||||||
} else {
|
} else {
|
||||||
this.showToast('Failed to copy', 'error');
|
this.showToast('Failed to copy', 'error');
|
||||||
}
|
}
|
||||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||||
// is the CJK-aware focus router, not xterm's raw focus().
|
// is the CJK-aware focus router, not xterm's raw focus().
|
||||||
this.terminal.focus();
|
terminal.focus();
|
||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -5688,10 +5759,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Update overlay font cache and re-render at new cell dimensions
|
// Update overlay font cache and re-render at new cell dimensions
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontSize = size;
|
tile.terminal.options.fontSize = size;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5717,10 +5788,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._refitAfterCellSizeChange();
|
this._refitAfterCellSizeChange();
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontFamily = resolved;
|
tile.terminal.options.fontFamily = resolved;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5772,11 +5843,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/* pane not laid out yet — its own resize observer refits it */
|
/* pane not laid out yet — its own resize observer refits it */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
tile.terminal.options.fontWeight = fontWeight;
|
||||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
@@ -6245,13 +6316,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||||
this._splitPane.terminal.options.theme = { ...theme };
|
tile.terminal.options.theme = { ...theme };
|
||||||
try {
|
try {
|
||||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,11 @@ const VoiceInput = {
|
|||||||
_analyserSource: null, // MediaStreamSource for level meter
|
_analyserSource: null, // MediaStreamSource for level meter
|
||||||
_audioContext: null, // AudioContext for level meter
|
_audioContext: null, // AudioContext for level meter
|
||||||
_levelAnimFrame: null, // rAF handle for level meter
|
_levelAnimFrame: null, // rAF handle for level meter
|
||||||
|
// The session dictation was started FOR, captured in start(). Transcripts
|
||||||
|
// arrive seconds later and the green send button / compose overlay can be
|
||||||
|
// used later still; reading app.activeSessionId at that point sent the text
|
||||||
|
// to whatever tab the user had switched to in the meantime.
|
||||||
|
_targetSessionId: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._initRecognition();
|
this._initRecognition();
|
||||||
@@ -663,10 +668,12 @@ const VoiceInput = {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.isRecording) return;
|
if (this.isRecording) return;
|
||||||
if (!app.activeSessionId) {
|
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||||
|
if (!target) {
|
||||||
app.showToast('No active session', 'warning');
|
app.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._targetSessionId = target;
|
||||||
this._retryCount = 0;
|
this._retryCount = 0;
|
||||||
|
|
||||||
const provider = this._resolveProvider();
|
const provider = this._resolveProvider();
|
||||||
@@ -959,8 +966,31 @@ const VoiceInput = {
|
|||||||
this.stop();
|
this.stop();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The session this dictation belongs to (see _targetSessionId). */
|
||||||
|
_targetSession() {
|
||||||
|
return this._targetSessionId || app.activeSessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send text to the dictation's own session. The active session keeps going
|
||||||
|
* through app.sendInput() exactly as before; any other session goes straight
|
||||||
|
* to the durable queue with the same useMux flag sendInput() passes.
|
||||||
|
*/
|
||||||
|
_sendToTarget(target, text) {
|
||||||
|
if (target === app.activeSessionId) return app.sendInput(text);
|
||||||
|
// Closed while dictating: say so instead of queueing text for a session
|
||||||
|
// that will only answer 404 (and never typing it into some other tab).
|
||||||
|
if (app.sessions && !app.sessions.has(target)) {
|
||||||
|
app.showToast?.('That session has closed; dictation not sent', 'warning');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
app._sendInputAsync(target, text, { useMux: true });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
_insertText(text) {
|
_insertText(text) {
|
||||||
if (!app.activeSessionId || !text.trim()) return;
|
const target = this._targetSession();
|
||||||
|
if (!target || !text.trim()) return;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||||
|
|
||||||
@@ -975,14 +1005,17 @@ const VoiceInput = {
|
|||||||
this._showComposeOverlay(trimmed);
|
this._showComposeOverlay(trimmed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||||
|
// for any other session must not be typed into it.
|
||||||
|
const isActive = target === app.activeSessionId;
|
||||||
|
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
app._localEchoOverlay.appendText(trimmed);
|
app._localEchoOverlay.appendText(trimmed);
|
||||||
} else {
|
} else {
|
||||||
app.sendInput(trimmed).catch(() => {});
|
this._sendToTarget(target, trimmed).catch(() => {});
|
||||||
}
|
}
|
||||||
this._showVoiceSendBtn();
|
this._showVoiceSendBtn();
|
||||||
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1008,10 +1041,15 @@ const VoiceInput = {
|
|||||||
|
|
||||||
// Click handler
|
// Click handler
|
||||||
this._voiceSendHandler = () => {
|
this._voiceSendHandler = () => {
|
||||||
if (!app.activeSessionId) return;
|
const target = this._targetSession();
|
||||||
|
if (!target) return;
|
||||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||||
// otherwise just send \r directly to the PTY
|
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||||
|
// for another session just sends its Enter there.
|
||||||
|
if (target !== app.activeSessionId) {
|
||||||
|
this._sendToTarget(target, '\r').catch(() => {});
|
||||||
|
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
const text = app._localEchoOverlay.pendingText || '';
|
const text = app._localEchoOverlay.pendingText || '';
|
||||||
app._localEchoOverlay.clear();
|
app._localEchoOverlay.clear();
|
||||||
app._localEchoOverlay.suppressBufferDetection();
|
app._localEchoOverlay.suppressBufferDetection();
|
||||||
@@ -1065,7 +1103,7 @@ const VoiceInput = {
|
|||||||
const send = () => {
|
const send = () => {
|
||||||
const val = textarea.value.trim();
|
const val = textarea.value.trim();
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||||
};
|
};
|
||||||
const cancel = () => overlay.remove();
|
const cancel = () => overlay.remove();
|
||||||
const newInput = () => {
|
const newInput = () => {
|
||||||
|
|||||||
+142
-12
@@ -50,6 +50,8 @@ import {
|
|||||||
} from '../../git-clone.js';
|
} from '../../git-clone.js';
|
||||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
|
import { prepareNewCasePath } from '../case-path.js';
|
||||||
|
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
|
||||||
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||||
import {
|
import {
|
||||||
@@ -163,6 +165,9 @@ function gitDiagnosticLine(stderr: string): string {
|
|||||||
* the clone response says so out loud instead of silently merging into them.
|
* the clone response says so out loud instead of silently merging into them.
|
||||||
*/
|
*/
|
||||||
function repoShipsClaudeSettings(casePath: string): boolean {
|
function repoShipsClaudeSettings(casePath: string): boolean {
|
||||||
|
// Deliberately NOT the bounded path probe: the tree was just cloned into the
|
||||||
|
// local case space (and lstat'ed synchronously moments ago), so a bound protects
|
||||||
|
// nothing here, while a probe answering "unknown" could silently drop this warning.
|
||||||
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +271,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
cases.push({
|
cases.push({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
path: casePath,
|
path: casePath,
|
||||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
|
||||||
location: 'local',
|
location: 'local',
|
||||||
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
||||||
});
|
});
|
||||||
@@ -281,17 +286,21 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const existingNames = new Set(cases.map((c) => c.name));
|
const existingNames = new Set(cases.map((c) => c.name));
|
||||||
if (admin) {
|
if (admin) {
|
||||||
for (const [name, path] of Object.entries(linkedCases)) {
|
for (const [name, path] of Object.entries(linkedCases)) {
|
||||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
|
||||||
|
const state = await probePath(path);
|
||||||
|
if (state === 'absent') continue;
|
||||||
|
// An unreachable linked case (a dead network mount) stays listed and says
|
||||||
|
// so: dropping it would read as "deleted" and invite a same-name local case.
|
||||||
cases.push({
|
cases.push({
|
||||||
name,
|
name,
|
||||||
path,
|
path,
|
||||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
|
||||||
linked: true,
|
linked: true,
|
||||||
location: 'linked-local',
|
location: 'linked-local',
|
||||||
|
...(state === 'unknown' ? { unreachable: true } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
|
||||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||||
@@ -333,7 +342,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const dockerCaseInfo: CaseInfo = {
|
const dockerCaseInfo: CaseInfo = {
|
||||||
name: dockerCase.name,
|
name: dockerCase.name,
|
||||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||||
location: 'docker',
|
location: 'docker',
|
||||||
docker: {
|
docker: {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
@@ -424,8 +433,109 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return { success: true, data: { cases: summaries } };
|
return { success: true, data: { cases: summaries } };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
/**
|
||||||
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
|
||||||
|
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
|
||||||
|
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
|
||||||
|
* anything is written; a failure after the first write undoes what this call created.
|
||||||
|
*/
|
||||||
|
async function createCaseInCustomFolder(
|
||||||
|
name: string,
|
||||||
|
description: string | undefined,
|
||||||
|
customPath: string,
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: { code: (n: number) => unknown }
|
||||||
|
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
|
||||||
|
const ownCasesDir = resolveCasesDir(getAuthUser(req));
|
||||||
|
if (existsSync(join(ownCasesDir, name))) {
|
||||||
|
reply.code(409);
|
||||||
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||||
|
}
|
||||||
|
const linkedCases = await readLinkedCases();
|
||||||
|
if (linkedCases[name]) {
|
||||||
|
reply.code(409);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.ALREADY_EXISTS,
|
||||||
|
`Case "${name}" is already linked to ${linkedCases[name]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
|
||||||
|
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
|
||||||
|
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
|
||||||
|
if (!prepared.ok) {
|
||||||
|
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
|
||||||
|
// POST /api/sessions for a workingDir on a dead mount.
|
||||||
|
if (prepared.code === 'UNREACHABLE') {
|
||||||
|
reply.code(422);
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
|
||||||
|
}
|
||||||
|
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
|
||||||
|
reply.code(status);
|
||||||
|
const code =
|
||||||
|
prepared.code === 'NOT_FOUND'
|
||||||
|
? ApiErrorCode.NOT_FOUND
|
||||||
|
: prepared.code === 'EXISTS'
|
||||||
|
? ApiErrorCode.ALREADY_EXISTS
|
||||||
|
: ApiErrorCode.INVALID_INPUT;
|
||||||
|
return createErrorResponse(code, prepared.reason);
|
||||||
|
}
|
||||||
|
const casePath = prepared.path;
|
||||||
|
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
|
||||||
|
if (alreadyAs) {
|
||||||
|
reply.code(409);
|
||||||
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const made: string[] = [];
|
||||||
|
try {
|
||||||
|
if (!prepared.existedEmpty) {
|
||||||
|
mkdirSync(casePath);
|
||||||
|
made.push(casePath);
|
||||||
|
}
|
||||||
|
mkdirSync(join(casePath, 'src'));
|
||||||
|
made.push(join(casePath, 'src'));
|
||||||
|
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||||
|
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||||
|
made.push(join(casePath, 'CLAUDE.md'));
|
||||||
|
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
|
||||||
|
await writeHooksConfig(casePath);
|
||||||
|
|
||||||
|
const codemanDir = getDataDir();
|
||||||
|
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
|
||||||
|
// Re-read right before writing, so a case linked since the check above is not dropped. This only
|
||||||
|
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
|
||||||
|
// requests that both read before either writes can still lose one entry.
|
||||||
|
const fresh = await readLinkedCases();
|
||||||
|
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
|
||||||
|
fresh[name] = casePath;
|
||||||
|
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||||
|
|
||||||
|
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||||
|
return { success: true, data: { case: { name, path: casePath } } };
|
||||||
|
} catch (err) {
|
||||||
|
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
|
||||||
|
// entries inside the empty folder the user picked; never anything else.
|
||||||
|
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
if ((err as { conflict?: boolean }).conflict) {
|
||||||
|
reply.code(409);
|
||||||
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
reply.code(500);
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||||
|
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
|
||||||
|
|
||||||
|
// A custom folder writes outside the cases directory and registers the path in the shared,
|
||||||
|
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
|
||||||
|
if (customPath !== undefined) {
|
||||||
|
const denied = adminOnly(req, reply);
|
||||||
|
if (denied) return denied;
|
||||||
|
return createCaseInCustomFolder(name, description, customPath, req, reply);
|
||||||
|
}
|
||||||
|
|
||||||
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
|
||||||
if (!casePath) {
|
if (!casePath) {
|
||||||
@@ -1618,7 +1728,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||||
location: 'docker',
|
location: 'docker',
|
||||||
docker: {
|
docker: {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
@@ -1633,16 +1743,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
const casePath = await resolveCasePath(name, getAuthUser(req));
|
const casePath = await resolveCasePath(name, getAuthUser(req));
|
||||||
|
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
||||||
|
|
||||||
if (!existsSync(casePath)) {
|
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
|
||||||
|
// a path that merely did not answer (a dead network mount) must never get it.
|
||||||
|
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
|
||||||
|
const state = await probePath(casePath, { pastCap: true });
|
||||||
|
if (state === 'absent') {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||||
}
|
}
|
||||||
|
if (state === 'unknown') {
|
||||||
|
// The linked registry knows where the case lives, so say where, and that
|
||||||
|
// it is not answering. A local case has no such record to fall back on.
|
||||||
|
if (!linked) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
describeUnknownPath('Case folder', casePath, { pastCap: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
|
||||||
|
}
|
||||||
|
|
||||||
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: casePath,
|
path: casePath,
|
||||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
// Probed like the folder above, or a healthy case reads as having no CLAUDE.md under the cap.
|
||||||
|
hasClaudeMd: (await probePath(join(casePath, 'CLAUDE.md'), { pastCap: true })) === 'present',
|
||||||
...(linked && { linked: true }),
|
...(linked && { linked: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1660,7 +1786,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
|
|
||||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||||
|
|
||||||
if (!existsSync(fixPlanPath)) {
|
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
|
||||||
|
if (fixPlanState === 'unknown') {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
|
||||||
|
}
|
||||||
|
if (fixPlanState === 'absent') {
|
||||||
return { exists: false, content: null, todos: [] };
|
return { exists: false, content: null, todos: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
|
||||||
|
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
|
||||||
|
*
|
||||||
|
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
|
||||||
|
* timeout), so it must never run on the server's event loop: a handful of slow probes would
|
||||||
|
* freeze every request and every SSE client, with the process still alive. The default runner
|
||||||
|
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
|
||||||
|
* parses its output; the runner is injected so tests never spawn anything.
|
||||||
|
*
|
||||||
|
* Read-only, but the report names install paths and versions on the host, so in multi-user
|
||||||
|
* mode it is admin only (the same bar as the other host-introspection routes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||||
|
import { isAdmin } from '../route-helpers.js';
|
||||||
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||||
|
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
|
||||||
|
import type { DependencyReportJson } from '../../utils/dependency-report.js';
|
||||||
|
|
||||||
|
export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;
|
||||||
|
|
||||||
|
const DOCTOR_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function isReport(v: unknown): v is DependencyReportJson {
|
||||||
|
const r = v as Partial<DependencyReportJson> | null;
|
||||||
|
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
|
||||||
|
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
|
||||||
|
*/
|
||||||
|
export const defaultDoctorRunner: DoctorRunner = (category) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
...process.execArgv,
|
||||||
|
process.argv[1],
|
||||||
|
'doctor',
|
||||||
|
'--json',
|
||||||
|
...(category ? ['--category', category] : []),
|
||||||
|
];
|
||||||
|
execFile(
|
||||||
|
process.execPath,
|
||||||
|
args,
|
||||||
|
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err && (err as { killed?: boolean }).killed) {
|
||||||
|
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(stdout);
|
||||||
|
if (isReport(parsed)) return resolve(parsed);
|
||||||
|
} catch {
|
||||||
|
/* fall through to the error below */
|
||||||
|
}
|
||||||
|
reject(err ?? new Error('doctor produced no report'));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
|
||||||
|
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
|
||||||
|
// asking for the same category while one is in flight share its promise.
|
||||||
|
const inFlight = new Map<string, Promise<DependencyReportJson>>();
|
||||||
|
const runShared = (category?: string): Promise<DependencyReportJson> => {
|
||||||
|
const key = category ?? '';
|
||||||
|
let running = inFlight.get(key);
|
||||||
|
if (!running) {
|
||||||
|
running = runner(category).finally(() => inFlight.delete(key));
|
||||||
|
inFlight.set(key, running);
|
||||||
|
}
|
||||||
|
return running;
|
||||||
|
};
|
||||||
|
app.get(
|
||||||
|
'/api/doctor',
|
||||||
|
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
|
||||||
|
if (isMultiUserMode() && !isAdmin(req)) {
|
||||||
|
reply.code(403);
|
||||||
|
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||||
|
}
|
||||||
|
const { category } = req.query as { category?: string };
|
||||||
|
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { success: true, data: await runShared(category) };
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(500);
|
||||||
|
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
||||||
|
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
||||||
|
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
||||||
|
* projects (see `getGitWorkspaceOverview` for exactly which).
|
||||||
|
*
|
||||||
|
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
||||||
|
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
|
||||||
|
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
|
||||||
|
* `findSessionOrFail`, like every session-scoped route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||||
|
import { redactGitCredentials } from '../../git-clone.js';
|
||||||
|
import { readDockerCases } from '../../docker-hosts.js';
|
||||||
|
import { getDataDir } from '../../config/instance.js';
|
||||||
|
import { findSessionOrFail } from '../route-helpers.js';
|
||||||
|
import {
|
||||||
|
emptyOverview,
|
||||||
|
findWorkspaceRepo,
|
||||||
|
getGitFileDiff,
|
||||||
|
getGitWorkspaceOverview,
|
||||||
|
getGitWorkspaceStatus,
|
||||||
|
type GitFileDiff,
|
||||||
|
type GitFileKind,
|
||||||
|
type GitRunner,
|
||||||
|
type GitWorkspaceOverview,
|
||||||
|
} from '../../git-workspace-status.js';
|
||||||
|
import type { SessionPort } from '../ports/index.js';
|
||||||
|
|
||||||
|
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
|
||||||
|
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||||
|
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||||
|
|
||||||
|
export function registerGitStatusRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
ctx: SessionPort,
|
||||||
|
git?: GitRunner,
|
||||||
|
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
|
||||||
|
): void {
|
||||||
|
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { fresh } = req.query as { fresh?: string };
|
||||||
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||||
|
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||||
|
git,
|
||||||
|
fresh: fresh === '1',
|
||||||
|
dockerWorkspaces: await dockerWorkspaces(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
|
||||||
|
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
|
||||||
|
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
|
||||||
|
// the overview's own (cached) list with the Docker roots as they are now, and only that one
|
||||||
|
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
||||||
|
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
|
||||||
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
if (session.remote || session.docker) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||||
|
}
|
||||||
|
const repoRoot = repo
|
||||||
|
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
|
||||||
|
: null;
|
||||||
|
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
|
||||||
|
const entry =
|
||||||
|
status?.state === 'ok'
|
||||||
|
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
||||||
|
: undefined;
|
||||||
|
if (!status?.repoRoot || !entry) {
|
||||||
|
reply.code(404);
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(500);
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.INTERNAL_ERROR,
|
||||||
|
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
|
|||||||
export { registerApprovalRoutes } from './approval-routes.js';
|
export { registerApprovalRoutes } from './approval-routes.js';
|
||||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||||
|
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||||
export { registerCaseRoutes } from './case-routes.js';
|
export { registerCaseRoutes } from './case-routes.js';
|
||||||
export { registerSessionRoutes } from './session-routes.js';
|
export { registerSessionRoutes } from './session-routes.js';
|
||||||
@@ -30,6 +31,7 @@ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-rout
|
|||||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||||
|
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||||
export {
|
export {
|
||||||
registerCustomModelRoutes,
|
registerCustomModelRoutes,
|
||||||
refreshAllCustomModelHosts,
|
refreshAllCustomModelHosts,
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ import {
|
|||||||
toSessionDocker,
|
toSessionDocker,
|
||||||
} from '../../docker-hosts.js';
|
} from '../../docker-hosts.js';
|
||||||
import { LRUMap } from '../../utils/lru-map.js';
|
import { LRUMap } from '../../utils/lru-map.js';
|
||||||
|
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||||
@@ -971,16 +972,23 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate workingDir exists and is a directory
|
// Validate workingDir exists and is a directory. Bounded: a workingDir on a
|
||||||
|
// network mount that stopped answering must not freeze the event loop, and
|
||||||
|
// "did not answer" is reported as such, never as "does not exist".
|
||||||
if (body.workingDir) {
|
if (body.workingDir) {
|
||||||
try {
|
const kind = await probePathKind(workingDir, { pastCap: true });
|
||||||
const stat = statSync(workingDir);
|
if (kind === 'unknown') {
|
||||||
if (!stat.isDirectory()) {
|
return createErrorResponse(
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
describeUnknownPath('workingDir', workingDir, { pastCap: true })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
if (kind === 'absent') {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||||||
}
|
}
|
||||||
|
if (kind !== 'directory') {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
|
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
|
||||||
@@ -3694,9 +3702,21 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bounded probe of a local case folder: a linked case can sit on a network mount
|
||||||
|
// that stopped answering, and a synchronous check there froze the whole server.
|
||||||
|
// Only a DEFINITE absence may scaffold a new case; "did not answer" must not
|
||||||
|
// create one over the top of where the real case is mounted.
|
||||||
|
const localCaseState = remote || docker ? undefined : await probePathKind(resolvedCasePath, { pastCap: true });
|
||||||
|
if (localCaseState === 'unknown') {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
if (localCaseState === 'absent') {
|
||||||
try {
|
try {
|
||||||
mkdirSync(resolvedCasePath, { recursive: true });
|
mkdirSync(resolvedCasePath, { recursive: true });
|
||||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||||
|
|||||||
@@ -668,6 +668,13 @@ export const CreateCaseSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
/**
|
||||||
|
* Create the case in this folder instead of under the cases directory. Absolute, or starting with
|
||||||
|
* `~`. Only length-bounded here: what makes it acceptable (shape, blocked trees, symlinks, an
|
||||||
|
* existing folder with contents) is judged by `prepareNewCasePath()` in web/case-path.ts, which
|
||||||
|
* also produces the user-facing reason.
|
||||||
|
*/
|
||||||
|
path: z.string().min(1).max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ import {
|
|||||||
registerApprovalRoutes,
|
registerApprovalRoutes,
|
||||||
registerRebootRestoreRoutes,
|
registerRebootRestoreRoutes,
|
||||||
registerReadMyMindRoutes,
|
registerReadMyMindRoutes,
|
||||||
|
registerGitStatusRoutes,
|
||||||
registerStatusTelemetryRoutes,
|
registerStatusTelemetryRoutes,
|
||||||
registerSystemRoutes,
|
registerSystemRoutes,
|
||||||
registerCaseRoutes,
|
registerCaseRoutes,
|
||||||
@@ -202,6 +203,7 @@ import {
|
|||||||
registerTabLayoutRoutes,
|
registerTabLayoutRoutes,
|
||||||
registerMcpSyncRoutes,
|
registerMcpSyncRoutes,
|
||||||
registerWebhookRoutes,
|
registerWebhookRoutes,
|
||||||
|
registerDoctorRoutes,
|
||||||
registerCustomModelRoutes,
|
registerCustomModelRoutes,
|
||||||
refreshAllCustomModelHosts,
|
refreshAllCustomModelHosts,
|
||||||
readCustomModelEndpointsEnabled,
|
readCustomModelEndpointsEnabled,
|
||||||
@@ -1121,6 +1123,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerApprovalRoutes(this.app, ctx);
|
registerApprovalRoutes(this.app, ctx);
|
||||||
registerRebootRestoreRoutes(this.app, ctx);
|
registerRebootRestoreRoutes(this.app, ctx);
|
||||||
registerReadMyMindRoutes(this.app, ctx);
|
registerReadMyMindRoutes(this.app, ctx);
|
||||||
|
registerGitStatusRoutes(this.app, ctx);
|
||||||
registerStatusTelemetryRoutes(this.app, ctx);
|
registerStatusTelemetryRoutes(this.app, ctx);
|
||||||
registerSystemRoutes(this.app, ctx);
|
registerSystemRoutes(this.app, ctx);
|
||||||
registerCaseRoutes(this.app, ctx);
|
registerCaseRoutes(this.app, ctx);
|
||||||
@@ -1143,6 +1146,7 @@ export class WebServer extends EventEmitter {
|
|||||||
configDir: getDataDir(),
|
configDir: getDataDir(),
|
||||||
hostTitle: () => this.windowTitle,
|
hostTitle: () => this.windowTitle,
|
||||||
});
|
});
|
||||||
|
registerDoctorRoutes(this.app);
|
||||||
registerCustomModelRoutes(this.app);
|
registerCustomModelRoutes(this.app);
|
||||||
registerCliRegistryRoutes(this.app);
|
registerCliRegistryRoutes(this.app);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
|
||||||
|
* a stat() that never settles (an unreachable hard network mount) must not hold
|
||||||
|
* the caller past the timeout, must read as "unknown" rather than "absent", must
|
||||||
|
* not be re-issued while it is still pending, must not let stalled probes pile up
|
||||||
|
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: { stat: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
|
||||||
|
// (a mount point with a space, octal-escaped in the table) are network mounts;
|
||||||
|
// everything else sits on the root filesystem. `null` = no table (not Linux).
|
||||||
|
const mounts = vi.hoisted(() => ({
|
||||||
|
table: null as string | null,
|
||||||
|
default: [
|
||||||
|
'sysfs /sys sysfs rw 0 0',
|
||||||
|
'/dev/sda1 / ext4 rw 0 0',
|
||||||
|
'nas:/export /mnt/nas nfs rw,hard 0 0',
|
||||||
|
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
}));
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
|
||||||
|
if (String(path) === '/proc/self/mounts') {
|
||||||
|
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
|
return mounts.table;
|
||||||
|
}
|
||||||
|
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
|
||||||
|
}) as typeof actual.readFileSync;
|
||||||
|
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
boundedPathExists,
|
||||||
|
describeUnknownPath,
|
||||||
|
isNearStalledPath,
|
||||||
|
probePath,
|
||||||
|
probePathKind,
|
||||||
|
unknownPathReason,
|
||||||
|
} from '../src/utils/bounded-path-probe.js';
|
||||||
|
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
|
||||||
|
|
||||||
|
const stat = vi.mocked(fs.stat);
|
||||||
|
const dirStats = { isDirectory: () => true } as never;
|
||||||
|
const fileStats = { isDirectory: () => false } as never;
|
||||||
|
|
||||||
|
let releases: Map<string, () => void>;
|
||||||
|
let warn: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the first stat() of each given path hang until released (the mount is
|
||||||
|
* down); every later stat, and every other path, answers "a directory exists".
|
||||||
|
*/
|
||||||
|
function hangOn(paths: string[]): Map<string, () => void> {
|
||||||
|
stat.mockImplementation((path) => {
|
||||||
|
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
releases.set(String(path), () => resolve(dirStats));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start probes for `paths` and let them time out, leaving each one stalled. */
|
||||||
|
async function stall(paths: string[]): Promise<void> {
|
||||||
|
const pending = paths.map((p) => probePath(p));
|
||||||
|
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||||
|
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mounts.table = mounts.default;
|
||||||
|
releases = new Map();
|
||||||
|
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Settle every stalled stat so module state does not leak into the next test.
|
||||||
|
releases.forEach((release) => release());
|
||||||
|
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
|
||||||
|
else await new Promise((r) => setTimeout(r, 0));
|
||||||
|
vi.useRealTimers();
|
||||||
|
stat.mockReset();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probePath', () => {
|
||||||
|
it('tells present, absent and unreadable apart', async () => {
|
||||||
|
stat.mockImplementation(async (path) => {
|
||||||
|
if (String(path) === '/present') return dirStats;
|
||||||
|
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
|
||||||
|
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
|
||||||
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
expect(await probePath('/present')).toBe('present');
|
||||||
|
expect(await probePath('/missing')).toBe('absent');
|
||||||
|
expect(await probePath('/notdir/child')).toBe('absent');
|
||||||
|
// A soft mount that gave up answers EIO: that is not proof the path is gone.
|
||||||
|
expect(await probePath('/eio')).toBe('unknown');
|
||||||
|
expect(await boundedPathExists('/present')).toBe(true);
|
||||||
|
expect(await boundedPathExists('/missing')).toBe(false);
|
||||||
|
expect(await boundedPathExists('/eio')).toBe(false);
|
||||||
|
// An error answer is neither a stall nor a refusal.
|
||||||
|
expect(unknownPathReason('/eio')).toBe('unreadable');
|
||||||
|
expect(describeUnknownPath('Case folder', '/eio')).toBe('Case folder is not responding or not readable: /eio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports whether a present path is a directory', async () => {
|
||||||
|
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
|
||||||
|
expect(await probePathKind('/dir')).toBe('directory');
|
||||||
|
expect(await probePathKind('/file')).toBe('file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hangOn(['/mnt/stalled/case']);
|
||||||
|
|
||||||
|
const result = probePath('/mnt/stalled/case');
|
||||||
|
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||||
|
expect(await result).toBe('unknown');
|
||||||
|
|
||||||
|
// A second caller gets the cached verdict immediately, without another stat.
|
||||||
|
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Once the mount answers, the path is probed afresh.
|
||||||
|
releases.get('/mnt/stalled/case')!();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(await probePath('/mnt/stalled/case')).toBe('present');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight stat between concurrent callers of the same path', async () => {
|
||||||
|
hangOn(['/slow']);
|
||||||
|
const a = probePath('/slow');
|
||||||
|
const b = boundedPathExists('/slow');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(1);
|
||||||
|
releases.get('/slow')!();
|
||||||
|
expect(await a).toBe('present');
|
||||||
|
expect(await b).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not give concurrent healthy probes a false negative', async () => {
|
||||||
|
stat.mockImplementation(async () => dirStats);
|
||||||
|
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
|
||||||
|
expect(results).toEqual([true, true, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still probes a healthy path as present while fewer unrelated paths are stalled than the cap', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES - 1 }, (_, i) => `/mnt/nas-${i}/project`);
|
||||||
|
hangOn(dead);
|
||||||
|
await stall(dead);
|
||||||
|
|
||||||
|
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
|
||||||
|
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
|
||||||
|
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers unknown, without a stat, for paths near a stalled one', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hangOn(['/mnt/nas/project-one']);
|
||||||
|
await stall(['/mnt/nas/project-one']);
|
||||||
|
stat.mockClear();
|
||||||
|
|
||||||
|
// Its own files, and a sibling linked case on the same mount.
|
||||||
|
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
|
||||||
|
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
|
||||||
|
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
|
||||||
|
expect(stat).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
releases.get('/mnt/nas/project-one')!();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
|
||||||
|
expect(await probePath('/mnt/nas/project-two')).toBe('present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads octal-escaped mount points from the table', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hangOn(['/mnt/nas b/one']);
|
||||||
|
await stall(['/mnt/nas b/one']);
|
||||||
|
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
|
||||||
|
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hangOn(['/srv/projects/stuck']);
|
||||||
|
await stall(['/srv/projects/stuck']);
|
||||||
|
|
||||||
|
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
|
||||||
|
expect(await probePath('/srv/projects/other')).toBe('present');
|
||||||
|
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows a stall on a local mount to the stalled path, even when that mount is not /', async () => {
|
||||||
|
// /home is its own local filesystem; ~/nas is a symlink to a network mount, so
|
||||||
|
// the stalled path is typed under /home. Only network and FUSE mounts widen.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mounts.table = [mounts.default, '/dev/sdb1 /home ext4 rw,relatime 0 0', ''].join('\n');
|
||||||
|
hangOn(['/home/user/nas/project']);
|
||||||
|
await stall(['/home/user/nas/project']);
|
||||||
|
stat.mockClear();
|
||||||
|
|
||||||
|
expect(await probePath('/home/user/nas/project/CLAUDE.md')).toBe('unknown');
|
||||||
|
expect(isNearStalledPath('/home/user/codeman-cases/one')).toBe(false);
|
||||||
|
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
|
||||||
|
expect(await probePath('/home/user/nas/other')).toBe('present');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widens a stall to the whole mount for network and FUSE filesystems', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mounts.table = [
|
||||||
|
mounts.default,
|
||||||
|
'nas:/four /srv/nas4 nfs4 rw,hard 0 0',
|
||||||
|
'user@host:/ /srv/sshfs fuse.sshfs rw 0 0',
|
||||||
|
'//nas/share /srv/smb cifs rw 0 0',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const dead = ['/srv/nas4/one', '/srv/sshfs/one'];
|
||||||
|
hangOn(dead);
|
||||||
|
await stall(dead);
|
||||||
|
|
||||||
|
expect(isNearStalledPath('/srv/nas4/two')).toBe(true);
|
||||||
|
expect(isNearStalledPath('/srv/sshfs/two')).toBe(true);
|
||||||
|
expect(isNearStalledPath('/srv/smb/two')).toBe(false);
|
||||||
|
expect(isNearStalledPath('/srv/elsewhere')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows a stall to the stalled path when there is no mount table', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mounts.table = null;
|
||||||
|
hangOn(['/mnt/nas/project-one']);
|
||||||
|
await stall(['/mnt/nas/project-one']);
|
||||||
|
|
||||||
|
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
|
||||||
|
expect(await probePath('/mnt/nas/project-two')).toBe('present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
|
||||||
|
hangOn(dead);
|
||||||
|
await stall(dead);
|
||||||
|
stat.mockClear();
|
||||||
|
|
||||||
|
// Every slot is held by a stat that never returned: refuse another, but never
|
||||||
|
// claim the path is absent.
|
||||||
|
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
|
||||||
|
expect(stat).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Once the stalled stats settle, probing resumes normally.
|
||||||
|
releases.forEach((release) => release());
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(await probePath('/healthy/elsewhere')).toBe('present');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
|
||||||
|
hangOn([...dead, '/mnt/another-dead/case']);
|
||||||
|
await stall(dead);
|
||||||
|
stat.mockClear();
|
||||||
|
|
||||||
|
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
|
||||||
|
|
||||||
|
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||||
|
expect(await hung).toBe('unknown');
|
||||||
|
// A retry is answered from the stall record, not with another stat.
|
||||||
|
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
|
||||||
|
expect(stat).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops pastCap probes at the threadpool ceiling, answering unknown without a stat', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// Fill the bulk cap, then let pastCap probes stall until the ceiling is reached.
|
||||||
|
const dead = Array.from({ length: PATH_PROBE_STALL_CEILING + 1 }, (_, i) => `/mnt/ceiling-${i}/case`);
|
||||||
|
hangOn(dead);
|
||||||
|
await stall(dead.slice(0, MAX_STALLED_PATH_PROBES));
|
||||||
|
for (const path of dead.slice(MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING)) {
|
||||||
|
const hung = probePath(path, { pastCap: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||||
|
expect(await hung).toBe('unknown');
|
||||||
|
}
|
||||||
|
stat.mockClear();
|
||||||
|
|
||||||
|
// One worker must stay free: no new stat, even for an explicit request.
|
||||||
|
const refused = probePath(dead[PATH_PROBE_STALL_CEILING], { pastCap: true });
|
||||||
|
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||||
|
expect(await refused).toBe('unknown');
|
||||||
|
expect(stat).not.toHaveBeenCalled();
|
||||||
|
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('unknown');
|
||||||
|
expect(stat).not.toHaveBeenCalled();
|
||||||
|
// ...and the message says the folder was never checked, rather than blaming it.
|
||||||
|
expect(unknownPathReason('/healthy/explicit', { pastCap: true })).toBe('refused');
|
||||||
|
expect(unknownPathReason(dead[0], { pastCap: true })).toBe('stalled');
|
||||||
|
expect(describeUnknownPath('workingDir', '/healthy/explicit', { pastCap: true })).toMatch(
|
||||||
|
/^workingDir was not checked: .*not answering.*: \/healthy\/explicit$/
|
||||||
|
);
|
||||||
|
expect(describeUnknownPath('workingDir', dead[0], { pastCap: true })).toBe(
|
||||||
|
`workingDir is not responding or not readable: ${dead[0]}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once one stalled stat settles, an explicit request is probed again.
|
||||||
|
releases.get(dead[0])!();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the bulk cap below the ceiling, so a pastCap probe has room', async () => {
|
||||||
|
// Read under a controlled environment: the limits are computed at import from
|
||||||
|
// UV_THREADPOOL_SIZE and CODEMAN_PATH_PROBE_MAX_STALLED, which the test process
|
||||||
|
// could otherwise inherit.
|
||||||
|
const saved = { uv: process.env.UV_THREADPOOL_SIZE, max: process.env.CODEMAN_PATH_PROBE_MAX_STALLED };
|
||||||
|
const limitsUnder = async (uv: string | undefined, max: string | undefined) => {
|
||||||
|
if (uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
|
||||||
|
else process.env.UV_THREADPOOL_SIZE = uv;
|
||||||
|
if (max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
|
||||||
|
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = max;
|
||||||
|
vi.resetModules();
|
||||||
|
return import('../src/config/path-probe.js');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const defaults = await limitsUnder(undefined, undefined);
|
||||||
|
expect([defaults.MAX_STALLED_PATH_PROBES, defaults.PATH_PROBE_STALL_CEILING]).toEqual([2, 3]);
|
||||||
|
const bigPool = await limitsUnder('8', undefined);
|
||||||
|
expect([bigPool.MAX_STALLED_PATH_PROBES, bigPool.PATH_PROBE_STALL_CEILING]).toEqual([6, 7]);
|
||||||
|
// An override may reach the ceiling but never pass it.
|
||||||
|
const overridden = await limitsUnder(undefined, '64');
|
||||||
|
expect(overridden.MAX_STALLED_PATH_PROBES).toBe(overridden.PATH_PROBE_STALL_CEILING);
|
||||||
|
} finally {
|
||||||
|
if (saved.uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
|
||||||
|
else process.env.UV_THREADPOOL_SIZE = saved.uv;
|
||||||
|
if (saved.max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
|
||||||
|
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = saved.max;
|
||||||
|
vi.resetModules();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns once when a path first stalls and once when the cap engages', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
|
||||||
|
hangOn(dead);
|
||||||
|
await stall([dead[0]]);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
|
||||||
|
|
||||||
|
// Asking again about the same stalled path does not warn again.
|
||||||
|
await probePath(dead[0]);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await stall(dead.slice(1));
|
||||||
|
warn.mockClear();
|
||||||
|
await probePath('/healthy/one');
|
||||||
|
await probePath('/healthy/two');
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { basename, join } from 'node:path';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
declare const PathPicker: any; // evaluated inside the page, where it is a global
|
||||||
|
|
||||||
|
const PORT = 3193;
|
||||||
|
|
||||||
|
describe('Create a case in a custom folder', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
let parent: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
parent = mkdtempSync(join(homedir(), 'custom-case-'));
|
||||||
|
server = new WebServer(PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
rmSync(parent, { recursive: true, force: true });
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).app.showCreateCaseModal();
|
||||||
|
(window as any).app.switchCaseModalTab('case-create');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toastText = () =>
|
||||||
|
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
|
||||||
|
|
||||||
|
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
|
||||||
|
await open();
|
||||||
|
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
|
||||||
|
await page.fill('#newCaseName', 'my-app');
|
||||||
|
await page.fill('#newCasePath', '~/projects/');
|
||||||
|
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is exclusive with the Docker option, in both directions', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseDocker)');
|
||||||
|
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseDocker)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'picked');
|
||||||
|
const opts = await page.evaluate(() => {
|
||||||
|
// A top-level `const` in a classic script: a global binding, not a window property.
|
||||||
|
const picker = PathPicker;
|
||||||
|
let captured: any = null;
|
||||||
|
const original = picker.open;
|
||||||
|
picker.open = (o: any) => (captured = o);
|
||||||
|
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
|
||||||
|
picker.open = original;
|
||||||
|
captured.onSelect('/srv/work');
|
||||||
|
return { directoriesOnly: captured.directoriesOnly };
|
||||||
|
});
|
||||||
|
expect(opts.directoriesOnly).toBe(true);
|
||||||
|
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
|
||||||
|
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'no-folder');
|
||||||
|
await page.evaluate(() => (window as any).app.createCase());
|
||||||
|
expect(await toastText()).toMatch(/Choose the folder/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'in-custom');
|
||||||
|
await page.fill('#newCasePath', parent);
|
||||||
|
await page.evaluate(() => (window as any).app.submitCaseModal());
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
|
||||||
|
);
|
||||||
|
const target = join(parent, 'in-custom');
|
||||||
|
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
|
||||||
|
expect(existsSync(join(target, 'src'))).toBe(true);
|
||||||
|
const cases = await page.evaluate(async () => {
|
||||||
|
const body = await (await fetch('/api/cases')).json();
|
||||||
|
return Array.isArray(body) ? body : body.data;
|
||||||
|
});
|
||||||
|
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
|
||||||
|
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
|
||||||
|
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
|
||||||
|
const busy = join(parent, 'busy');
|
||||||
|
mkdirSync(busy);
|
||||||
|
writeFileSync(join(busy, 'keep.txt'), 'mine');
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'busy');
|
||||||
|
await page.fill('#newCasePath', parent);
|
||||||
|
await page.evaluate(() => (window as any).app.createCase());
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
|
||||||
|
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
|
||||||
|
await open();
|
||||||
|
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
|
||||||
|
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
|
||||||
|
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps / as the root parent instead of sending an empty path', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'at-root');
|
||||||
|
await page.fill('#newCasePath', '/');
|
||||||
|
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
|
||||||
|
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the folder the server created in the success toast (~ expanded)', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCaseName', 'via-tilde');
|
||||||
|
await page.fill('#newCasePath', `~/${basename(parent)}`);
|
||||||
|
await page.evaluate(() => (window as any).app.submitCaseModal());
|
||||||
|
const target = join(realpathSync(parent), 'via-tilde');
|
||||||
|
await page.waitForFunction(
|
||||||
|
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
|
||||||
|
target
|
||||||
|
);
|
||||||
|
expect(await toastText()).not.toMatch(/created in ~\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts unticked every time the modal opens', async () => {
|
||||||
|
await open();
|
||||||
|
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||||
|
await page.fill('#newCasePath', '/tmp');
|
||||||
|
await open();
|
||||||
|
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
|
||||||
|
expect(await page.inputValue('#newCasePath')).toBe('');
|
||||||
|
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
|
||||||
|
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
|
||||||
|
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
|
||||||
|
* lstat / readdir calls that would wait on a hard mount forever.
|
||||||
|
*
|
||||||
|
* Only the chosen dead paths hang; everything else is the real filesystem.
|
||||||
|
* Port: none.
|
||||||
|
*/
|
||||||
|
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const probe = vi.hoisted(() => {
|
||||||
|
// Short probe timeout so a stall costs ~100 ms, read at import.
|
||||||
|
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||||
|
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
|
||||||
|
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
|
||||||
|
return ((path: string, ...rest: unknown[]) => {
|
||||||
|
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
|
||||||
|
probe.touched.push(`${name} ${String(path)}`);
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
||||||
|
}) as unknown as F;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
const stat = hangOnDead('stat', actual.stat);
|
||||||
|
return { ...actual, stat, default: { ...actual, stat } };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
const promises = {
|
||||||
|
...actual.promises,
|
||||||
|
realpath: hangOnDead('realpath', actual.promises.realpath),
|
||||||
|
stat: hangOnDead('stat', actual.promises.stat),
|
||||||
|
lstat: hangOnDead('lstat', actual.promises.lstat),
|
||||||
|
readdir: hangOnDead('readdir', actual.promises.readdir),
|
||||||
|
};
|
||||||
|
return { ...actual, promises, default: { ...actual, promises } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { prepareNewCasePath } from '../src/web/case-path.js';
|
||||||
|
|
||||||
|
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
|
||||||
|
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
probe.releases.splice(0).forEach((release) => release());
|
||||||
|
probe.touched.length = 0;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareNewCasePath on a parent folder that does not answer', () => {
|
||||||
|
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await Promise.race([
|
||||||
|
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
|
||||||
|
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
|
||||||
|
]);
|
||||||
|
warn.mockRestore();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
|
||||||
|
expect((result as { reason: string }).reason).toBe(
|
||||||
|
`The parent folder is not responding or not readable: ${probe.dead}`
|
||||||
|
);
|
||||||
|
// Only the bounded probe's own stat reached the dead mount.
|
||||||
|
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
|
||||||
|
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
let home: string;
|
||||||
|
let ctx: NewCasePathContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
|
||||||
|
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
|
||||||
|
home = join(root, 'home');
|
||||||
|
mkdirSync(join(home, 'code'), { recursive: true });
|
||||||
|
ctx = { home, dataDir: join(home, '.codeman') };
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('expandHome', () => {
|
||||||
|
it('expands ~ and ~/x only', () => {
|
||||||
|
expect(expandHome('~', '/h')).toBe('/h');
|
||||||
|
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
|
||||||
|
expect(expandHome('~other/x', '/h')).toBe('~other/x');
|
||||||
|
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockedReason', () => {
|
||||||
|
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
|
||||||
|
it.each([
|
||||||
|
['/', /root/],
|
||||||
|
['/etc', /system directory/],
|
||||||
|
['/etc/cron.d/x', /system directory/],
|
||||||
|
['/usr/local/src', /system directory/],
|
||||||
|
['/proc/1', /system directory/],
|
||||||
|
['/home/u', /home folder itself/],
|
||||||
|
['/home/u/.ssh', /credentials/],
|
||||||
|
['/home/u/.ssh/proj', /credentials/],
|
||||||
|
['/home/u/.aws/x', /credentials/],
|
||||||
|
['/home/u/.claude/skills/x', /configuration/],
|
||||||
|
['/home/u/.codeman/cases/x', /data folder/],
|
||||||
|
['/home/u/.codeman-beta/x', /data folder/],
|
||||||
|
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
|
||||||
|
|
||||||
|
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
|
||||||
|
'allows %s (a name that merely contains a blocked word is fine)',
|
||||||
|
(p) => expect(blockedReason(p, c)).toBeNull()
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not treat /etcetera or /usrlocal as the system directories', () => {
|
||||||
|
expect(blockedReason('/etcetera/x', c)).toBeNull();
|
||||||
|
expect(blockedReason('/usrlocal', c)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a cases directory and anything inside it, when given', () => {
|
||||||
|
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
|
||||||
|
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
|
||||||
|
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
|
||||||
|
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
|
||||||
|
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges against the system roots it is given', () => {
|
||||||
|
expect(blockedReason('/private/etc/x', c)).toBeNull();
|
||||||
|
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareNewCasePath', () => {
|
||||||
|
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
|
||||||
|
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
|
||||||
|
expect(r).toMatchObject({ ok: true, existedEmpty: false });
|
||||||
|
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an existing EMPTY folder and says so', async () => {
|
||||||
|
mkdirSync(join(home, 'code', 'empty'));
|
||||||
|
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
|
||||||
|
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
|
||||||
|
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
|
||||||
|
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
|
||||||
|
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
|
||||||
|
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
|
||||||
|
// ...and a folder with a space in it can be the PARENT of the next one.
|
||||||
|
mkdirSync(join(home, 'code', 'with space'));
|
||||||
|
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['empty', ''],
|
||||||
|
['whitespace', ' '],
|
||||||
|
['relative', 'code/app'],
|
||||||
|
['dot-relative', './app'],
|
||||||
|
['traversal', '/tmp/../etc/x'],
|
||||||
|
['shell metacharacters', '/tmp/a;rm -rf /'],
|
||||||
|
['command substitution', '/tmp/$(id)'],
|
||||||
|
['quotes', "/tmp/it's"],
|
||||||
|
['newline', '/tmp/a\nb'],
|
||||||
|
])('rejects %s as invalid', async (_label, raw) => {
|
||||||
|
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
|
||||||
|
for (const raw of [
|
||||||
|
'/etc/proj',
|
||||||
|
'/usr/src/x',
|
||||||
|
home,
|
||||||
|
join(home, '.ssh', 'x'),
|
||||||
|
join(home, '.codeman', 'cases', 'x'),
|
||||||
|
]) {
|
||||||
|
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
|
||||||
|
symlinkSync('/etc', join(home, 'code', 'sneaky'));
|
||||||
|
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'BLOCKED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
|
||||||
|
const realHome = join(root, 'realhome');
|
||||||
|
mkdirSync(join(realHome, '.ssh'), { recursive: true });
|
||||||
|
mkdirSync(join(realHome, 'code'));
|
||||||
|
const linkHome = join(root, 'linkhome');
|
||||||
|
symlinkSync(realHome, linkHome);
|
||||||
|
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
|
||||||
|
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
|
||||||
|
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
|
||||||
|
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'BLOCKED',
|
||||||
|
});
|
||||||
|
// The same for Codeman's data dir given through the link.
|
||||||
|
mkdirSync(join(realHome, '.codeman'));
|
||||||
|
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
|
||||||
|
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'BLOCKED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a link into a cases directory, judged on its resolved form', async () => {
|
||||||
|
const cases = join(home, 'codeman-cases');
|
||||||
|
mkdirSync(cases);
|
||||||
|
symlinkSync(cases, join(home, 'code', 'shortcut'));
|
||||||
|
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
|
||||||
|
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a parent that is a file', async () => {
|
||||||
|
writeFileSync(join(home, 'code', 'afile'), 'x');
|
||||||
|
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
|
||||||
|
mkdirSync(join(home, 'code', 'mine'));
|
||||||
|
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
|
||||||
|
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a target that is a file or a symbolic link', async () => {
|
||||||
|
writeFileSync(join(home, 'code', 'plain'), 'x');
|
||||||
|
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
|
||||||
|
mkdirSync(join(home, 'code', 'real'));
|
||||||
|
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
|
||||||
|
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never creates anything', async () => {
|
||||||
|
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
|
||||||
|
const { existsSync } = await import('node:fs');
|
||||||
|
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
|
||||||
|
*
|
||||||
|
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
|
||||||
|
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||||
|
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
|
||||||
|
* sentence; with the split pane open it was not even the pane being typed in.
|
||||||
|
* The action now has NO default key: the capture-phase shortcut handler lets
|
||||||
|
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
|
||||||
|
* stays in the registry so a user can still bind a key to it in App Settings →
|
||||||
|
* Shortcuts.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
|
||||||
|
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
|
||||||
|
type App = {
|
||||||
|
getShortcutRegistry(): Shortcut[];
|
||||||
|
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
|
||||||
|
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(settings: Record<string, unknown> = {}): App {
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app.loadAppSettingsFromStorage = () => settings;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydown = (overrides: Record<string, unknown>) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: 'w',
|
||||||
|
code: 'KeyW',
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ctrl+W is left to the terminal', () => {
|
||||||
|
it('Close Session is still in the registry, with no default key', () => {
|
||||||
|
const close = makeApp()
|
||||||
|
.getShortcutRegistry()
|
||||||
|
.find((s) => s.id === 'close-session');
|
||||||
|
|
||||||
|
expect(close?.action).toBe('killActiveSession');
|
||||||
|
expect(close?.bindings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Ctrl+W', { ctrlKey: true }],
|
||||||
|
['Cmd+W', { metaKey: true }],
|
||||||
|
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
|
||||||
|
const app = makeApp();
|
||||||
|
const event = keydown(mods);
|
||||||
|
|
||||||
|
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
|
||||||
|
|
||||||
|
expect(matched.map((s) => s.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user can still bind a key to Close Session', () => {
|
||||||
|
const app = makeApp({
|
||||||
|
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
|
||||||
|
});
|
||||||
|
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
|
||||||
|
|
||||||
|
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
|
||||||
|
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the shortcut overlay says an unbound action is not bound', () => {
|
||||||
|
const appSource = read('app.js');
|
||||||
|
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
|
||||||
|
|
||||||
|
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { dependencyRegistry } from '../src/config/dependency-registry.js';
|
import { dependencyRegistry } from '../src/config/dependency-registry.js';
|
||||||
import {
|
import {
|
||||||
detectEnvironment,
|
detectEnvironment,
|
||||||
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
|
|||||||
environment: env,
|
environment: env,
|
||||||
which: () => null,
|
which: () => null,
|
||||||
fileExists: () => false,
|
fileExists: () => false,
|
||||||
|
isExecutableFile: () => false,
|
||||||
runVersion: () => null,
|
runVersion: () => null,
|
||||||
windowsProgramRoots: () => [],
|
windowsProgramRoots: () => [],
|
||||||
windowsFileVersion: () => null,
|
windowsFileVersion: () => null,
|
||||||
@@ -259,6 +263,121 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkTool with searchDirs (service PATH is minimal)', () => {
|
||||||
|
const claudeLike: ToolDependency = {
|
||||||
|
...tmuxTool,
|
||||||
|
id: 'claude',
|
||||||
|
label: 'Claude CLI',
|
||||||
|
resolvers: [
|
||||||
|
{
|
||||||
|
match: ['linux'],
|
||||||
|
resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => {
|
||||||
|
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||||
|
const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion });
|
||||||
|
expect(checkTool(claudeLike, host)).toMatchObject({
|
||||||
|
status: 'ok',
|
||||||
|
path: '/opt/npm/bin/claude',
|
||||||
|
version: '2.1.0',
|
||||||
|
});
|
||||||
|
expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports missing when neither PATH nor any search dir has it', () => {
|
||||||
|
expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the PATH hit over a search dir', () => {
|
||||||
|
const host = fakeHost('linux', {
|
||||||
|
which: () => '/usr/bin/claude',
|
||||||
|
isExecutableFile: () => true,
|
||||||
|
runVersion: () => '1.0.0',
|
||||||
|
});
|
||||||
|
expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only
|
||||||
|
// as an absolute path to an executable regular file. A file that merely exists is not one.
|
||||||
|
it('skips a search-dir file that exists but is not executable', () => {
|
||||||
|
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||||
|
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion });
|
||||||
|
expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' });
|
||||||
|
expect(runVersion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => {
|
||||||
|
const relative: ToolDependency = {
|
||||||
|
...claudeLike,
|
||||||
|
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }],
|
||||||
|
};
|
||||||
|
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||||
|
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion });
|
||||||
|
expect(checkTool(relative, host)).toMatchObject({ status: 'missing' });
|
||||||
|
expect(runVersion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin.
|
||||||
|
// The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor
|
||||||
|
// used to stop at the PATH hit and report MISSING.
|
||||||
|
const squatted: ToolDependency = {
|
||||||
|
...claudeLike,
|
||||||
|
id: 'pi',
|
||||||
|
label: 'Pi CLI',
|
||||||
|
resolvers: [
|
||||||
|
{
|
||||||
|
match: ['linux'],
|
||||||
|
resolver: {
|
||||||
|
kind: 'path',
|
||||||
|
bins: ['pi'],
|
||||||
|
versionRegex: PI_VERSION_REGEX,
|
||||||
|
requireVersionMatch: true,
|
||||||
|
searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('finds the right binary in a search dir when a wrong one is on the PATH', () => {
|
||||||
|
const host = fakeHost('linux', {
|
||||||
|
which: () => '/usr/bin/pi',
|
||||||
|
isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi',
|
||||||
|
runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'),
|
||||||
|
});
|
||||||
|
expect(checkTool(squatted, host)).toMatchObject({
|
||||||
|
status: 'ok',
|
||||||
|
path: '/home/u/.npm-global/bin/pi',
|
||||||
|
version: '0.84.3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version-checks each search-dir candidate and moves past one that fails', () => {
|
||||||
|
const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n'));
|
||||||
|
const host = fakeHost('linux', { isExecutableFile: () => true, runVersion });
|
||||||
|
expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' });
|
||||||
|
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes a search dir that is also on the PATH only once', () => {
|
||||||
|
const runVersion = vi.fn(() => 'Raspberry Pi utility\n');
|
||||||
|
const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion });
|
||||||
|
expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' });
|
||||||
|
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => {
|
||||||
|
const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver));
|
||||||
|
const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length);
|
||||||
|
expect(withDirs.length).toBeGreaterThan(0);
|
||||||
|
for (const r of withDirs) {
|
||||||
|
if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('checkAll', () => {
|
describe('checkAll', () => {
|
||||||
it('maps every tool to a result', () => {
|
it('maps every tool to a result', () => {
|
||||||
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
|
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
|
||||||
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
|
|||||||
expect(typeof host.which).toBe('function');
|
expect(typeof host.which).toBe('function');
|
||||||
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
|
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts only an executable regular file as a search-dir candidate', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-'));
|
||||||
|
try {
|
||||||
|
const file = join(dir, 'tool');
|
||||||
|
writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 });
|
||||||
|
const host = createRealHost();
|
||||||
|
expect(host.isExecutableFile(file)).toBe(false);
|
||||||
|
chmodSync(file, 0o755);
|
||||||
|
expect(host.isExecutableFile(file)).toBe(true);
|
||||||
|
expect(host.isExecutableFile(dir)).toBe(false);
|
||||||
|
expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
// The contract GET /api/doctor's default runner relies on: the same entry script, given
|
||||||
|
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
|
||||||
|
// non-zero because something required is missing.
|
||||||
|
//
|
||||||
|
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
|
||||||
|
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
|
||||||
|
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
|
||||||
|
// clis.json in that HOME's data dir drops the registry's absolute search dirs
|
||||||
|
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
|
||||||
|
|
||||||
|
import { execFile, execFileSync } from 'node:child_process';
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { isAbsolute, join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dirname, '..');
|
||||||
|
|
||||||
|
interface DoctorRun {
|
||||||
|
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
|
||||||
|
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
|
||||||
|
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
|
||||||
|
symlinkSync(process.execPath, join(bare, 'node'));
|
||||||
|
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
|
||||||
|
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
|
||||||
|
const clis = Object.fromEntries(
|
||||||
|
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
|
||||||
|
);
|
||||||
|
mkdirSync(join(home, '.codeman'), { recursive: true });
|
||||||
|
// 0600 or the registry ignores the file (isUnsafePermissions).
|
||||||
|
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
|
||||||
|
delete env.CODEMAN_DATA_DIR;
|
||||||
|
delete env.CODEMAN_INSTANCE;
|
||||||
|
return {
|
||||||
|
home,
|
||||||
|
bare,
|
||||||
|
env,
|
||||||
|
cleanup: () => {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(bare, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
|
||||||
|
join(ROOT, 'src/index.ts'),
|
||||||
|
'doctor',
|
||||||
|
'--json',
|
||||||
|
'--category',
|
||||||
|
'core',
|
||||||
|
],
|
||||||
|
{ timeout: 60_000, cwd: ROOT, env },
|
||||||
|
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('codeman doctor --json', () => {
|
||||||
|
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
|
||||||
|
const h = hermeticDoctorEnv();
|
||||||
|
try {
|
||||||
|
const { report, stderr } = await runDoctor(h.env);
|
||||||
|
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
|
||||||
|
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
|
||||||
|
const node = report.tools.find((t) => t.id === 'node');
|
||||||
|
expect(node?.status).toBe('ok');
|
||||||
|
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
|
||||||
|
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
|
||||||
|
// the doctor found, and so ran, lives outside this test's own temp dirs.
|
||||||
|
expect(stderr).not.toContain('[cli-registry]');
|
||||||
|
for (const t of report.tools.filter((t) => t.path)) {
|
||||||
|
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
|
||||||
|
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
|
||||||
|
// searchDirs) found it.
|
||||||
|
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
|
||||||
|
const h = hermeticDoctorEnv();
|
||||||
|
try {
|
||||||
|
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
|
||||||
|
const fake = join(h.home, '.local/bin/claude');
|
||||||
|
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
|
||||||
|
chmodSync(fake, 0o755);
|
||||||
|
const { report } = await runDoctor(h.env);
|
||||||
|
const claude = report.tools.find((t) => t.id === 'claude');
|
||||||
|
expect(claude).toMatchObject({ status: 'ok', path: fake });
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
}, 90_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
const PORT = 3196;
|
||||||
|
|
||||||
|
const REPORT = {
|
||||||
|
platform: { environment: 'linux' },
|
||||||
|
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
label: 'Node.js',
|
||||||
|
category: 'core',
|
||||||
|
required: true,
|
||||||
|
usedBy: [],
|
||||||
|
status: 'ok',
|
||||||
|
version: '22.1.0',
|
||||||
|
path: '/usr/bin/node',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tmux',
|
||||||
|
label: 'tmux',
|
||||||
|
category: 'core',
|
||||||
|
required: true,
|
||||||
|
usedBy: [],
|
||||||
|
status: 'missing',
|
||||||
|
installHint: 'apt install tmux',
|
||||||
|
},
|
||||||
|
// Host-supplied strings must be rendered as text, never as markup.
|
||||||
|
{
|
||||||
|
id: 'x',
|
||||||
|
label: '<img src=x onerror=window.__pwned=1>',
|
||||||
|
category: 'other',
|
||||||
|
required: false,
|
||||||
|
usedBy: [],
|
||||||
|
status: 'missing',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Diagnostics panel in a real browser', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
// A controlling service worker can swallow requests before page.route() sees them, letting the
|
||||||
|
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
|
||||||
|
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
|
||||||
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
|
||||||
|
await page.route('**/api/doctor', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
|
||||||
|
);
|
||||||
|
await page.click('#doctorRunBtn');
|
||||||
|
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||||
|
const text = await page.textContent('#doctorResult');
|
||||||
|
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
|
||||||
|
expect(text).toContain('✓ Node.js ok · 22.1.0');
|
||||||
|
expect(text).toContain('/usr/bin/node');
|
||||||
|
expect(text).toContain('✗ tmux missing · required');
|
||||||
|
// A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it.
|
||||||
|
expect(text).toContain('○ <img src=x onerror=window.__pwned=1> missing · optional');
|
||||||
|
expect(text).toContain('Install: apt install tmux');
|
||||||
|
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
|
||||||
|
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||||
|
expect(await page.$('#doctorResult img')).toBeNull();
|
||||||
|
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the server’s message when the check fails, and re-enables the button', async () => {
|
||||||
|
await page.unroute('**/api/doctor');
|
||||||
|
await page.route('**/api/doctor', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.click('#doctorRunBtn');
|
||||||
|
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||||
|
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => {
|
||||||
|
const visible = (user: Record<string, unknown>) =>
|
||||||
|
page.evaluate((u) => {
|
||||||
|
(window as any).__codemanUser = u;
|
||||||
|
document.dispatchEvent(new CustomEvent('codeman:me'));
|
||||||
|
return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none';
|
||||||
|
}, user);
|
||||||
|
expect(await visible({ multiUser: true, role: 'user' })).toBe(false);
|
||||||
|
expect(await visible({ multiUser: true, role: 'admin' })).toBe(true);
|
||||||
|
expect(await visible({ multiUser: false })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
|
||||||
|
*
|
||||||
|
* With the split pane open, the global shortcut handler (app.js, capture phase)
|
||||||
|
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
|
||||||
|
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
|
||||||
|
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
|
||||||
|
* now answers with the pane whose terminal was focused last, and the actions
|
||||||
|
* that are about a TERMINAL (clear, restore size) go through it.
|
||||||
|
*
|
||||||
|
* Close Session is not one of them: it has no default key any more (Ctrl+W is
|
||||||
|
* left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
|
||||||
|
* key a user binds to it closes the active session, as it always did.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
function loadApp() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
app.activeSessionId = 'session-a';
|
||||||
|
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app.sendResize = vi.fn(async () => true);
|
||||||
|
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneB(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
|
||||||
|
fit: vi.fn(),
|
||||||
|
_destroyed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('_focusedPane()', () => {
|
||||||
|
it('is the primary pane until another pane claims the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
const pane = app._focusedPane();
|
||||||
|
|
||||||
|
expect(pane.isPrimary).toBe(true);
|
||||||
|
expect(pane.terminal).toBe(app.terminal);
|
||||||
|
expect(pane.sessionId).toBe('session-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
|
||||||
|
|
||||||
|
app._noteFocusedTile(null);
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never answers with a destroyed pane', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
tile._destroyed = true;
|
||||||
|
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal shortcuts follow the focused pane', () => {
|
||||||
|
it('Ctrl+L clears the focused second pane, not the primary', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(tile.fit).toHaveBeenCalledWith({ force: true });
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Close Session (user-bound key only) stays on the active session', () => {
|
||||||
|
it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
|
||||||
|
const appSource = read('app.js');
|
||||||
|
const body = appSource.slice(
|
||||||
|
appSource.indexOf('async killActiveSession() {'),
|
||||||
|
appSource.indexOf('async killAllSessions() {')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('await this.closeSession(this.activeSessionId);');
|
||||||
|
expect(body).not.toContain('_focusedPane');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
const PORT = 3192;
|
||||||
|
const ENV = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'T',
|
||||||
|
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||||
|
GIT_COMMITTER_NAME: 'T',
|
||||||
|
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
};
|
||||||
|
const git = (cwd: string, ...args: string[]) =>
|
||||||
|
execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
|
||||||
|
|
||||||
|
describe('Git status indicator in a real browser', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
let root: string;
|
||||||
|
let repo: string;
|
||||||
|
let plain: string;
|
||||||
|
let repoSession: string;
|
||||||
|
let plainSession: string;
|
||||||
|
const gitStatusRequests: string[] = [];
|
||||||
|
const settingsPutStatuses: number[] = [];
|
||||||
|
|
||||||
|
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
|
||||||
|
const commitAll = (msg: string) => {
|
||||||
|
git(repo, 'add', '-A');
|
||||||
|
git(repo, 'commit', '-q', '-m', msg);
|
||||||
|
};
|
||||||
|
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
|
||||||
|
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
|
||||||
|
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
|
||||||
|
const setSetting = async (on: boolean) => {
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
|
||||||
|
await page.click('label.switch:has(#appSettingsShowGitStatus)');
|
||||||
|
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||||
|
};
|
||||||
|
const createSession = (dir: string) =>
|
||||||
|
page.evaluate(async (workingDir) => {
|
||||||
|
const res = await fetch('/api/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||||
|
});
|
||||||
|
const id = (await res.json()).data.session.id;
|
||||||
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
|
return id as string;
|
||||||
|
}, dir);
|
||||||
|
const select = (id: string) =>
|
||||||
|
page.evaluate(async (sid) => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||||
|
await app.selectSession(sid);
|
||||||
|
}, id);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
root = mkdtempSync(join(homedir(), 'git-ui-'));
|
||||||
|
const bare = join(root, 'origin.git');
|
||||||
|
repo = join(root, 'repo');
|
||||||
|
plain = join(root, 'plain');
|
||||||
|
mkdirSync(repo);
|
||||||
|
mkdirSync(plain);
|
||||||
|
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||||
|
git(repo, 'init', '-q', '-b', 'main');
|
||||||
|
git(repo, 'remote', 'add', 'origin', bare);
|
||||||
|
write('a.txt', '1\n');
|
||||||
|
commitAll('base');
|
||||||
|
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||||
|
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
|
||||||
|
write('b.txt', 'b\n');
|
||||||
|
commitAll('add b (not pushed)');
|
||||||
|
write('a.txt', '2\n');
|
||||||
|
write('new file.txt', 'n\n');
|
||||||
|
|
||||||
|
server = new WebServer(PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||||
|
page.on('request', (r) => {
|
||||||
|
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
|
||||||
|
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
|
||||||
|
});
|
||||||
|
page.on('response', (r) => {
|
||||||
|
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||||
|
});
|
||||||
|
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
repoSession = await createSession(repo);
|
||||||
|
plainSession = await createSession(plain);
|
||||||
|
await select(repoSession);
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('is off by default: no button and no request to the git-status route', async () => {
|
||||||
|
await page.waitForTimeout(2500); // longer than one tick
|
||||||
|
expect(await buttonVisible()).toBe(false);
|
||||||
|
expect(gitStatusRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
|
||||||
|
await setSetting(true);
|
||||||
|
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||||
|
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||||
|
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||||
|
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
|
||||||
|
expect(await label()).toContain('↑ 1'); // one commit not pushed
|
||||||
|
const title = await page.getAttribute('#gitStatusBtn', 'title');
|
||||||
|
expect(title).toMatch(/main/);
|
||||||
|
expect(title).toMatch(/2 uncommitted files/);
|
||||||
|
expect(title).toMatch(/1 commit not pushed/);
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
|
||||||
|
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
|
||||||
|
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
|
||||||
|
expect(colour).toBe('rgb(224, 160, 48)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits at the right of the bottom bar, beside the version', async () => {
|
||||||
|
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
|
||||||
|
expect(inRight).toBe(true);
|
||||||
|
const order = await page.evaluate(() => {
|
||||||
|
const right = document.querySelector('.toolbar-right')!;
|
||||||
|
return [...right.children].map((c) => c.id).filter(Boolean);
|
||||||
|
});
|
||||||
|
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
|
||||||
|
await page.click('#gitStatusBtn');
|
||||||
|
await page.waitForSelector('#gitStatusPanel.visible');
|
||||||
|
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||||
|
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||||
|
expect(body).toContain('main → origin/main');
|
||||||
|
expect(body).toContain('Uncommitted changes (2)');
|
||||||
|
expect(body).toMatch(/Not staged \(1\)/);
|
||||||
|
expect(body).toMatch(/Untracked \(1\)/);
|
||||||
|
expect(body).toContain('a.txt');
|
||||||
|
expect(body).toContain('new file.txt');
|
||||||
|
expect(body).toContain('Not pushed (1)');
|
||||||
|
expect(body).toContain('add b (not pushed)');
|
||||||
|
expect(await page.textContent('#gitStatusBranch')).toBe('main');
|
||||||
|
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hostile file names as text, never as markup', async () => {
|
||||||
|
write('<img src=x onerror=window.__pwned=1>.txt');
|
||||||
|
await refresh();
|
||||||
|
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
|
||||||
|
expect(await page.$('#gitStatusBody img')).toBeNull();
|
||||||
|
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a file shows its diff in the panel; Back returns to the list; Open file previews it', async () => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__previewed = [];
|
||||||
|
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
|
||||||
|
});
|
||||||
|
await page.click('.git-status-file:has-text("a.txt")');
|
||||||
|
await page.waitForSelector('#gitStatusBody .git-diff');
|
||||||
|
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('a.txt');
|
||||||
|
expect(await page.textContent('#gitStatusBody .git-diff-line--del')).toBe('-1\n');
|
||||||
|
expect(await page.textContent('#gitStatusBody .git-diff-line--add')).toBe('+2\n');
|
||||||
|
// The 15 s poll re-renders the panel; the diff must survive it.
|
||||||
|
await refresh();
|
||||||
|
expect(await page.$('#gitStatusBody .git-diff')).not.toBeNull();
|
||||||
|
await page.click('#gitStatusBody button:has-text("Open file")');
|
||||||
|
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
|
||||||
|
await page.click('#gitStatusBody button:has-text("Back")');
|
||||||
|
await page.waitForSelector('.git-status-file:has-text("a.txt")');
|
||||||
|
expect(await page.$('#gitStatusBody .git-diff')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an untracked file diffs as all additions, and a deleted file as all removals (with no Open file)', async () => {
|
||||||
|
await page.click('.git-status-file:has-text("new file.txt")');
|
||||||
|
await page.waitForSelector('#gitStatusBody .git-diff-line--add');
|
||||||
|
expect(await page.locator('#gitStatusBody .git-diff-line--del').count()).toBe(0);
|
||||||
|
await page.click('#gitStatusBody button:has-text("Back")');
|
||||||
|
rmSync(join(repo, 'b.txt'));
|
||||||
|
await refresh();
|
||||||
|
await page.waitForSelector('.git-status-file:has(.git-status-badge--D)');
|
||||||
|
await page.click('.git-status-file:has(.git-status-badge--D)');
|
||||||
|
await page.waitForSelector('#gitStatusBody .git-diff-line--del');
|
||||||
|
expect(await page.locator('#gitStatusBody .git-diff-line--add').count()).toBe(0);
|
||||||
|
expect(await page.locator('#gitStatusBody button:has-text("Open file")').count()).toBe(0);
|
||||||
|
await page.click('#gitStatusBody button:has-text("Back")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps keyboard focus on the same file row across the 15 s re-render', async () => {
|
||||||
|
await page.focus('.git-status-file:has-text("a.txt")');
|
||||||
|
const key = () => page.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.gitKey ?? null);
|
||||||
|
expect(await key()).toBe('unstaged|a.txt');
|
||||||
|
await refresh();
|
||||||
|
await page.waitForFunction(() => document.activeElement?.getAttribute('data-git-key') === 'unstaged|a.txt');
|
||||||
|
expect(await key()).toBe('unstaged|a.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the panel never starts off-screen, even on a 650px-wide viewport', async () => {
|
||||||
|
const original = page.viewportSize()!;
|
||||||
|
await page.setViewportSize({ width: 650, height: original.height });
|
||||||
|
const left = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||||
|
await page.setViewportSize(original);
|
||||||
|
expect(left).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drags by the header', async () => {
|
||||||
|
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||||
|
const box = (await page.locator('.git-status-header').boundingBox())!;
|
||||||
|
await page.mouse.move(box.x + 40, box.y + 10);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
|
||||||
|
expect(after).toBeLessThan(before - 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups files under folders that start collapsed and expand on click; the setting turns it off', async () => {
|
||||||
|
mkdirSync(join(repo, 'deep/er/still'), { recursive: true });
|
||||||
|
mkdirSync(join(repo, 'docs'));
|
||||||
|
write('deep/er/still/one.txt');
|
||||||
|
write('docs/a.md');
|
||||||
|
write('docs/b.md');
|
||||||
|
// git reports an all-untracked folder as ONE `dir/` entry, so commit these first and then edit them.
|
||||||
|
git(repo, 'add', 'deep', 'docs');
|
||||||
|
git(repo, 'commit', '-q', '-m', 'add folders');
|
||||||
|
write('deep/er/still/one.txt', 'changed\n');
|
||||||
|
write('docs/a.md', 'changed\n');
|
||||||
|
write('docs/b.md', 'changed\n');
|
||||||
|
await refresh();
|
||||||
|
await page.waitForSelector('.git-tree-dir');
|
||||||
|
// `deep/er/still` is a chain of single-child folders: one row, not three.
|
||||||
|
const names = await page.locator('.git-tree-name').allTextContents();
|
||||||
|
expect(names).toContain('deep/er/still/');
|
||||||
|
expect(names).toContain('docs/');
|
||||||
|
expect(await page.locator('.git-tree-dir[open]').count()).toBe(0);
|
||||||
|
expect(await page.locator('.git-status-file:has-text("one.txt")').isVisible()).toBe(false);
|
||||||
|
expect(await page.locator('.git-tree-dir:has(.git-tree-name:text-is("docs/")) .git-tree-count').textContent()).toBe(
|
||||||
|
'2'
|
||||||
|
);
|
||||||
|
await page.click('.git-tree-summary:has-text("docs/")');
|
||||||
|
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
|
||||||
|
// The open folder survives the re-render a refresh causes.
|
||||||
|
await refresh();
|
||||||
|
await page.waitForSelector('.git-tree-dir[open]');
|
||||||
|
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
|
||||||
|
// A file in a folder still opens its diff, and shows only its own name.
|
||||||
|
await page.click('.git-status-file:has-text("a.md")');
|
||||||
|
await page.waitForSelector('#gitStatusBody .git-diff-path');
|
||||||
|
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('docs/a.md');
|
||||||
|
await page.click('#gitStatusBody button:has-text("Back")');
|
||||||
|
|
||||||
|
// Setting off: the flat list, every file by its full path.
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
await page.click('label.switch:has(#appSettingsGitStatusTree)');
|
||||||
|
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||||
|
await page.evaluate(() => (window as any).app._renderGitStatusPanel());
|
||||||
|
expect(await page.locator('.git-tree-dir').count()).toBe(0);
|
||||||
|
expect(await page.locator('.git-status-path', { hasText: 'docs/a.md' }).count()).toBe(1);
|
||||||
|
// Back on for the rest of the file.
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
await page.click('label.switch:has(#appSettingsGitStatusTree)');
|
||||||
|
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||||
|
// gitStatusTree is per-device: it must never reach the strict PUT /api/settings (a 400 there is
|
||||||
|
// what an unstripped key looks like), and it must round-trip through the saved settings.
|
||||||
|
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
|
||||||
|
expect(await page.evaluate(() => (window as any).app.isGitStatusTree())).toBe(true);
|
||||||
|
rmSync(join(repo, 'deep'), { recursive: true });
|
||||||
|
rmSync(join(repo, 'docs'), { recursive: true });
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
|
||||||
|
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
|
||||||
|
git(repo, 'checkout', '-q', '--', '.');
|
||||||
|
git(repo, 'clean', '-fdq');
|
||||||
|
git(repo, 'push', '-q');
|
||||||
|
await refresh();
|
||||||
|
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
|
||||||
|
// next 15 s poll, which is what a cached answer would mean).
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
|
||||||
|
null,
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
|
||||||
|
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
|
||||||
|
);
|
||||||
|
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||||
|
expect(body).toContain('Nothing uncommitted.');
|
||||||
|
expect(body).toContain('Every commit on this branch is on a remote.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
|
||||||
|
await select(plainSession);
|
||||||
|
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||||
|
// The open panel must stop showing the previous repo at once, then say why there is nothing.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
|
||||||
|
await select(repoSession);
|
||||||
|
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
|
||||||
|
const parent = join(root, 'monorepo-ish');
|
||||||
|
mkdirSync(parent);
|
||||||
|
for (const name of ['api', 'web']) {
|
||||||
|
const r = join(parent, name);
|
||||||
|
mkdirSync(r);
|
||||||
|
git(r, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(r, 'f.txt'), '1\n');
|
||||||
|
git(r, 'add', '-A');
|
||||||
|
git(r, 'commit', '-q', '-m', 'init');
|
||||||
|
}
|
||||||
|
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
|
||||||
|
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
|
||||||
|
const multiSession = await createSession(parent);
|
||||||
|
await select(multiSession);
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
|
||||||
|
expect(await label()).toContain('● 2'); // only api is dirty, with two files
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
|
||||||
|
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
|
||||||
|
expect(names).toEqual(['api', 'web']);
|
||||||
|
// Every repository starts collapsed (the summary line shows what is outstanding); one the user
|
||||||
|
// opens stays open when a refresh re-renders the panel.
|
||||||
|
const allClosed = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
|
||||||
|
expect(allClosed).toEqual([false, false]);
|
||||||
|
await page.click('.git-status-repo:nth-of-type(1) > summary');
|
||||||
|
await refresh();
|
||||||
|
await page.waitForSelector('.git-status-repo[open]');
|
||||||
|
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
|
||||||
|
expect(open).toEqual([true, false]);
|
||||||
|
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
|
||||||
|
expect(apiBody).toContain('dirty.txt');
|
||||||
|
expect(apiBody).toContain('dirty2.txt');
|
||||||
|
await select(repoSession);
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turning the setting off while a read is in flight, then on again, does not leave polling dead', async () => {
|
||||||
|
let slow = true;
|
||||||
|
await page.route('**/api/sessions/*/git-status*', async (route) => {
|
||||||
|
if (slow) await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
page.setDefaultTimeout(6000);
|
||||||
|
// A background poll may be mid-read: let it finish so OUR read is the one the slow route holds.
|
||||||
|
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === false);
|
||||||
|
await page.evaluate(() => void (window as any).app.refreshGitStatus({ fresh: true }));
|
||||||
|
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === true);
|
||||||
|
await setSetting(false);
|
||||||
|
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
|
||||||
|
slow = false;
|
||||||
|
await setSetting(true);
|
||||||
|
// Re-enabling starts its own read. With the flag stuck true that read is skipped for this session
|
||||||
|
// and the indicator never comes back.
|
||||||
|
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
|
||||||
|
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
// The first read may still be asleep in the handler: wait for it to continue, or its late
|
||||||
|
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
|
||||||
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
expect(await buttonVisible()).toBe(true);
|
||||||
|
// Turning the setting off closed the panel; reopen it for the tests that follow.
|
||||||
|
await page.click('#gitStatusBtn');
|
||||||
|
await page.waitForSelector('#gitStatusPanel.visible');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
||||||
|
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||||
|
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||||
|
await page.click('#gitStatusBtn');
|
||||||
|
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
|
||||||
|
await setSetting(false);
|
||||||
|
expect(await buttonVisible()).toBe(false);
|
||||||
|
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||||
|
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
|
||||||
|
const before = gitStatusRequests.length;
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
expect(gitStatusRequests.length).toBe(before);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearGitStatusCache,
|
||||||
|
getGitWorkspaceStatus,
|
||||||
|
MAX_FILES,
|
||||||
|
parseCommitLog,
|
||||||
|
parsePorcelainV2,
|
||||||
|
runGit,
|
||||||
|
type GitRunner,
|
||||||
|
} from '../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
const NUL = '\0';
|
||||||
|
|
||||||
|
describe('parsePorcelainV2', () => {
|
||||||
|
const header = (extra: string[] = []) =>
|
||||||
|
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
|
||||||
|
NUL
|
||||||
|
) + NUL;
|
||||||
|
|
||||||
|
it('reads the branch, upstream and ahead/behind', () => {
|
||||||
|
const p = parsePorcelainV2(header());
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
branch: 'main',
|
||||||
|
detached: false,
|
||||||
|
upstream: 'origin/main',
|
||||||
|
ahead: 2,
|
||||||
|
behind: 1,
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an upstream whose remote branch is gone: branch.upstream without branch.ab', () => {
|
||||||
|
expect(
|
||||||
|
parsePorcelainV2(['# branch.oid x', '# branch.head feature', '# branch.upstream origin/feature'].join(NUL) + NUL)
|
||||||
|
).toMatchObject({ upstream: 'origin/feature', upstreamGone: true });
|
||||||
|
expect(
|
||||||
|
parsePorcelainV2(
|
||||||
|
['# branch.oid x', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +0 -0'].join(NUL) + NUL
|
||||||
|
)
|
||||||
|
).toMatchObject({ upstreamGone: false });
|
||||||
|
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL).upstreamGone).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
|
||||||
|
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
|
||||||
|
branch: null,
|
||||||
|
detached: true,
|
||||||
|
upstream: null,
|
||||||
|
ahead: 0,
|
||||||
|
});
|
||||||
|
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
|
||||||
|
branch: 'feature',
|
||||||
|
upstream: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
|
||||||
|
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
|
||||||
|
const files = parsePorcelainV2(header([line])).files;
|
||||||
|
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
|
||||||
|
['staged', 'M', 'M', 'src/a.ts'],
|
||||||
|
['unstaged', 'M', 'M', 'src/a.ts'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies staged-only, unstaged-only, added and deleted', () => {
|
||||||
|
const lines = [
|
||||||
|
'1 M. N... 100644 100644 100644 a b staged.ts',
|
||||||
|
'1 .M N... 100644 100644 100644 a b unstaged.ts',
|
||||||
|
'1 A. N... 000000 100644 100644 0 b added.ts',
|
||||||
|
'1 .D N... 100644 100644 000000 a b gone.ts',
|
||||||
|
];
|
||||||
|
const files = parsePorcelainV2(header(lines)).files;
|
||||||
|
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
|
||||||
|
'staged:M.:staged.ts',
|
||||||
|
'unstaged:.M:unstaged.ts',
|
||||||
|
'staged:A.:added.ts',
|
||||||
|
'unstaged:.D:gone.ts',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a rename with its original path from the following token', () => {
|
||||||
|
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
|
||||||
|
expect(parsePorcelainV2(text).files).toEqual([
|
||||||
|
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
|
||||||
|
const text = header([
|
||||||
|
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
|
||||||
|
'? with space.txt',
|
||||||
|
'? quote"and\'tick.txt',
|
||||||
|
'? new\nline.txt',
|
||||||
|
'? dir/',
|
||||||
|
]);
|
||||||
|
const files = parsePorcelainV2(text).files;
|
||||||
|
expect(files.map((f) => [f.kind, f.path])).toEqual([
|
||||||
|
['conflicted', 'conflict.ts'],
|
||||||
|
['untracked', 'with space.txt'],
|
||||||
|
['untracked', 'quote"and\'tick.txt'],
|
||||||
|
['untracked', 'new\nline.txt'],
|
||||||
|
['untracked', 'dir/'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown lines and survives empty input', () => {
|
||||||
|
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
|
||||||
|
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCommitLog', () => {
|
||||||
|
it('reads hash, author, time and subject, including unicode and empty input', () => {
|
||||||
|
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
|
||||||
|
expect(parseCommitLog(text)).toEqual([
|
||||||
|
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
|
||||||
|
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
|
||||||
|
]);
|
||||||
|
expect(parseCommitLog('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real git
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GIT_ENV = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'T',
|
||||||
|
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||||
|
GIT_COMMITTER_NAME: 'T',
|
||||||
|
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
};
|
||||||
|
const git = (cwd: string, ...args: string[]): string =>
|
||||||
|
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
|
||||||
|
cwd,
|
||||||
|
env: GIT_ENV,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
let repo: string;
|
||||||
|
const write = (rel: string, text = 'x\n', dir = repo) => {
|
||||||
|
const f = join(dir, rel);
|
||||||
|
mkdirSync(join(f, '..'), { recursive: true });
|
||||||
|
writeFileSync(f, text);
|
||||||
|
};
|
||||||
|
const commit = (msg: string, dir = repo) => {
|
||||||
|
git(dir, 'add', '-A');
|
||||||
|
git(dir, 'commit', '-q', '-m', msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearGitStatusCache();
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'git-status-'));
|
||||||
|
repo = join(root, 'repo');
|
||||||
|
mkdirSync(repo);
|
||||||
|
git(repo, 'init', '-q', '-b', 'main');
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('getGitWorkspaceStatus against a real repository', () => {
|
||||||
|
it('is not-a-repo outside a repository', async () => {
|
||||||
|
const plain = join(root, 'plain');
|
||||||
|
mkdirSync(plain);
|
||||||
|
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
|
||||||
|
const s = await getGitWorkspaceStatus(join(root, 'gone'));
|
||||||
|
expect(['not-a-repo', 'error']).toContain(s.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
|
||||||
|
write('a.txt');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
|
||||||
|
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
|
||||||
|
write('a.txt');
|
||||||
|
commit('one');
|
||||||
|
write('b.txt');
|
||||||
|
commit('two');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.counts.uncommitted).toBe(0);
|
||||||
|
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
|
||||||
|
write('tracked.txt', '1\n');
|
||||||
|
write('both.txt', '1\n');
|
||||||
|
write('removed.txt', '1\n');
|
||||||
|
commit('base');
|
||||||
|
write('both.txt', '2\n');
|
||||||
|
git(repo, 'add', 'both.txt');
|
||||||
|
write('both.txt', '3\n'); // staged AND modified again
|
||||||
|
write('tracked.txt', '2\n'); // unstaged only
|
||||||
|
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
|
||||||
|
write('new.txt'); // untracked
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
|
||||||
|
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
|
||||||
|
expect(row('staged', 'both.txt')).toBeTruthy();
|
||||||
|
expect(row('unstaged', 'both.txt')).toBeTruthy();
|
||||||
|
expect(row('staged', 'removed.txt')?.index).toBe('D');
|
||||||
|
expect(row('untracked', 'new.txt')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a staged rename with its original path', async () => {
|
||||||
|
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
|
||||||
|
commit('base');
|
||||||
|
git(repo, 'mv', 'old.txt', 'new.txt');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.files).toContainEqual(
|
||||||
|
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports merge conflicts', async () => {
|
||||||
|
write('c.txt', 'base\n');
|
||||||
|
commit('base');
|
||||||
|
git(repo, 'checkout', '-q', '-b', 'other');
|
||||||
|
write('c.txt', 'other\n');
|
||||||
|
commit('other');
|
||||||
|
git(repo, 'checkout', '-q', 'main');
|
||||||
|
write('c.txt', 'main\n');
|
||||||
|
commit('main');
|
||||||
|
expect(() => git(repo, 'merge', 'other')).toThrow();
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.counts.conflicted).toBe(1);
|
||||||
|
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles file names with spaces, quotes and unicode', async () => {
|
||||||
|
write('with space.txt');
|
||||||
|
write('quote"d.txt');
|
||||||
|
write('café ☕.txt');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the file list but keeps the counts exact', async () => {
|
||||||
|
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s.files).toHaveLength(MAX_FILES);
|
||||||
|
expect(s.filesTruncated).toBe(true);
|
||||||
|
expect(s.counts.untracked).toBe(MAX_FILES + 20);
|
||||||
|
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts stashes', async () => {
|
||||||
|
write('a.txt', '1\n');
|
||||||
|
commit('base');
|
||||||
|
write('a.txt', '2\n');
|
||||||
|
git(repo, 'stash', '-q');
|
||||||
|
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a detached HEAD', async () => {
|
||||||
|
write('a.txt');
|
||||||
|
commit('one');
|
||||||
|
git(repo, 'checkout', '-q', '--detach');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ detached: true, branch: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with a remote', () => {
|
||||||
|
let bare: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
bare = join(root, 'origin.git');
|
||||||
|
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
||||||
|
git(repo, 'remote', 'add', 'origin', bare);
|
||||||
|
write('a.txt', '1\n');
|
||||||
|
commit('first');
|
||||||
|
git(repo, 'push', '-q', '-u', 'origin', 'main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in sync: nothing ahead, nothing unpushed', async () => {
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
|
||||||
|
write('b.txt');
|
||||||
|
commit('second: add b');
|
||||||
|
write('c.txt');
|
||||||
|
commit('third: add c');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
|
||||||
|
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
|
||||||
|
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
|
||||||
|
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
|
||||||
|
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behind reflects the last fetch only: it never fetches on its own', async () => {
|
||||||
|
const other = join(root, 'other');
|
||||||
|
git(root, 'clone', '-q', bare, other);
|
||||||
|
write('theirs.txt', 'x\n', other);
|
||||||
|
commit('theirs', other);
|
||||||
|
git(other, 'push', '-q', 'origin', 'main');
|
||||||
|
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
|
||||||
|
clearGitStatusCache();
|
||||||
|
git(repo, 'fetch', '-q');
|
||||||
|
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a branch with no upstream lists what no remote has', async () => {
|
||||||
|
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||||
|
write('f1.txt');
|
||||||
|
commit('f1');
|
||||||
|
write('f2.txt');
|
||||||
|
commit('f2');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
|
||||||
|
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a branch whose upstream was deleted and pruned is NOT reported as everything pushed', async () => {
|
||||||
|
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||||
|
write('f1.txt');
|
||||||
|
commit('f1');
|
||||||
|
git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
||||||
|
git(repo, 'push', '-q', 'origin', '--delete', 'feature');
|
||||||
|
git(repo, 'fetch', '-q', '--prune');
|
||||||
|
write('f2.txt');
|
||||||
|
commit('f2');
|
||||||
|
const s = await getGitWorkspaceStatus(repo);
|
||||||
|
expect(s).toMatchObject({ branch: 'feature', upstream: 'origin/feature', upstreamGone: true });
|
||||||
|
expect(s.unpushedCount).toBe(2);
|
||||||
|
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
|
||||||
|
git(repo, 'checkout', '-q', '-b', 'feature');
|
||||||
|
write('f1.txt');
|
||||||
|
commit('f1');
|
||||||
|
git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
||||||
|
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('it only reads', () => {
|
||||||
|
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
|
||||||
|
write('a.txt', '1\n');
|
||||||
|
commit('base');
|
||||||
|
const before = readFileSync(join(repo, '.git', 'index'));
|
||||||
|
// Touching a tracked file makes a plain `git status` want to refresh the index.
|
||||||
|
const now = new Date();
|
||||||
|
const { utimesSync } = await import('node:fs');
|
||||||
|
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
|
||||||
|
await getGitWorkspaceStatus(repo);
|
||||||
|
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
|
||||||
|
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
|
||||||
|
write('a.txt', '1\n');
|
||||||
|
commit('base');
|
||||||
|
const hook = join(root, 'fsmonitor.sh');
|
||||||
|
const marker = join(root, 'fsmonitor-ran');
|
||||||
|
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
|
||||||
|
chmodSync(hook, 0o755);
|
||||||
|
git(repo, 'config', 'core.fsmonitor', hook);
|
||||||
|
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
|
||||||
|
git(repo, 'status', '--short');
|
||||||
|
expect(existsSync(marker)).toBe(true);
|
||||||
|
rmSync(marker);
|
||||||
|
await getGitWorkspaceStatus(repo);
|
||||||
|
expect(existsSync(marker)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache and error mapping (fake runner)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('caching and failures', () => {
|
||||||
|
const okRunner = (calls: string[][] = []): GitRunner =>
|
||||||
|
vi.fn(async (_cwd, args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never runs a git command that writes or touches the network', async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
|
||||||
|
const verbs = new Set(calls.map((a) => a[0]));
|
||||||
|
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
|
||||||
|
expect(verbs.has(forbidden), forbidden).toBe(false);
|
||||||
|
}
|
||||||
|
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight computation between concurrent callers', async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const git = okRunner(calls);
|
||||||
|
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(b).toBe(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const git = okRunner(calls);
|
||||||
|
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
|
||||||
|
await get('/w/a');
|
||||||
|
t += 1000;
|
||||||
|
await get('/w/a');
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||||
|
await get('/w/b');
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||||
|
t += 10_000;
|
||||||
|
await get('/w/a');
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const git = okRunner(calls);
|
||||||
|
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
|
||||||
|
await get(false);
|
||||||
|
t += 500;
|
||||||
|
await get(false); // reused
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||||
|
await get(true); // a person pressed Refresh
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
|
||||||
|
clearGitStatusCache();
|
||||||
|
calls.length = 0;
|
||||||
|
await Promise.all([get(true), get(true), get(true)]);
|
||||||
|
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps "not a git repository" to not-a-repo', async () => {
|
||||||
|
const git: GitRunner = async () => {
|
||||||
|
throw Object.assign(new Error('x'), {
|
||||||
|
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing git binary and a timeout as short errors', async () => {
|
||||||
|
const enoent: GitRunner = async () => {
|
||||||
|
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||||
|
};
|
||||||
|
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
|
||||||
|
state: 'error',
|
||||||
|
error: expect.stringMatching(/not installed/),
|
||||||
|
});
|
||||||
|
const slow: GitRunner = async () => {
|
||||||
|
throw Object.assign(new Error('timed out'), { killed: true });
|
||||||
|
};
|
||||||
|
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
|
||||||
|
state: 'error',
|
||||||
|
error: 'git timed out',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts credentials embedded in a remote URL from an error message', async () => {
|
||||||
|
const git: GitRunner = async () => {
|
||||||
|
throw Object.assign(new Error('x'), {
|
||||||
|
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const s = await getGitWorkspaceStatus('/w/redact', { git });
|
||||||
|
expect(s.state).toBe('error');
|
||||||
|
expect(s.error).not.toContain('ghp_SECRET');
|
||||||
|
expect(s.error).toContain('***:***@');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives the secondary calls failing: status still comes back', async () => {
|
||||||
|
const git: GitRunner = async (_cwd, args) => {
|
||||||
|
if (args[0] === 'status')
|
||||||
|
return (
|
||||||
|
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
|
||||||
|
);
|
||||||
|
throw new Error('boom');
|
||||||
|
};
|
||||||
|
const s = await getGitWorkspaceStatus('/w/partial', { git });
|
||||||
|
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Which repositories: getGitWorkspaceOverview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
|
||||||
|
import {
|
||||||
|
discoverChildRepos,
|
||||||
|
getGitWorkspaceOverview,
|
||||||
|
isUnrelatedAncestor,
|
||||||
|
MAX_REPOS,
|
||||||
|
} from '../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
describe('getGitWorkspaceOverview', () => {
|
||||||
|
let top: string;
|
||||||
|
let home: string;
|
||||||
|
const repoAt = (p: string): string => {
|
||||||
|
mkdir(p, { recursive: true });
|
||||||
|
git(p, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(p, 'f.txt'), '1\n');
|
||||||
|
git(p, 'add', '-A');
|
||||||
|
git(p, 'commit', '-q', '-m', 'c');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
|
||||||
|
home = join(top, 'home');
|
||||||
|
mkdir(home, { recursive: true });
|
||||||
|
clearGitStatusCache();
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'web'));
|
||||||
|
repoAt(join(ws, 'api'));
|
||||||
|
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(o.state).toBe('ok');
|
||||||
|
expect(names(o)).toEqual(['api', 'web']);
|
||||||
|
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
|
||||||
|
expect(o.repos[0].status.counts.uncommitted).toBe(1);
|
||||||
|
expect(o.repos[1].status.counts.uncommitted).toBe(0);
|
||||||
|
expect(o.reposTruncated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds repositories two levels down but not three', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'apps', 'web'));
|
||||||
|
repoAt(join(ws, 'a', 'b', 'too-deep'));
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(names(o)).toEqual([join('apps', 'web')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
|
||||||
|
const r = repoAt(join(home, 'proj'));
|
||||||
|
mkdir(join(r, 'src', 'deep'), { recursive: true });
|
||||||
|
writeFileSync(join(r, 'top.txt'), 'x');
|
||||||
|
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
|
||||||
|
expect(o.repos).toHaveLength(1);
|
||||||
|
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
|
||||||
|
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
|
||||||
|
const r = repoAt(join(home, 'outer'));
|
||||||
|
repoAt(join(r, 'vendor-ish', 'inner'));
|
||||||
|
const o = await getGitWorkspaceOverview(r, { home });
|
||||||
|
expect(names(o)).toEqual(['.']);
|
||||||
|
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a session started inside the nested repository reports that one', async () => {
|
||||||
|
const r = repoAt(join(home, 'outer'));
|
||||||
|
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
|
||||||
|
const o = await getGitWorkspaceOverview(inner, { home });
|
||||||
|
expect(o.repos[0].name).toBe('inner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
|
||||||
|
repoAt(home);
|
||||||
|
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
|
||||||
|
const ws = join(home, 'codeman-cases', 'my-case');
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
const none = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(none.state).toBe('not-a-repo');
|
||||||
|
// ...but a project below the workspace is still found.
|
||||||
|
repoAt(join(ws, 'real-project'));
|
||||||
|
clearGitStatusCache();
|
||||||
|
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
|
||||||
|
expect(names(some)).toEqual(['real-project']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
|
||||||
|
repoAt(home);
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'proj'));
|
||||||
|
const calls: Array<[string, string]> = [];
|
||||||
|
const spy: GitRunner = async (cwd, args) => {
|
||||||
|
calls.push([cwd, args[0]]);
|
||||||
|
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
|
||||||
|
return runGit(cwd, args);
|
||||||
|
};
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
|
||||||
|
expect(o.state).toBe('ok');
|
||||||
|
expect(names(o)).toEqual(['proj']);
|
||||||
|
// The dotfiles repository costs one rev-parse, never a full status.
|
||||||
|
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
|
||||||
|
repoAt(top); // contains `home`, so it is above it
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
|
||||||
|
repoAt(home);
|
||||||
|
const o = await getGitWorkspaceOverview(home, { home });
|
||||||
|
expect(o.state).toBe('ok');
|
||||||
|
expect(o.repos[0].path).toBe('.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
|
||||||
|
const r = repoAt(join(home, 'proj'));
|
||||||
|
mkdir(join(r, 'sub'), { recursive: true });
|
||||||
|
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
|
||||||
|
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
|
||||||
|
clearGitStatusCache();
|
||||||
|
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'node_modules', 'pkg'));
|
||||||
|
repoAt(join(ws, '.hidden', 'secret'));
|
||||||
|
const outside = repoAt(join(top, 'outside'));
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
symlink(outside, join(ws, 'linked'));
|
||||||
|
repoAt(join(ws, 'real'));
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(names(o)).toEqual(['real']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a worktree (its .git is a file) as a repository', async () => {
|
||||||
|
const main = repoAt(join(top, 'main-repo'));
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(names(o)).toEqual(['wt']);
|
||||||
|
expect(o.repos[0].status.branch).toBe('feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps how many repositories it reports and says so', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home });
|
||||||
|
expect(o.repos).toHaveLength(MAX_REPOS);
|
||||||
|
expect(o.reposTruncated).toBe(true);
|
||||||
|
expect(names(o)[0]).toBe('p00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
|
||||||
|
const ws = join(home, 'empty');
|
||||||
|
mkdir(join(ws, 'a'), { recursive: true });
|
||||||
|
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
|
||||||
|
clearGitStatusCache(); // the answer above is cached for this folder
|
||||||
|
const broken: GitRunner = async () => {
|
||||||
|
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
||||||
|
};
|
||||||
|
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
repoAt(join(ws, 'one'));
|
||||||
|
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
|
||||||
|
expect(names(await get())).toEqual(['one']);
|
||||||
|
repoAt(join(ws, 'two'));
|
||||||
|
t += 5000;
|
||||||
|
expect(names(await get())).toEqual(['one']); // list reused
|
||||||
|
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
|
||||||
|
t += 60_000;
|
||||||
|
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discoverChildRepos never reads below a repository it found', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'outer'));
|
||||||
|
repoAt(join(ws, 'outer', 'inner'));
|
||||||
|
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
|
||||||
|
mkdir(join(home, 'proj'), { recursive: true });
|
||||||
|
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
|
||||||
|
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
|
||||||
|
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
|
||||||
|
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
|
||||||
|
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { MAX_DIFF_BYTES, getGitFileDiff, isSafeRepoRelativePath } from '../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
describe('isSafeRepoRelativePath', () => {
|
||||||
|
it.each([
|
||||||
|
['a.txt', true],
|
||||||
|
['src/deep/x.ts', true],
|
||||||
|
['', false],
|
||||||
|
['-rf', true], // every operand follows `--`, so a leading dash is just a name
|
||||||
|
['-', true],
|
||||||
|
['/etc/passwd', false],
|
||||||
|
['../x', false],
|
||||||
|
['a/../../x', false],
|
||||||
|
['a\0b', false],
|
||||||
|
])('%j -> %s', (p, ok) => expect(isSafeRepoRelativePath(p)).toBe(ok));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGitFileDiff', () => {
|
||||||
|
it('refuses an unsafe path without running git', async () => {
|
||||||
|
const git = vi.fn(async () => '');
|
||||||
|
await expect(getGitFileDiff('/r', { path: '../x', kind: 'unstaged' }, { git })).rejects.toThrow('Invalid path');
|
||||||
|
expect(git).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds read-only, option-injection-safe commands per kind', async () => {
|
||||||
|
const git = vi.fn(async (_cwd: string, _args: string[]) => '');
|
||||||
|
await getGitFileDiff('/r', { path: 'a.txt', kind: 'unstaged' }, { git });
|
||||||
|
await getGitFileDiff('/r', { path: 'b.txt', origPath: 'old.txt', kind: 'staged' }, { git });
|
||||||
|
const [unstaged, staged] = git.mock.calls.map((c) => c[1]);
|
||||||
|
for (const args of [unstaged, staged]) {
|
||||||
|
expect(args).toContain('--no-ext-diff');
|
||||||
|
expect(args).toContain('--no-textconv');
|
||||||
|
expect(args.indexOf('--')).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(unstaged.slice(-2)).toEqual(['--', 'a.txt']);
|
||||||
|
expect(staged).toContain('--cached');
|
||||||
|
expect(staged.slice(-3)).toEqual(['--', 'old.txt', 'b.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats --no-index exit 1 as the normal untracked result', async () => {
|
||||||
|
const git = vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error('exit 1'), { code: 1, stdout: '+hello\n' });
|
||||||
|
});
|
||||||
|
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git })).resolves.toMatchObject({
|
||||||
|
diff: '+hello\n',
|
||||||
|
});
|
||||||
|
const boom = vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error('exit 128'), { code: 128, stdout: '' });
|
||||||
|
});
|
||||||
|
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git: boom })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags binary output and cuts an oversized diff at a line boundary', async () => {
|
||||||
|
const bin = await getGitFileDiff(
|
||||||
|
'/r',
|
||||||
|
{ path: 'x.png', kind: 'unstaged' },
|
||||||
|
{ git: async () => 'Binary files a/x.png and b/x.png differ\n' }
|
||||||
|
);
|
||||||
|
expect(bin.binary).toBe(true);
|
||||||
|
const big = ('+' + 'x'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) + 50);
|
||||||
|
const cut = await getGitFileDiff('/r', { path: 'big', kind: 'unstaged' }, { git: async () => big });
|
||||||
|
expect(cut.truncated).toBe(true);
|
||||||
|
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||||
|
expect(cut.diff.endsWith('x')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
|
||||||
|
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
|
||||||
|
const git = vi.fn(async () => {
|
||||||
|
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
|
||||||
|
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||||
|
stdout: partial,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const kind of ['unstaged', 'untracked'] as const) {
|
||||||
|
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
|
||||||
|
expect(d.truncated).toBe(true);
|
||||||
|
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||||
|
expect(d.diff.endsWith('y')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
|
||||||
|
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
|
||||||
|
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
|
||||||
|
expect(d.truncated).toBe(true);
|
||||||
|
expect(d.diff.startsWith('diff --git')).toBe(true);
|
||||||
|
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Docker case workspaces are never inspected', () => {
|
||||||
|
let top: string;
|
||||||
|
let home: string;
|
||||||
|
const repoAt = (p: string): string => {
|
||||||
|
mkdir(p, { recursive: true });
|
||||||
|
git(p, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(p, 'f.txt'), '1\n');
|
||||||
|
git(p, 'add', '-A');
|
||||||
|
git(p, 'commit', '-q', '-m', 'c');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
/** A repository whose clean filter drops a marker file: proof that git ran on it. */
|
||||||
|
const booby = (p: string): string => {
|
||||||
|
repoAt(p);
|
||||||
|
git(p, 'config', 'filter.mark.clean', 'touch RAN; cat');
|
||||||
|
writeFileSync(join(p, '.gitattributes'), 'f.txt filter=mark\n');
|
||||||
|
// Same size as the committed '1\n', so git must read the content (running the filter) to see the change.
|
||||||
|
writeFileSync(join(p, 'f.txt'), 'x\n');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
top = mkdtempSync(join(tmpdir(), 'git-docker-'));
|
||||||
|
home = join(top, 'home');
|
||||||
|
mkdir(home, { recursive: true });
|
||||||
|
clearGitStatusCache();
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('control: without the exclusion git does run the repository’s clean filter', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
booby(join(ws, 'proj'));
|
||||||
|
await getGitWorkspaceOverview(ws, { home, git: undefined });
|
||||||
|
expect(existsSync(join(ws, 'proj', 'RAN'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a Docker workspace found below the folder, and runs nothing in it', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
booby(join(ws, 'sandbox'));
|
||||||
|
repoAt(join(ws, 'plain'));
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home, dockerWorkspaces: [join(ws, 'sandbox')] });
|
||||||
|
expect(o.repos.map((r) => r.path)).toEqual(['plain']);
|
||||||
|
expect(existsSync(join(ws, 'sandbox', 'RAN'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers unsupported/docker for a folder at or inside a Docker workspace, before any git runs', async () => {
|
||||||
|
const dock = booby(join(home, 'dock'));
|
||||||
|
mkdir(join(dock, 'sub'));
|
||||||
|
for (const cwd of [dock, join(dock, 'sub')]) {
|
||||||
|
clearGitStatusCache();
|
||||||
|
const git = vi.fn(async () => '');
|
||||||
|
const o = await getGitWorkspaceOverview(cwd, { home, git, dockerWorkspaces: [dock] });
|
||||||
|
expect(o).toMatchObject({ state: 'unsupported', reason: 'docker', repos: [] });
|
||||||
|
expect(git).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
expect(existsSync(join(dock, 'RAN'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
|
||||||
|
let t = 1_000_000;
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'a'));
|
||||||
|
repoAt(join(ws, 'b'));
|
||||||
|
const cwds: string[] = [];
|
||||||
|
const spy: GitRunner = (cwd, args) => {
|
||||||
|
cwds.push(cwd);
|
||||||
|
return runGit(cwd, args);
|
||||||
|
};
|
||||||
|
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
|
||||||
|
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
|
||||||
|
t += 5000; // past the status cache, well inside the list's 30 s
|
||||||
|
cwds.length = 0;
|
||||||
|
const o = await getGitWorkspaceOverview(ws, {
|
||||||
|
home,
|
||||||
|
git: spy,
|
||||||
|
now: () => t,
|
||||||
|
dockerWorkspaces: [join(ws, 'a')],
|
||||||
|
});
|
||||||
|
expect(o.repos.map((r) => r.path)).toEqual(['b']);
|
||||||
|
expect(cwds).not.toContain(join(ws, 'a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees through a symlink to the workspace', async () => {
|
||||||
|
const dock = booby(join(home, 'dock'));
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
mkdir(ws, { recursive: true });
|
||||||
|
symlink(dock, join(ws, 'link'));
|
||||||
|
const o = await getGitWorkspaceOverview(join(ws, 'link'), { home, dockerWorkspaces: [dock] });
|
||||||
|
expect(o.state).toBe('unsupported');
|
||||||
|
expect(existsSync(join(dock, 'RAN'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a folder next to the workspace, and one whose name merely starts the same, are not excluded', async () => {
|
||||||
|
const dock = join(home, 'dock');
|
||||||
|
const sibling = repoAt(join(home, 'dock-two'));
|
||||||
|
mkdir(dock, { recursive: true });
|
||||||
|
const o = await getGitWorkspaceOverview(sibling, { home, dockerWorkspaces: [dock] });
|
||||||
|
expect(o.state).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,7 +38,9 @@ describe('help modal shortcuts', () => {
|
|||||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||||
|
|
||||||
it('documents implemented global and tab shortcuts', () => {
|
it('documents implemented global and tab shortcuts', () => {
|
||||||
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
|
// Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
|
||||||
|
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
|
||||||
|
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
|
||||||
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
||||||
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
||||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => {
|
|||||||
dom.window.close();
|
dom.window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
|
||||||
|
const dom = makeDom('');
|
||||||
|
const api = dom.window.CodemanI18n;
|
||||||
|
api.configure({ language: 'zh-CN' });
|
||||||
|
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
|
||||||
|
api.t(label)
|
||||||
|
);
|
||||||
|
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
|
||||||
|
expect(new Set(labels).size).toBe(4);
|
||||||
|
dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders hostile-looking names as text rather than HTML', () => {
|
it('renders hostile-looking names as text rather than HTML', () => {
|
||||||
const dom = makeDom('<span class="logo">Codeman</span>');
|
const dom = makeDom('<span class="logo">Codeman</span>');
|
||||||
const api = dom.window.CodemanI18n;
|
const api = dom.window.CodemanI18n;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function loadImageInputApp() {
|
|||||||
app.activeSessionId = 'session-1';
|
app.activeSessionId = 'session-1';
|
||||||
app.showToast = vi.fn();
|
app.showToast = vi.fn();
|
||||||
app.sendInput = vi.fn(async () => {});
|
app.sendInput = vi.fn(async () => {});
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||||
return app as Record<string, any>;
|
return app as Record<string, any>;
|
||||||
@@ -199,6 +200,7 @@ describe('image upload insertion policy', () => {
|
|||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||||
expect(app.sendInput).not.toHaveBeenCalled();
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves terminal insertion by default', async () => {
|
it('preserves terminal insertion by default', async () => {
|
||||||
@@ -207,6 +209,33 @@ describe('image upload insertion policy', () => {
|
|||||||
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||||
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
// The same delivery sendInput() uses (durable queue, useMux for the POST
|
||||||
|
// fallback), but addressed to the session the batch was uploaded to.
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
// The user switches tabs while the upload is in flight. sendInput() re-read
|
||||||
|
// activeSessionId after the awaits, so the paths used to land in session-2.
|
||||||
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
|
||||||
|
app.activeSessionId = 'session-2';
|
||||||
|
return file.path;
|
||||||
|
});
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads to and inserts into an explicitly named session', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+471
-7
@@ -13,7 +13,8 @@
|
|||||||
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
|
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
|
||||||
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
|
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
|
||||||
*
|
*
|
||||||
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
|
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
|
||||||
|
* (per MEMORY.md, ports 3150+ for tests)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
|
|||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
const PORT = 3164;
|
const PORT = 3164;
|
||||||
|
const ORDERING_PORT = 3165;
|
||||||
|
const LONG_PREFIX_PORT = 3192;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
describe('Inline rename input', () => {
|
describe('Inline rename input', () => {
|
||||||
@@ -581,11 +584,20 @@ describe('Inline rename input', () => {
|
|||||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
|
// The rail has two row variants, and the detailed one clamps the name to
|
||||||
|
// three lines instead of two: the editor must come out unclamped in both,
|
||||||
|
// and cancelling must put back the clamp of the variant it was opened in.
|
||||||
|
it.each([
|
||||||
|
{ detail: 'simple', restoredClamp: '2' },
|
||||||
|
{ detail: 'rich', restoredClamp: '3' },
|
||||||
|
])(
|
||||||
|
'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel',
|
||||||
|
async ({ detail, restoredClamp }) => {
|
||||||
await resetState();
|
await resetState();
|
||||||
const id = 'vertical-live-input';
|
const id = `vertical-live-input-${detail}`;
|
||||||
|
|
||||||
await page.evaluate((sessionId) => {
|
await page.evaluate(
|
||||||
|
({ sessionId, detail }) => {
|
||||||
const app = (
|
const app = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
app: {
|
app: {
|
||||||
@@ -595,6 +607,7 @@ describe('Inline rename input', () => {
|
|||||||
}
|
}
|
||||||
).app;
|
).app;
|
||||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||||
|
document.documentElement.dataset.tabRailDetail = detail;
|
||||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||||
const tab = document.createElement('div');
|
const tab = document.createElement('div');
|
||||||
tab.setAttribute('data-test-tab', '1');
|
tab.setAttribute('data-test-tab', '1');
|
||||||
@@ -605,7 +618,9 @@ describe('Inline rename input', () => {
|
|||||||
rail.appendChild(tab);
|
rail.appendChild(tab);
|
||||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||||
app.startInlineRename(sessionId);
|
app.startInlineRename(sessionId);
|
||||||
}, id);
|
},
|
||||||
|
{ sessionId: id, detail }
|
||||||
|
);
|
||||||
|
|
||||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||||
const input = label.locator('input.tab-rename-input');
|
const input = label.locator('input.tab-rename-input');
|
||||||
@@ -622,13 +637,462 @@ describe('Inline rename input', () => {
|
|||||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||||
app._activeRename?.cancel();
|
app._activeRename?.cancel();
|
||||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||||
return {
|
const result = {
|
||||||
classActive: label.classList.contains('tab-name-renaming'),
|
classActive: label.classList.contains('tab-name-renaming'),
|
||||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||||
};
|
};
|
||||||
|
document.documentElement.dataset.tabOrientation = 'horizontal';
|
||||||
|
return result;
|
||||||
}, id);
|
}, id);
|
||||||
|
|
||||||
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
|
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two renames of one session can be in flight at once: commit, reopen the
|
||||||
|
* editor before the PUT answers, then commit or cancel again (or start a group
|
||||||
|
* rename, which cancels the session editor). The writes go out one at a time
|
||||||
|
* in the order they were made, and a confirmed write is applied locally even
|
||||||
|
* if the editor that made it has since been cancelled, so the tab never shows
|
||||||
|
* a name the server no longer holds.
|
||||||
|
*/
|
||||||
|
describe('Inline rename write ordering', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
type Pending = { body: string; resolve: (response: Response) => void };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(ORDERING_PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
typeof (window as { app?: unknown }).app !== 'undefined' &&
|
||||||
|
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
|
||||||
|
);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
/** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */
|
||||||
|
async function mount(id: string, name: string): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ id, name }) => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: {
|
||||||
|
_activeRename: { cancel: () => void } | null;
|
||||||
|
sessions: Map<string, { id: string; name: string; status: string }>;
|
||||||
|
sessionOrder: string[];
|
||||||
|
};
|
||||||
|
__pending: Array<{ body: string; resolve: (response: Response) => void }>;
|
||||||
|
__origFetch?: typeof window.fetch;
|
||||||
|
};
|
||||||
|
w.app._activeRename?.cancel();
|
||||||
|
w.app.sessions.clear();
|
||||||
|
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
|
||||||
|
w.app.sessions.set(id, { id, name, status: 'idle' });
|
||||||
|
w.app.sessionOrder = [id];
|
||||||
|
const tab = document.createElement('div');
|
||||||
|
tab.setAttribute('data-test-tab', '1');
|
||||||
|
tab.className = 'session-tab';
|
||||||
|
tab.dataset.id = id;
|
||||||
|
tab.innerHTML = `<span class="tab-info"><span class="tab-name" data-session-id="${id}">${name}</span></span>`;
|
||||||
|
(document.getElementById('sessionTabs') as HTMLElement).appendChild(tab);
|
||||||
|
w.__pending = [];
|
||||||
|
w.__origFetch ??= window.fetch;
|
||||||
|
const passThrough = w.__origFetch;
|
||||||
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init);
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
w.__pending.push({ body: String(init?.body ?? ''), resolve });
|
||||||
|
});
|
||||||
|
}) as typeof window.fetch;
|
||||||
|
},
|
||||||
|
{ id, name }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreFetch(): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as { __origFetch?: typeof window.fetch };
|
||||||
|
if (w.__origFetch) window.fetch = w.__origFetch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(id: string, value: string | null): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
async ({ id, value }) => {
|
||||||
|
const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app;
|
||||||
|
if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) {
|
||||||
|
app.startInlineRename(id);
|
||||||
|
}
|
||||||
|
const input = document.querySelector(
|
||||||
|
`.tab-name[data-session-id="${id}"] input.tab-rename-input`
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (value !== null) input.value = value;
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
},
|
||||||
|
{ id, value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answer(index: number, name: string): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
async ({ index, body }) => {
|
||||||
|
const w = window as unknown as { __pending: Pending[] };
|
||||||
|
w.__pending[index]?.resolve(
|
||||||
|
new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
},
|
||||||
|
{ index, body: JSON.stringify({ success: true, data: { name } }) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state(id: string) {
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: { sessions: Map<string, { name: string }>; _activeRename: unknown };
|
||||||
|
__pending: Pending[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
bodies: w.__pending.map(({ body }) => JSON.parse(body).name),
|
||||||
|
mapName: w.app.sessions.get(id)?.name ?? null,
|
||||||
|
renameActive: !!w.app._activeRename,
|
||||||
|
};
|
||||||
|
}, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends successive renames of one session one at a time, in the order they were made', async () => {
|
||||||
|
await mount('order', 'Old');
|
||||||
|
await commit('order', 'First');
|
||||||
|
await commit('order', 'Second');
|
||||||
|
expect((await state('order')).bodies).toEqual(['First']);
|
||||||
|
|
||||||
|
await answer(0, 'First');
|
||||||
|
expect((await state('order')).bodies).toEqual(['First', 'Second']);
|
||||||
|
await answer(1, 'Second');
|
||||||
|
await restoreFetch();
|
||||||
|
expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => {
|
||||||
|
await mount('reopen', 'Old');
|
||||||
|
await commit('reopen', 'First');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
|
||||||
|
).app;
|
||||||
|
app.startInlineRename('reopen');
|
||||||
|
app._activeRename.cancel();
|
||||||
|
});
|
||||||
|
await answer(0, 'First');
|
||||||
|
await restoreFetch();
|
||||||
|
expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens the editor on the name still in flight, so confirming it unchanged keeps the rename', async () => {
|
||||||
|
await mount('stale', 'Old');
|
||||||
|
await commit('stale', 'First');
|
||||||
|
// The PUT for "First" has not answered, so app.sessions still says "Old".
|
||||||
|
// The reopened editor must show "First", the user's last word, and an
|
||||||
|
// untouched confirm must not queue "Old" behind it.
|
||||||
|
const reopenedValue = await page.evaluate(() => {
|
||||||
|
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('stale');
|
||||||
|
return (document.querySelector('.tab-name[data-session-id="stale"] input.tab-rename-input') as HTMLInputElement)
|
||||||
|
.value;
|
||||||
|
});
|
||||||
|
expect(reopenedValue).toBe('First');
|
||||||
|
await commit('stale', null);
|
||||||
|
await answer(0, 'First');
|
||||||
|
await restoreFetch();
|
||||||
|
expect(await state('stale')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { app: { _inlineRenamePending?: Map<string, string> } }).app._inlineRenamePending?.has(
|
||||||
|
'stale'
|
||||||
|
) ?? false
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the in-flight name when a reopened editor is confirmed unchanged, before the PUT lands', async () => {
|
||||||
|
await mount('shown', 'Old');
|
||||||
|
await commit('shown', 'First');
|
||||||
|
// Reopen while the PUT for "First" is held, then confirm it untouched. The
|
||||||
|
// label must read "First" now, not the "Old" the cancelled editor
|
||||||
|
// repainted from app.sessions.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('shown')
|
||||||
|
);
|
||||||
|
await commit('shown', null);
|
||||||
|
const label = await page.evaluate(
|
||||||
|
() => (document.querySelector('.tab-name[data-session-id="shown"]') as HTMLElement).textContent
|
||||||
|
);
|
||||||
|
expect((await state('shown')).bodies).toEqual(['First']);
|
||||||
|
expect(label).toBe('First');
|
||||||
|
await answer(0, 'First');
|
||||||
|
await restoreFetch();
|
||||||
|
expect(await state('shown')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a failed write even after its editor is gone', async () => {
|
||||||
|
await mount('fail-late', 'Old');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: { showToast: (message: string, type?: string) => void };
|
||||||
|
__toasts: string[];
|
||||||
|
__origToast?: (message: string, type?: string) => void;
|
||||||
|
};
|
||||||
|
w.__toasts = [];
|
||||||
|
w.__origToast = w.app.showToast;
|
||||||
|
w.app.showToast = (message: string) => {
|
||||||
|
w.__toasts.push(message);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await commit('fail-late', 'First');
|
||||||
|
// Reopen and dismiss: the editor that made the write is gone.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
|
||||||
|
).app;
|
||||||
|
app.startInlineRename('fail-late');
|
||||||
|
app._activeRename.cancel();
|
||||||
|
});
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const w = window as unknown as { __pending: Pending[] };
|
||||||
|
w.__pending[0]?.resolve(
|
||||||
|
new Response(JSON.stringify({ success: false, error: 'boom' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
});
|
||||||
|
await restoreFetch();
|
||||||
|
const toasts = await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: { showToast: unknown };
|
||||||
|
__toasts: string[];
|
||||||
|
__origToast?: unknown;
|
||||||
|
};
|
||||||
|
w.app.showToast = w.__origToast;
|
||||||
|
return w.__toasts;
|
||||||
|
});
|
||||||
|
expect(toasts).toEqual(['Failed to rename']);
|
||||||
|
expect(await state('fail-late')).toEqual({ bodies: ['First'], mapName: 'Old', renameActive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sending a session renames after the work following a PUT throws', async () => {
|
||||||
|
await mount('throws', 'Old');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: { updateSubagentParentNames?: (id: string) => void };
|
||||||
|
__origParentNames?: (id: string) => void;
|
||||||
|
__throwOnce: boolean;
|
||||||
|
};
|
||||||
|
w.__origParentNames = w.app.updateSubagentParentNames;
|
||||||
|
w.__throwOnce = true;
|
||||||
|
w.app.updateSubagentParentNames = (id: string) => {
|
||||||
|
if (w.__throwOnce) {
|
||||||
|
w.__throwOnce = false;
|
||||||
|
throw new Error('forced');
|
||||||
|
}
|
||||||
|
w.__origParentNames?.call(w.app, id);
|
||||||
|
};
|
||||||
|
window.addEventListener('unhandledrejection', (event) => event.preventDefault(), { once: true });
|
||||||
|
});
|
||||||
|
await commit('throws', 'First');
|
||||||
|
await answer(0, 'First');
|
||||||
|
await commit('throws', 'Second');
|
||||||
|
await answer(1, 'Second');
|
||||||
|
await restoreFetch();
|
||||||
|
const leftover = await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: { updateSubagentParentNames?: unknown; _inlineRenameWrites?: Map<string, unknown> };
|
||||||
|
__origParentNames?: unknown;
|
||||||
|
};
|
||||||
|
w.app.updateSubagentParentNames = w.__origParentNames;
|
||||||
|
return w.app._inlineRenameWrites?.has('throws') ?? false;
|
||||||
|
});
|
||||||
|
expect(await state('throws')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
|
||||||
|
expect(leftover).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the header strip editor shrink (inline min-width 0)', async () => {
|
||||||
|
await mount('header-width', 'Old');
|
||||||
|
const minWidth = await page.evaluate(() => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as {
|
||||||
|
app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } | null };
|
||||||
|
}
|
||||||
|
).app;
|
||||||
|
app.startInlineRename('header-width');
|
||||||
|
const input = document.querySelector(
|
||||||
|
'.tab-name[data-session-id="header-width"] input.tab-rename-input'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const value = input.style.minWidth;
|
||||||
|
app._activeRename?.cancel();
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
await restoreFetch();
|
||||||
|
expect(minWidth).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a confirmed session rename when a group rename takes over the editor', async () => {
|
||||||
|
await mount('to-group', 'Old');
|
||||||
|
await commit('to-group', 'Saved');
|
||||||
|
const groupStarted = await page.evaluate(() => {
|
||||||
|
const app = (
|
||||||
|
window as unknown as {
|
||||||
|
app: {
|
||||||
|
tabLayout: unknown;
|
||||||
|
startTabGroupRename: (groupId: string) => boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).app;
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.setAttribute('data-test-tab', '1');
|
||||||
|
section.innerHTML =
|
||||||
|
'<div class="tab-layout-group-header" data-tab-group-header="g1">' +
|
||||||
|
'<span class="tab-layout-group-name">Group</span></div>';
|
||||||
|
(document.getElementById('sessionTabs') as HTMLElement).appendChild(section);
|
||||||
|
(window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout;
|
||||||
|
app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] };
|
||||||
|
return app.startTabGroupRename('g1');
|
||||||
|
});
|
||||||
|
expect(groupStarted).toBe(true);
|
||||||
|
|
||||||
|
await answer(0, 'Saved');
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const w = window as unknown as {
|
||||||
|
app: {
|
||||||
|
sessions: Map<string, { name: string }>;
|
||||||
|
_inlineRenameActive: boolean;
|
||||||
|
_activeRename: { cancel: () => void } | null;
|
||||||
|
tabLayout: unknown;
|
||||||
|
};
|
||||||
|
__origLayout: unknown;
|
||||||
|
};
|
||||||
|
const groupInput = document.querySelector('.tab-layout-group-rename-input');
|
||||||
|
const result = {
|
||||||
|
mapName: w.app.sessions.get('to-group')?.name ?? null,
|
||||||
|
groupEditorOpen: !!groupInput?.isConnected,
|
||||||
|
guardHeld: w.app._inlineRenameActive,
|
||||||
|
};
|
||||||
|
w.app._activeRename?.cancel();
|
||||||
|
w.app.tabLayout = w.__origLayout;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await restoreFetch();
|
||||||
|
expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real rows, rendered by the app from a live session: a long `w<n>-<case>`
|
||||||
|
* prefix must not push the editor (or the prefix itself) out of the row in any
|
||||||
|
* rail variant. The prefix gives way first, with an ellipsis, and the input
|
||||||
|
* always keeps a usable width.
|
||||||
|
*/
|
||||||
|
describe('Vertical rail rename editor with a long prefix', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
const port = LONG_PREFIX_PORT;
|
||||||
|
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
|
||||||
|
let sessionId = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(port, false, true);
|
||||||
|
await server.start();
|
||||||
|
const res = await fetch(`http://localhost:${port}/api/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: NAME, mode: 'shell' }),
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } };
|
||||||
|
sessionId = created.data?.session?.id ?? created.data?.id ?? '';
|
||||||
|
expect(sessionId).not.toBe('');
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false },
|
||||||
|
{ variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false },
|
||||||
|
{ variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true },
|
||||||
|
{ variant: 'sidebar', settings: { sessionListLayout: 'sidebar' }, compact: false },
|
||||||
|
{ variant: 'detailed sidebar', settings: { sessionListLayout: 'sidebar-rich' }, compact: false },
|
||||||
|
])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => {
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
|
||||||
|
try {
|
||||||
|
await context.addInitScript(
|
||||||
|
(value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
|
||||||
|
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
|
||||||
|
await row.waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact);
|
||||||
|
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
const input = row.locator('input.tab-rename-input');
|
||||||
|
await input.press('Control+A');
|
||||||
|
await page.keyboard.type('typed live text');
|
||||||
|
expect(await input.inputValue()).toBe('typed live text');
|
||||||
|
|
||||||
|
const geometry = await row.evaluate((tab) => {
|
||||||
|
const box = (el: Element) => el.getBoundingClientRect();
|
||||||
|
const within = (inner: DOMRect, outer: DOMRect) =>
|
||||||
|
inner.left >= outer.left - 0.5 &&
|
||||||
|
inner.right <= outer.right + 0.5 &&
|
||||||
|
inner.top >= outer.top - 0.5 &&
|
||||||
|
inner.bottom <= outer.bottom + 0.5;
|
||||||
|
const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||||
|
const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement;
|
||||||
|
const info = tab.querySelector('.tab-info') as HTMLElement;
|
||||||
|
return {
|
||||||
|
focused: document.activeElement === input,
|
||||||
|
inputWidth: box(input).width,
|
||||||
|
prefixWidth: box(prefix).width,
|
||||||
|
inputInsideRow: within(box(input), box(info)),
|
||||||
|
prefixInsideRow: within(box(prefix), box(info)),
|
||||||
|
prefixEllipsis: getComputedStyle(prefix).textOverflow,
|
||||||
|
inlineMinWidth: input.style.minWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.focused).toBe(true);
|
||||||
|
expect(geometry.inputWidth).toBeGreaterThanOrEqual(64);
|
||||||
|
expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24);
|
||||||
|
expect(geometry.inputInsideRow).toBe(true);
|
||||||
|
expect(geometry.prefixInsideRow).toBe(true);
|
||||||
|
expect(geometry.prefixEllipsis).toBe('ellipsis');
|
||||||
|
// The floor is the editor's own inline style, not a stylesheet override.
|
||||||
|
expect(geometry.inlineMinWidth).toBe('4rem');
|
||||||
|
|
||||||
|
await input.press('Escape');
|
||||||
|
expect(await row.locator('input.tab-rename-input').count()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('drops a frame the WebSocket refused with an error ACK', () => {
|
it('drops a frame the WebSocket refused with an error ACK', () => {
|
||||||
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
|
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
|
||||||
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The durable input layer delivers over ANY registered terminal
|
||||||
|
* socket, not only the primary one (`_inputSocketFor`, app.js).
|
||||||
|
*
|
||||||
|
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
|
||||||
|
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
|
||||||
|
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
|
||||||
|
* bound to another session (the split pane's Pane B, later a grid tile) had no
|
||||||
|
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
|
||||||
|
* and no idle-alert acknowledgement. These tests pin the seam: a registered
|
||||||
|
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
|
||||||
|
* registration can never evict the socket that replaced it, and the sweep
|
||||||
|
* judges a socket's silence by that socket's own last frame.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function loadCodemanAppClass() {
|
||||||
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
localStorage: {
|
||||||
|
length: 0,
|
||||||
|
key: vi.fn(),
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodemanApp = loadCodemanAppClass();
|
||||||
|
|
||||||
|
type Frame = { t: string; d: string; seq?: number; cid?: string };
|
||||||
|
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
|
||||||
|
type Handle = { ws: FakeSocket; lastRecvAt: number };
|
||||||
|
type Rec = { seq: number; data: string; sentAt: number; tries: number };
|
||||||
|
|
||||||
|
type App = {
|
||||||
|
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||||
|
_sendInputEphemeral: (sessionId: string, input: string) => void;
|
||||||
|
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
|
||||||
|
_registerInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
|
||||||
|
_onWsReady: (sessionId: string) => void;
|
||||||
|
_redeliverSweep: () => void;
|
||||||
|
_pendingDeliveries: Map<string, Rec[]>;
|
||||||
|
_seqCounters: Map<string, number>;
|
||||||
|
_ws: FakeSocket | null;
|
||||||
|
_wsSessionId: string | null;
|
||||||
|
_wsLastRecvAt: number;
|
||||||
|
_reliableAckTimeoutMs: number;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.activeSessionId = 'primary';
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
app._reliableAckTimeoutMs = 4000;
|
||||||
|
return app as unknown as App;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSocket(frames: Frame[]): FakeSocket {
|
||||||
|
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a registered second socket joins the exactly-once queue', () => {
|
||||||
|
it('delivers seq-tagged frames over the registered socket for its session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'x');
|
||||||
|
app._sendInputAsync('other', 'y');
|
||||||
|
|
||||||
|
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['x', 1, 'c-test'],
|
||||||
|
['y', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
// Nothing for the other session leaks onto the primary socket.
|
||||||
|
expect(primaryFrames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prefers the primary socket for the primary session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
|
||||||
|
app._sendInputAsync('primary', 'a');
|
||||||
|
|
||||||
|
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
global.fetch = vi.fn(async (_url, init) => {
|
||||||
|
posts.push(JSON.parse(String(init?.body)));
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
});
|
||||||
|
const closing = { ...fakeSocket([]), readyState: 3 };
|
||||||
|
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
app._sendInputAsync('other', 'z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes ephemeral input over the registered socket too', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
|
||||||
|
|
||||||
|
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ACK routing', () => {
|
||||||
|
it('drops the record from the queue of the session the ACK arrived for', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
app._sendInputAsync('other', 'o');
|
||||||
|
|
||||||
|
// Both sessions issued seq 1. The ACK names no session, so the socket's own
|
||||||
|
// session decides which queue it drains; the primary's must be untouched.
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('other')).toBeUndefined();
|
||||||
|
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the primary socket session when no session is passed', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-sends everything pending over a freshly registered socket on ready', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const first: Frame[] = [];
|
||||||
|
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', firstHandle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
expect(first.map((f) => f.d)).toEqual(['a']);
|
||||||
|
|
||||||
|
// The socket dies without an ACK; a replacement opens and asks for a flush.
|
||||||
|
firstHandle.ws.readyState = 3;
|
||||||
|
const second: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
|
||||||
|
app._onWsReady('other');
|
||||||
|
|
||||||
|
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registration ownership', () => {
|
||||||
|
it('a stale handle cannot unregister the socket that replaced it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const newFrames: Frame[] = [];
|
||||||
|
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', oldHandle);
|
||||||
|
app._registerInputSocket('other', newHandle);
|
||||||
|
|
||||||
|
// The old socket's close lands late and tries to clean up after itself.
|
||||||
|
app._unregisterInputSocket('other', oldHandle);
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'still-here');
|
||||||
|
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the owning handle does unregister', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._unregisterInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const before = Date.now();
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the redelivery sweep judges each socket by its own last frame', () => {
|
||||||
|
function staleRecord(app: App, sessionId: string) {
|
||||||
|
const rec = app._pendingDeliveries.get(sessionId)![0];
|
||||||
|
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('force-closes a silent registered socket with a stale record', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
// The primary socket is chatty; that must not vouch for the other one.
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._wsLastRecvAt = Date.now();
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._ws.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-drives (does not close) a registered socket that is still receiving', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const frames: Frame[] = [];
|
||||||
|
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
// A SILENT primary must not condemn the live registered socket either.
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).not.toHaveBeenCalled();
|
||||||
|
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
|
|||||||
|
|
||||||
describe('the client lifts itself over the watermark', () => {
|
describe('the client lifts itself over the watermark', () => {
|
||||||
const handler = appSource.slice(
|
const handler = appSource.slice(
|
||||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||||
appSource.indexOf('/** Called from ws.onopen')
|
appSource.indexOf('/** Called from ws.onopen')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,28 @@
|
|||||||
* Port: N/A (app.inject).
|
* Port: N/A (app.inject).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
|
||||||
|
// network mount that went away), so the bounded path probe can be driven to its
|
||||||
|
// stall cap. Every other stat is the real one. The short timeout is read at import.
|
||||||
|
const deadMount = vi.hoisted(() => {
|
||||||
|
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
|
||||||
|
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
|
||||||
|
});
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
const stat = ((path: string, ...rest: unknown[]) => {
|
||||||
|
if (String(path).startsWith(deadMount.root + '/')) {
|
||||||
|
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
|
||||||
|
}
|
||||||
|
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
|
||||||
|
}) as typeof actual.stat;
|
||||||
|
return { ...actual, stat, default: { ...actual, stat } };
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
|||||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||||
import { isGitAvailable } from '../../src/git-clone.js';
|
import { isGitAvailable } from '../../src/git-clone.js';
|
||||||
|
import { probePath } from '../../src/utils/index.js';
|
||||||
|
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
|
||||||
|
|
||||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||||
const gitPresent = isGitAvailable();
|
const gitPresent = isGitAvailable();
|
||||||
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
|
|||||||
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
|
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
// Engage the probe's stall cap: every new bounded probe is now refused.
|
||||||
|
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
|
||||||
|
|
||||||
|
created.push('warns-under-cap');
|
||||||
|
const res = await clone({ name: 'warns-under-cap', repository: origin });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
|
||||||
|
} finally {
|
||||||
|
deadMount.releases.splice(0).forEach((release) => release());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
|
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
|
||||||
created.push('hooked');
|
created.push('hooked');
|
||||||
await clone({ name: 'hooked', repository: origin });
|
await clone({ name: 'hooked', repository: origin });
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
|
||||||
|
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
|
||||||
|
* Port: N/A (app.inject()).
|
||||||
|
*
|
||||||
|
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
|
||||||
|
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
|
||||||
|
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
|
||||||
|
* safeRmHomeTree, so a run can only ever delete what it created.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { basename, join } from 'node:path';
|
||||||
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||||
|
import { dataPath } from '../../src/config/instance.js';
|
||||||
|
import { safeRmHomeTree } from '../mocks/index.js';
|
||||||
|
|
||||||
|
const LINKED = () => dataPath('linked-cases.json');
|
||||||
|
const CASES_DIR = () => join(homedir(), 'codeman-cases');
|
||||||
|
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
|
||||||
|
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
|
||||||
|
let workDir = '';
|
||||||
|
const work = () => workDir;
|
||||||
|
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
||||||
|
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
|
||||||
|
app.inject({ method: 'POST', url: '/api/cases', payload });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!sandboxed()) {
|
||||||
|
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
|
||||||
|
}
|
||||||
|
// Recursive: the rollback tests turn the registry into a directory.
|
||||||
|
safeRmHomeTree(LINKED());
|
||||||
|
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
|
||||||
|
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
if (workDir) safeRmHomeTree(workDir);
|
||||||
|
workDir = '';
|
||||||
|
if (sandboxed()) safeRmHomeTree(LINKED());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/cases with a custom path', () => {
|
||||||
|
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
const target = join(work(), 'my-app');
|
||||||
|
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
|
||||||
|
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
|
||||||
|
expect(existsSync(join(target, 'src'))).toBe(true);
|
||||||
|
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
|
||||||
|
expect(linked()).toEqual({ 'my-app': target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears in GET /api/cases at its custom path', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
const target = join(work(), 'listed');
|
||||||
|
await create(app, { name: 'listed', path: target });
|
||||||
|
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
|
||||||
|
const cases = Array.isArray(list) ? list : list.data;
|
||||||
|
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands ~ and fills an existing EMPTY folder', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
mkdirSync(join(work(), 'empty-one'));
|
||||||
|
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
|
||||||
|
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a folder that already has files (409) and touches nothing', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
mkdirSync(join(work(), 'existing'));
|
||||||
|
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
|
||||||
|
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json().error).toMatch(/Link Existing/);
|
||||||
|
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
|
||||||
|
expect(linked()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a system folder', () => '/etc/my-case', 400],
|
||||||
|
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
|
||||||
|
['the home folder itself', () => homedir(), 400],
|
||||||
|
['a relative path', () => 'projects/x', 400],
|
||||||
|
['a path with traversal', () => `${work()}/../x`, 400],
|
||||||
|
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
|
||||||
|
] as const)('refuses %s', async (_label, path, status) => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
const res = await create(app, { name: 'x', path: path() });
|
||||||
|
expect(res.statusCode).toBe(status);
|
||||||
|
expect(linked()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
await create(app, { name: 'one', path: join(work(), 'one') });
|
||||||
|
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
|
||||||
|
expect(dupName.statusCode).toBe(409);
|
||||||
|
expect(existsSync(join(work(), 'two'))).toBe(false);
|
||||||
|
// Same folder under another name: the first case's folder now has files, which is refused earlier.
|
||||||
|
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
|
||||||
|
expect(dupPath.statusCode).toBe(409);
|
||||||
|
expect(linked()).toEqual({ one: join(work(), 'one') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the case name like a normal create', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(existsSync(join(work(), 'x'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
|
||||||
|
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(existsSync(join(work(), 'doomed'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
mkdirSync(join(work(), 'picked'));
|
||||||
|
mkdirSync(LINKED(), { recursive: true });
|
||||||
|
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(existsSync(join(work(), 'picked'))).toBe(true);
|
||||||
|
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a request without a path still creates under the cases directory, as before', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
const res = await create(app, { name: 'plain-case' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
|
||||||
|
expect(linked()).toEqual({});
|
||||||
|
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
|
||||||
|
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||||
|
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
|
||||||
|
// A link from the custom parent into the cases dir is judged on its resolved form too.
|
||||||
|
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
|
||||||
|
try {
|
||||||
|
for (const path of [
|
||||||
|
join(CASES_DIR(), 'bar'),
|
||||||
|
join(CASES_DIR(), 'existing-empty'),
|
||||||
|
join(work(), 'into-cases', 'via-link'),
|
||||||
|
]) {
|
||||||
|
const res = await create(app, { name: 'bar', path });
|
||||||
|
expect(res.statusCode, path).toBe(400);
|
||||||
|
expect(res.json().error, path).toMatch(/plain Create New/);
|
||||||
|
}
|
||||||
|
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
|
||||||
|
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
|
||||||
|
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
|
||||||
|
expect(linked()).toEqual({});
|
||||||
|
} finally {
|
||||||
|
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
|
||||||
|
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
|
||||||
|
expect(denied.statusCode).toBe(403);
|
||||||
|
expect(existsSync(join(work(), 'bobs'))).toBe(false);
|
||||||
|
expect(linked()).toEqual({});
|
||||||
|
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
|
||||||
|
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,13 +13,24 @@
|
|||||||
* behavior matches production exactly).
|
* behavior matches production exactly).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||||
|
import { probePath } from '../../src/utils/index.js';
|
||||||
|
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
|
||||||
|
|
||||||
|
// A short path-probe timeout keeps the unreachable-mount tests quick. Read when the
|
||||||
|
// probe's config module is first imported, so it is set before any import runs.
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '300';
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
// Mock filesystem modules
|
// Mock filesystem modules
|
||||||
vi.mock('node:fs', async (importOriginal) => {
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
@@ -35,6 +46,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
|||||||
|
|
||||||
vi.mock('node:fs/promises', () => ({
|
vi.mock('node:fs/promises', () => ({
|
||||||
default: {
|
default: {
|
||||||
|
stat: vi.fn(),
|
||||||
readdir: vi.fn(async () => []),
|
readdir: vi.fn(async () => []),
|
||||||
readFile: vi.fn(async () => {
|
readFile: vi.fn(async () => {
|
||||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||||
@@ -74,6 +86,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
|
|||||||
const mockedReaddir = vi.mocked(fs.readdir);
|
const mockedReaddir = vi.mocked(fs.readdir);
|
||||||
const mockedReadFile = vi.mocked(fs.readFile);
|
const mockedReadFile = vi.mocked(fs.readFile);
|
||||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||||
|
|
||||||
interface CaseRouteHarness {
|
interface CaseRouteHarness {
|
||||||
@@ -127,6 +140,12 @@ describe('case-routes', () => {
|
|||||||
// Default: existsSync returns false, readFile throws ENOENT
|
// Default: existsSync returns false, readFile throws ENOENT
|
||||||
mockedExistsSync.mockReturnValue(false);
|
mockedExistsSync.mockReturnValue(false);
|
||||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
// Async stat (the bounded path probe) follows the mocked existsSync, so a
|
||||||
|
// test that sets up a path's presence via existsSync drives both the same way.
|
||||||
|
mockedStat.mockImplementation(async (path) => {
|
||||||
|
if (mockedExistsSync(path)) return { isDirectory: () => true } as never;
|
||||||
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -210,6 +229,53 @@ describe('case-routes', () => {
|
|||||||
// Should have both regular and linked cases
|
// Should have both regular and linked cases
|
||||||
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('still answers promptly when a linked case sits on an unreachable mount', async () => {
|
||||||
|
// A hard network mount that went away: a synchronous probe blocks the
|
||||||
|
// thread (simulated by a busy-wait), and an async stat never settles.
|
||||||
|
const stalledPath = '/mnt/unreachable/linked-nfs';
|
||||||
|
const BLOCK_MS = 4_000;
|
||||||
|
mockedReaddir.mockResolvedValue([] as never);
|
||||||
|
mockedReadFile.mockResolvedValueOnce(JSON.stringify({ 'linked-nfs': stalledPath }) as never);
|
||||||
|
mockedExistsSync.mockImplementation((p) => {
|
||||||
|
if (String(p) !== stalledPath) return false;
|
||||||
|
const until = Date.now() + BLOCK_MS;
|
||||||
|
while (Date.now() < until) {
|
||||||
|
// spin: the event loop is frozen for as long as the mount does not answer
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
mockedStat.mockImplementation((p) => {
|
||||||
|
if (String(p) !== stalledPath) {
|
||||||
|
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
release = () => resolve({ isDirectory: () => true } as never);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
release?.();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(elapsed).toBeLessThan(BLOCK_MS - 1_000);
|
||||||
|
// The unreachable case is listed as such rather than holding the list
|
||||||
|
// hostage, or vanishing as though it had been deleted.
|
||||||
|
expect(JSON.parse(res.body).data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'linked-nfs',
|
||||||
|
path: stalledPath,
|
||||||
|
hasClaudeMd: false,
|
||||||
|
linked: true,
|
||||||
|
location: 'linked-local',
|
||||||
|
unreachable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remote host and remote case routes', () => {
|
describe('remote host and remote case routes', () => {
|
||||||
@@ -670,6 +736,66 @@ describe('case-routes', () => {
|
|||||||
expect(body.data.name).toBe('regular-case');
|
expect(body.data.name).toBe('regular-case');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers a linked case on an unreachable mount with its registered path, not NOT_FOUND', async () => {
|
||||||
|
// The timeout path: the mount does not answer at all.
|
||||||
|
const stalledPath = '/mnt/unreachable/linked-get';
|
||||||
|
mockedReadFile.mockResolvedValue(JSON.stringify({ 'linked-get': stalledPath }) as never);
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
mockedStat.mockImplementation((p) => {
|
||||||
|
if (String(p) !== stalledPath) {
|
||||||
|
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
release = () => resolve({ isDirectory: () => true } as never);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/linked-get' });
|
||||||
|
release?.();
|
||||||
|
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data).toMatchObject({ name: 'linked-get', path: stalledPath, linked: true, unreachable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still answers a healthy case while unrelated mounts are stalled past the cap', async () => {
|
||||||
|
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/linked`);
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
mockedStat.mockImplementation((p) => {
|
||||||
|
if (dead.includes(String(p))) {
|
||||||
|
return new Promise((resolve) => releases.push(() => resolve({ isDirectory: () => true } as never)));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ isDirectory: () => true } as never);
|
||||||
|
});
|
||||||
|
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/healthy-local' });
|
||||||
|
releases.forEach((release) => release());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data).toMatchObject({ name: 'healthy-local' });
|
||||||
|
expect(JSON.parse(res.body).data.unreachable).toBeUndefined();
|
||||||
|
// Its CLAUDE.md is probed the same way as its folder, so it is not misreported missing.
|
||||||
|
expect(JSON.parse(res.body).data.hasClaudeMd).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a local case it cannot read with a non-NOT_FOUND error', async () => {
|
||||||
|
// A soft mount that gave up (EIO) is not proof the case is gone, and the Run
|
||||||
|
// button creates a case on NOT_FOUND.
|
||||||
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/eio-case' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.errorCode).toBe('OPERATION_FAILED');
|
||||||
|
expect(res.statusCode).not.toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error when case not found anywhere', async () => {
|
it('returns error when case not found anywhere', async () => {
|
||||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
mockedExistsSync.mockReturnValue(false);
|
mockedExistsSync.mockReturnValue(false);
|
||||||
@@ -704,6 +830,16 @@ describe('case-routes', () => {
|
|||||||
expect(body.data.todos).toEqual([]);
|
expect(body.data.todos).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports an unreadable fix plan as an error, not as "no plan"', async () => {
|
||||||
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
|
||||||
|
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/my-case/fix-plan' });
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.errorCode).toBe('OPERATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
it('parses fix plan with todos and stats', async () => {
|
it('parses fix plan with todos and stats', async () => {
|
||||||
const fixPlanContent = [
|
const fixPlanContent = [
|
||||||
'# Fix Plan',
|
'# Fix Plan',
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
|
||||||
|
* The route runs the probe out of process (the engine is synchronous), so every test injects the
|
||||||
|
* runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
|
||||||
|
* it relies on is exercised for real in test/doctor-cli-json.test.ts.
|
||||||
|
*
|
||||||
|
* Port: N/A (app.inject()).
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
|
||||||
|
import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
|
||||||
|
vi.mock('node:child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||||
|
return { ...actual, execFile: execFileMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
const REPORT: DependencyReportJson = {
|
||||||
|
platform: { environment: 'linux' },
|
||||||
|
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
|
||||||
|
tools: [
|
||||||
|
{ id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
|
||||||
|
{ id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
execFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/doctor', () => {
|
||||||
|
it('returns the runner’s report in the success envelope', async () => {
|
||||||
|
const runner = vi.fn<DoctorRunner>(async () => REPORT);
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ success: true, data: REPORT });
|
||||||
|
expect(runner).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a valid category through and rejects an unknown one without running anything', async () => {
|
||||||
|
const runner = vi.fn<DoctorRunner>(async () => REPORT);
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
|
||||||
|
expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
|
||||||
|
expect(runner).toHaveBeenLastCalledWith('office');
|
||||||
|
runner.mockClear();
|
||||||
|
const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
expect(bad.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 500 with a message when the runner fails', async () => {
|
||||||
|
const runner: DoctorRunner = async () => {
|
||||||
|
throw new Error('spawn blew up');
|
||||||
|
};
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(res.json().error).toContain('spawn blew up');
|
||||||
|
expect(res.json().errorCode).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => {
|
||||||
|
const releases: Array<(r: DependencyReportJson) => void> = [];
|
||||||
|
const runner = vi.fn<DoctorRunner>(() => new Promise<DependencyReportJson>((res) => releases.push(res)));
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
|
||||||
|
const first = app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
const second = app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' });
|
||||||
|
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2));
|
||||||
|
releases[0](REPORT);
|
||||||
|
expect((await first).statusCode).toBe(200);
|
||||||
|
expect((await second).statusCode).toBe(200);
|
||||||
|
expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office
|
||||||
|
releases[1](REPORT);
|
||||||
|
await other;
|
||||||
|
runner.mockImplementation(async () => REPORT);
|
||||||
|
await app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
expect(runner).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: a non-admin is refused and nothing is probed', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const runner = vi.fn<DoctorRunner>(async () => REPORT);
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
|
||||||
|
authUser: { username: 'bob', role: 'user' },
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: an admin is allowed', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
|
||||||
|
authUser: { username: 'root', role: 'admin' },
|
||||||
|
});
|
||||||
|
expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultDoctorRunner', () => {
|
||||||
|
type Done = (err: Error | null, stdout: string) => void;
|
||||||
|
const respond = (err: Error | null, stdout: string) =>
|
||||||
|
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
|
||||||
|
|
||||||
|
it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
|
||||||
|
respond(null, JSON.stringify(REPORT));
|
||||||
|
await defaultDoctorRunner('core');
|
||||||
|
const [bin, args, opts] = execFileMock.mock.calls[0];
|
||||||
|
expect(bin).toBe(process.execPath);
|
||||||
|
expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
|
||||||
|
expect(args).toContain(process.argv[1]);
|
||||||
|
expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
|
||||||
|
respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
|
||||||
|
await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['empty output', ''],
|
||||||
|
['non-JSON output', 'Segmentation fault'],
|
||||||
|
['JSON of the wrong shape', '{"hello":"world"}'],
|
||||||
|
])('rejects %s', async (_label, stdout) => {
|
||||||
|
respond(null, stdout);
|
||||||
|
await expect(defaultDoctorRunner()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => {
|
||||||
|
respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), '');
|
||||||
|
await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the child’s own error through when there is no report at all', async () => {
|
||||||
|
respond(new Error('ETIMEDOUT'), '');
|
||||||
|
await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
|
||||||
|
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
|
||||||
|
* all (remote and Docker sessions). Port: N/A (app.inject()).
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
|
||||||
|
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
const ENV = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'T',
|
||||||
|
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||||
|
GIT_COMMITTER_NAME: 'T',
|
||||||
|
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
};
|
||||||
|
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env: ENV, stdio: 'ignore' });
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let session: Record<string, unknown>;
|
||||||
|
|
||||||
|
async function setup(
|
||||||
|
opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {}
|
||||||
|
) {
|
||||||
|
const h = await createRouteTestHarness(
|
||||||
|
(app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []),
|
||||||
|
{
|
||||||
|
authUser: opts.authUser,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
session = h.ctx._session as unknown as Record<string, unknown>;
|
||||||
|
session.workingDir = dir;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearGitStatusCache();
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/git-status', () => {
|
||||||
|
it('returns the snapshot of the session workspace in the success envelope', async () => {
|
||||||
|
git(dir, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(dir, 'a.txt'), '1\n');
|
||||||
|
git(dir, 'add', '-A');
|
||||||
|
git(dir, 'commit', '-q', '-m', 'base');
|
||||||
|
writeFileSync(join(dir, 'a.txt'), '2\n');
|
||||||
|
writeFileSync(join(dir, 'new.txt'), 'n\n');
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.state).toBe('ok');
|
||||||
|
expect(body.data.repos).toHaveLength(1);
|
||||||
|
const repo = body.data.repos[0];
|
||||||
|
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
|
||||||
|
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
|
||||||
|
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports each repository found below a folder that holds several projects', async () => {
|
||||||
|
for (const name of ['api', 'web']) {
|
||||||
|
mkdirSync(join(dir, name));
|
||||||
|
git(join(dir, name), 'init', '-q', '-b', 'main');
|
||||||
|
}
|
||||||
|
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
const data = res.json().data;
|
||||||
|
expect(data.state).toBe('ok');
|
||||||
|
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
|
||||||
|
['api', 'api'],
|
||||||
|
['web', 'web'],
|
||||||
|
]);
|
||||||
|
expect(data.repos[0].status.counts.uncommitted).toBe(1);
|
||||||
|
expect(data.repos[1].status.counts.uncommitted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers not-a-repo for a folder that is not a repository', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(res.json().data.state).toBe('not-a-repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s an unknown session', async () => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
|
||||||
|
// The workspace is the root of its repository, as real git would say.
|
||||||
|
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
const revParses = calls('rev-parse');
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(calls('status')).toBe(1);
|
||||||
|
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
|
||||||
|
expect(calls('status')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs git in the session working directory', async () => {
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(runner).toHaveBeenCalled();
|
||||||
|
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['remote', { host: 'h', user: 'u' }],
|
||||||
|
['docker', { container: 'c' }],
|
||||||
|
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
session[kind] = value;
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
|
||||||
|
session.owner = 'alice';
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: the owner and an admin can read it', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
for (const authUser of [
|
||||||
|
{ username: 'alice', role: 'user' as const },
|
||||||
|
{ username: 'root', role: 'admin' as const },
|
||||||
|
]) {
|
||||||
|
clearGitStatusCache();
|
||||||
|
const { app } = await setup({ git: runner, authUser });
|
||||||
|
session.owner = 'alice';
|
||||||
|
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/git-diff', () => {
|
||||||
|
const url = (q: Record<string, string>) => `/api/sessions/test-session-1/git-diff?${new URLSearchParams(q)}`;
|
||||||
|
let root: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
git(dir, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(dir, 'a.txt'), 'one\n');
|
||||||
|
writeFileSync(join(dir, 'b.txt'), 'bee\n');
|
||||||
|
git(dir, 'add', '-A');
|
||||||
|
git(dir, 'commit', '-q', '-m', 'base');
|
||||||
|
writeFileSync(join(dir, 'a.txt'), 'two\n');
|
||||||
|
writeFileSync(join(dir, 'b.txt'), 'staged\n');
|
||||||
|
git(dir, 'add', 'b.txt');
|
||||||
|
writeFileSync(join(dir, 'new.txt'), 'fresh\n');
|
||||||
|
root = realpathSync(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unstaged', 'a.txt', ['-one', '+two']],
|
||||||
|
['staged', 'b.txt', ['-bee', '+staged']],
|
||||||
|
['untracked', 'new.txt', ['+fresh']],
|
||||||
|
])('returns the %s diff of %s', async (kind, path, lines) => {
|
||||||
|
const { app } = await setup();
|
||||||
|
const res = await app.inject({ method: 'GET', url: url({ repo: root, path, kind }) });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { diff, truncated, binary } = res.json().data;
|
||||||
|
for (const l of lines) expect(diff).toContain(l);
|
||||||
|
expect(truncated).toBe(false);
|
||||||
|
expect(binary).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for a path or repo the status does not list, running no diff', async () => {
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
for (const q of [
|
||||||
|
{ repo: root, path: '../../etc/passwd', kind: 'unstaged' },
|
||||||
|
{ repo: '/etc', path: 'a.txt', kind: 'unstaged' },
|
||||||
|
{ repo: root, path: 'a.txt', kind: 'staged' },
|
||||||
|
]) {
|
||||||
|
const res = await app.inject({ method: 'GET', url: url(q) });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
}
|
||||||
|
expect(runner.mock.calls.some(([, args]) => args[0] === 'diff')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run git for remote and Docker sessions', async () => {
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
session.remote = { host: 'h' };
|
||||||
|
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-user: another user’s session is not found', async () => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
const { app } = await setup({ authUser: { username: 'bob', role: 'user' } });
|
||||||
|
session.owner = 'alice';
|
||||||
|
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diffs a staged rename against its old name, and a merge conflict as git’s combined diff (real git)', async () => {
|
||||||
|
// beforeEach left a.txt/b.txt modified; start this case from a clean tree.
|
||||||
|
git(dir, 'checkout', '-q', '--', '.');
|
||||||
|
git(dir, 'reset', '-q', '--hard');
|
||||||
|
git(dir, 'clean', '-fdq');
|
||||||
|
writeFileSync(join(dir, 'old.txt'), 'a\nb\nc\nd\ne\nf\ng\n');
|
||||||
|
git(dir, 'add', 'old.txt');
|
||||||
|
git(dir, 'commit', '-q', '-m', 'old');
|
||||||
|
// A real conflict on c.txt.
|
||||||
|
writeFileSync(join(dir, 'c.txt'), 'base\n');
|
||||||
|
git(dir, 'add', 'c.txt');
|
||||||
|
git(dir, 'commit', '-q', '-m', 'c');
|
||||||
|
git(dir, 'checkout', '-q', '-b', 'other');
|
||||||
|
writeFileSync(join(dir, 'c.txt'), 'theirs\n');
|
||||||
|
git(dir, 'commit', '-q', '-am', 'theirs');
|
||||||
|
git(dir, 'checkout', '-q', 'main');
|
||||||
|
writeFileSync(join(dir, 'c.txt'), 'ours\n');
|
||||||
|
git(dir, 'commit', '-q', '-am', 'ours');
|
||||||
|
try {
|
||||||
|
git(dir, 'merge', 'other');
|
||||||
|
} catch {
|
||||||
|
/* the conflict is the point */
|
||||||
|
}
|
||||||
|
// A staged rename, made once the merge has stopped on the conflict.
|
||||||
|
git(dir, 'mv', 'old.txt', 'new-name.txt');
|
||||||
|
writeFileSync(join(dir, 'new-name.txt'), 'a\nb\nc\nd\ne\nf\nCHANGED\n');
|
||||||
|
git(dir, 'add', 'new-name.txt');
|
||||||
|
const { app } = await setup();
|
||||||
|
const root = realpathSync(dir);
|
||||||
|
const rename = await app.inject({ method: 'GET', url: url({ repo: root, path: 'new-name.txt', kind: 'staged' }) });
|
||||||
|
expect(rename.statusCode).toBe(200);
|
||||||
|
expect(rename.json().data.diff).toContain('rename from old.txt');
|
||||||
|
expect(rename.json().data.diff).toContain('+CHANGED');
|
||||||
|
const conflict = await app.inject({ method: 'GET', url: url({ repo: root, path: 'c.txt', kind: 'conflicted' }) });
|
||||||
|
expect(conflict.statusCode).toBe(200);
|
||||||
|
expect(conflict.json().data.diff).toMatch(/<<<<<<<|\+\+<<<<<<</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s a repository inside a Docker case workspace without running git in it', async () => {
|
||||||
|
const runner = vi.fn<GitRunner>(async () => '');
|
||||||
|
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: url({ repo: realpathSync(dir), path: 'a.txt', kind: 'unstaged' }),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(runner).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
|
||||||
|
it('checks the repository against the listed ones and re-reads only that one', async () => {
|
||||||
|
for (const name of ['api', 'web']) {
|
||||||
|
const r = join(dir, name);
|
||||||
|
mkdirSync(r);
|
||||||
|
git(r, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(r, 'f.txt'), `${name}\n`);
|
||||||
|
}
|
||||||
|
const calls: Array<[string, string]> = [];
|
||||||
|
const runner: GitRunner = async (cwd, args) => {
|
||||||
|
calls.push([cwd, args[0]]);
|
||||||
|
return runGit(cwd, args);
|
||||||
|
};
|
||||||
|
const { app } = await setup({ git: runner });
|
||||||
|
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
|
||||||
|
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
|
||||||
|
calls.length = 0;
|
||||||
|
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
|
||||||
|
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data.diff).toContain('+api');
|
||||||
|
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Session creation must not freeze the server on a workspace whose
|
||||||
|
* network mount has gone away (`POST /api/sessions` with a `workingDir` on it, and
|
||||||
|
* `POST /api/quick-start` for a linked case that lives there), and must not treat
|
||||||
|
* "did not answer" as "does not exist" (quick-start would scaffold a fresh case
|
||||||
|
* over the top of where the real one is mounted).
|
||||||
|
*
|
||||||
|
* A hard mount that stopped answering is simulated two ways, matching how each
|
||||||
|
* API behaves on one: a synchronous probe (`existsSync`/`statSync`/`mkdirSync`)
|
||||||
|
* busy-waits, freezing the event loop, and an async `stat()` never settles.
|
||||||
|
*
|
||||||
|
* Uses app.inject(), so no real HTTP port is needed.
|
||||||
|
*/
|
||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
|
||||||
|
const dead = vi.hoisted(() => {
|
||||||
|
// Short probe timeout so a stalled stat costs ~200 ms here. Read at import.
|
||||||
|
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
|
||||||
|
return {
|
||||||
|
root: '/mnt/codeman-test-dead-mount',
|
||||||
|
blockMs: 3_000,
|
||||||
|
syncTouches: [] as string[],
|
||||||
|
releases: [] as Array<() => void>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onDeadMount(path: unknown): boolean {
|
||||||
|
const p = String(path);
|
||||||
|
return p === dead.root || p.startsWith(dead.root + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
const freezeOn =
|
||||||
|
<T extends (...args: never[]) => unknown>(fn: T) =>
|
||||||
|
(...args: Parameters<T>): ReturnType<T> => {
|
||||||
|
if (onDeadMount(args[0])) {
|
||||||
|
dead.syncTouches.push(String(args[0]));
|
||||||
|
const until = Date.now() + dead.blockMs;
|
||||||
|
while (Date.now() < until) {
|
||||||
|
// spin: the event loop is frozen for as long as the mount does not answer
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('EIO'), { code: 'EIO' });
|
||||||
|
}
|
||||||
|
return fn(...args) as ReturnType<T>;
|
||||||
|
};
|
||||||
|
const existsSync = freezeOn(actual.existsSync);
|
||||||
|
const statSync = freezeOn(actual.statSync as (...args: never[]) => unknown);
|
||||||
|
const mkdirSync = freezeOn(actual.mkdirSync as (...args: never[]) => unknown);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync,
|
||||||
|
statSync,
|
||||||
|
mkdirSync,
|
||||||
|
default: { ...actual, existsSync, statSync, mkdirSync },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
const stat = ((path: string, ...rest: unknown[]) => {
|
||||||
|
if (onDeadMount(path)) {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
dead.releases.push(() => reject(Object.assign(new Error('EIO'), { code: 'EIO' })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
|
||||||
|
}) as typeof actual.stat;
|
||||||
|
return { ...actual, stat, default: { ...actual, stat } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createMockRouteContext } from '../mocks/index.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { dataPath } from '../../src/config/instance.js';
|
||||||
|
|
||||||
|
describe('session creation on an unreachable mount', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let scratch: string;
|
||||||
|
let warn: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dead.syncTouches.length = 0;
|
||||||
|
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
scratch = await mkdtemp(join(tmpdir(), 'codeman-unreachable-create-'));
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
registerSessionRoutes(app, createMockRouteContext() as never);
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
dead.releases.splice(0).forEach((release) => release());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await rm(scratch, { recursive: true, force: true });
|
||||||
|
await rm(dataPath('linked-cases.json'), { force: true });
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/sessions answers promptly, and not as "does not exist", for a workingDir on a dead mount', async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { name: 'dead-mount', mode: 'shell', workingDir: `${dead.root}/project` },
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
|
||||||
|
expect(dead.syncTouches).toEqual([]);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.errorCode).toBe('OPERATION_FAILED');
|
||||||
|
expect(body.error).toMatch(/not responding/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/sessions keeps INVALID_INPUT for a missing workingDir and for a file', async () => {
|
||||||
|
const file = join(scratch, 'a-file.txt');
|
||||||
|
await writeFile(file, 'x');
|
||||||
|
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { name: 'missing', mode: 'shell', workingDir: join(scratch, 'nope') },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(missing.body)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_INPUT',
|
||||||
|
error: 'workingDir does not exist',
|
||||||
|
});
|
||||||
|
|
||||||
|
const notDir = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { name: 'file', mode: 'shell', workingDir: file },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(notDir.body)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
errorCode: 'INVALID_INPUT',
|
||||||
|
error: 'workingDir is not a directory',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/quick-start refuses, promptly and without scaffolding, a linked case on a dead mount', async () => {
|
||||||
|
await writeFile(dataPath('linked-cases.json'), JSON.stringify({ 'nas-linked': `${dead.root}/linked` }));
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/quick-start',
|
||||||
|
payload: { caseName: 'nas-linked', mode: 'shell' },
|
||||||
|
});
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
|
||||||
|
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
|
||||||
|
// Neither probed nor created synchronously on the dead mount.
|
||||||
|
expect(dead.syncTouches).toEqual([]);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.errorCode).toBe('OPERATION_FAILED');
|
||||||
|
expect(body.error).toMatch(/not responding/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1228,3 +1228,65 @@ describe('Grok quick start', () => {
|
|||||||
expect(selected).toEqual(['sess-gk-0']);
|
expect(selected).toEqual(['sess-gk-0']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('case lookup before a local launch', () => {
|
||||||
|
function loadLaunchHarness(caseAnswer: Record<string, unknown>) {
|
||||||
|
const elements: Record<string, any> = {
|
||||||
|
quickStartCase: { value: 'nas-case' },
|
||||||
|
shellCount: { value: '1' },
|
||||||
|
tabCount: { value: '1' },
|
||||||
|
};
|
||||||
|
const requests: Array<{ url: string; method?: string }> = [];
|
||||||
|
const written: string[] = [];
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||||
|
fetch: async (url: string, init?: { method?: string }) => {
|
||||||
|
requests.push({ url, method: init?.method });
|
||||||
|
if (url === '/api/cases/nas-case') return { json: async () => caseAnswer };
|
||||||
|
if (url === '/api/cases' && init?.method === 'POST') {
|
||||||
|
return {
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
data: { case: { name: 'nas-case', path: '/home/u/codeman-cases/nas-case' } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Anything past the case lookup is out of scope here: stop the launch.
|
||||||
|
throw new Error(`stop: ${url}`);
|
||||||
|
},
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
app.terminal = { clear: () => {}, writeln: (line: string) => written.push(line), focus: () => {} };
|
||||||
|
app.sessions = new Map();
|
||||||
|
app.cases = [];
|
||||||
|
app.getTerminalDimensions = () => null;
|
||||||
|
app._readTabCount = () => 1;
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app.getCaseSettings = () => ({});
|
||||||
|
return { app, requests, written };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreachable = { success: false, error: 'Case folder is not responding', errorCode: 'OPERATION_FAILED' };
|
||||||
|
const missing = { success: false, error: 'Case not found', errorCode: 'NOT_FOUND' };
|
||||||
|
|
||||||
|
for (const launcher of ['runClaude', 'runShell'] as const) {
|
||||||
|
it(`${launcher} never creates a case when the lookup could not tell whether it exists`, async () => {
|
||||||
|
const { app, requests, written } = loadLaunchHarness(unreachable);
|
||||||
|
await app[launcher]();
|
||||||
|
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(false);
|
||||||
|
expect(written.join('\n')).toContain('Case folder is not responding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${launcher} creates the case when the lookup says it does not exist`, async () => {
|
||||||
|
const { app, requests } = loadLaunchHarness(missing);
|
||||||
|
await app[launcher]();
|
||||||
|
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||||
|
|
||||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
describe.each(['terminal-ui.js', 'terminal-tile.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||||
|
|
||||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* worked all along because Chromium fires no keypress for it.
|
* worked all along because Chromium fires no keypress for it.
|
||||||
*
|
*
|
||||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
* terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
|
||||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
* `TerminalTile`) attach. Nothing restates their predicate. What stands in for the server is
|
||||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||||
*
|
*
|
||||||
@@ -74,7 +74,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
const mount = document.createElement('div');
|
const mount = document.createElement('div');
|
||||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
const pane = new w.TerminalTile(paneBId, mount, { mode: 'claude' });
|
||||||
void pane.connect().catch(() => {});
|
void pane.connect().catch(() => {});
|
||||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||||
w.__paneBData = [] as string[];
|
w.__paneBData = [] as string[];
|
||||||
@@ -200,7 +200,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
expect(shift.sendKeys).toEqual([]);
|
expect(shift.sendKeys).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
it("Pane B (terminal-tile.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||||
expect(shift.focused).toBe(true);
|
expect(shift.focused).toBe(true);
|
||||||
expect(shift.data).toEqual([]);
|
expect(shift.data).toEqual([]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
/** @fileoverview Real Chromium + real WebSocket coverage for TerminalTile (Task 4 of the split-pane-sessions plan). */
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { chromium, type Browser, type Page } from 'playwright';
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
const PORT = 3175;
|
const PORT = 3175;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
describe('SplitTerminalPane in a real browser', () => {
|
describe('TerminalTile in a real browser', () => {
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page;
|
let page: Page;
|
||||||
@@ -37,7 +37,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
// Testing section) — the shell PTY only spawns once this is called, and
|
// Testing section) — the shell PTY only spawns once this is called, and
|
||||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||||
// below would hang until its own timeout for reasons unrelated to
|
// below would hang until its own timeout for reasons unrelated to
|
||||||
// SplitTerminalPane.
|
// TerminalTile.
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
pane.connect();
|
pane.connect();
|
||||||
|
|
||||||
// Wait for the WS to open, then send a real input frame — testMode's
|
// Wait for the WS to open, then send a real input frame — testMode's
|
||||||
@@ -107,7 +107,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
});
|
});
|
||||||
const id = (await res.json()).data.session.id;
|
const id = (await res.json()).data.session.id;
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
// Write directly to the session (not through SplitTerminalPane, which
|
// Write directly to the session (not through TerminalTile, which
|
||||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||||
// connect() below will use) rather than a fixed delay — the shell's
|
// connect() below will use) rather than a fixed delay — the shell's
|
||||||
// own startup can race an early write and, on this box, a startup
|
// own startup can race an early write and, on this box, a startup
|
||||||
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
|
|
||||||
// xterm's write() parses asynchronously (it queues data and processes it
|
// xterm's write() parses asynchronously (it queues data and processes it
|
||||||
@@ -191,7 +191,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||||
|
|||||||
@@ -451,8 +451,16 @@ describe('browser wiring', () => {
|
|||||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never writes the layout from the browser in this slice', () => {
|
it('writes the layout only through the edit coordinator (and its keepalive twin)', () => {
|
||||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
// Exactly two PUT sites: _putTabLayout (the coordinator's transport) and the
|
||||||
|
// pagehide keepalive. Anything else would bypass serialization.
|
||||||
|
const writes = APP_SOURCE.split('\n').filter(
|
||||||
|
(line) => line.includes("'/api/tab-layout'") && !line.includes("_apiJson('/api/tab-layout')")
|
||||||
|
);
|
||||||
|
expect(writes).toHaveLength(2);
|
||||||
|
expect(APP_SOURCE).toContain("this._api('/api/tab-layout', { method: 'PUT', body })");
|
||||||
|
expect(APP_SOURCE).toContain("void fetch('/api/tab-layout', {");
|
||||||
|
// The pure module never does IO itself.
|
||||||
expect(SOURCE).not.toContain('fetch(');
|
expect(SOURCE).not.toContain('fetch(');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Real-Chromium coverage for editing the grouped vertical rail.
|
||||||
|
*
|
||||||
|
* What DOM emulation cannot answer: a pointer drag (hit testing, capture, the
|
||||||
|
* click that ends a drag), whether the inline group editor actually paints
|
||||||
|
* inside the rail's nowrap/ellipsis header, and whether Escape on an open group
|
||||||
|
* menu reaches the menu first. The shipping app.js, tab-layout-browser.js,
|
||||||
|
* tab-rail-resize.js, api-client.js, webview-tabs.js and styles.css are loaded
|
||||||
|
* into a page; PUT /api/tab-layout is answered by a route that records bodies.
|
||||||
|
*
|
||||||
|
* Port: none (page.route on a fake origin, no server).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
|
||||||
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||||
|
|
||||||
|
const LAYOUT = {
|
||||||
|
version: 4,
|
||||||
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 'gx',
|
||||||
|
name: 'Core',
|
||||||
|
refs: [
|
||||||
|
{ kind: 'session', id: 'two' },
|
||||||
|
{ kind: 'webview', id: 'web' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] },
|
||||||
|
],
|
||||||
|
ungrouped: [{ kind: 'session', id: 'one' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('grouped rail editing in Chromium', () => {
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
let puts: any[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||||
|
await page.route('http://codeman.test/', (route) =>
|
||||||
|
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
||||||
|
);
|
||||||
|
await page.route('http://codeman.test/api/tab-layout', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
if (request.method() !== 'PUT') return route.fulfill({ status: 404, body: '' });
|
||||||
|
const body = JSON.parse(request.postData() || '{}');
|
||||||
|
puts.push(body);
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ success: true, data: { layout: { ...body.layout, version: body.baseVersion + 1 } } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('http://codeman.test/');
|
||||||
|
await page.setContent(`<!doctype html>
|
||||||
|
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
|
||||||
|
<head><style>${read('styles.css')}</style></head>
|
||||||
|
<body>
|
||||||
|
<main class="main" style="width:100%;height:760px">
|
||||||
|
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></aside>
|
||||||
|
<button id="elsewhere">elsewhere</button>
|
||||||
|
<textarea id="term" aria-label="stand-in for the terminal" style="position:absolute;left:700px;top:300px"></textarea>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
await page.addScriptTag({
|
||||||
|
content:
|
||||||
|
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||||
|
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||||
|
read('constants.js') +
|
||||||
|
'\n' +
|
||||||
|
read('tab-layout-browser.js') +
|
||||||
|
'\n' +
|
||||||
|
read('app.js') +
|
||||||
|
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||||
|
});
|
||||||
|
await page.addScriptTag({ content: read('tab-rail-resize.js') });
|
||||||
|
await page.addScriptTag({ content: read('api-client.js') });
|
||||||
|
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
const app = Object.create(w.CodemanApp.prototype);
|
||||||
|
app.$ = (id: string) => document.getElementById(id);
|
||||||
|
app.sessions = new Map([
|
||||||
|
['one', { id: 'one', name: 'One', status: 'idle' }],
|
||||||
|
['two', { id: 'two', name: 'Two', status: 'idle' }],
|
||||||
|
['three', { id: 'three', name: 'Three', status: 'idle' }],
|
||||||
|
]);
|
||||||
|
app.sessionOrder = ['one', 'two', 'three'];
|
||||||
|
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
|
||||||
|
app.webviewOrder = ['web'];
|
||||||
|
app.collapsedTabGroupIds = new Set();
|
||||||
|
app._hiddenTabGroupByRef = new Map();
|
||||||
|
app._inlineRenameActive = false;
|
||||||
|
app.tabAlerts = new Map();
|
||||||
|
app.terminalLoadStates = new Map();
|
||||||
|
app.minimizedSubagents = new Map();
|
||||||
|
app.hasTabDetachOverride = () => false;
|
||||||
|
app.renderSubagentTabBadge = () => '';
|
||||||
|
app.cancelHideSubagentDropdown = () => undefined;
|
||||||
|
app.updateTabOverflowMode = () => undefined;
|
||||||
|
app.updateConnectionLines = () => undefined;
|
||||||
|
app._applyTabEntrances = () => undefined;
|
||||||
|
app._scrollActiveTabIntoView = () => undefined;
|
||||||
|
app.applySidebarFilter = () => undefined;
|
||||||
|
app.isSessionSidebarActive = () => false;
|
||||||
|
app._startSidebarRichClock = () => undefined;
|
||||||
|
app._stopSidebarRichClock = () => undefined;
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app.showToast = () => undefined;
|
||||||
|
app.closeAllPanels = () => {
|
||||||
|
w.__panelsClosed = true;
|
||||||
|
};
|
||||||
|
app.selectSession = (id: string) => {
|
||||||
|
w.__activation = `session:${id}`;
|
||||||
|
};
|
||||||
|
app.openWebview = (id: string) => {
|
||||||
|
w.__activation = `webview:${id}`;
|
||||||
|
};
|
||||||
|
w.__setApp(app);
|
||||||
|
w.__app = app;
|
||||||
|
// Escapes that reach the stand-in terminal (xterm listens on its textarea).
|
||||||
|
w.__termEscapes = 0;
|
||||||
|
document.getElementById('term')!.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') w.__termEscapes++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => browser.close());
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
puts = [];
|
||||||
|
await page.evaluate((layout) => {
|
||||||
|
const w = window as any;
|
||||||
|
document.getElementById('sessionTabs')?.remove();
|
||||||
|
document
|
||||||
|
.getElementById('tabRail')!
|
||||||
|
.insertAdjacentHTML(
|
||||||
|
'afterbegin',
|
||||||
|
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
||||||
|
);
|
||||||
|
w.__app._tabKeydownHandler = null;
|
||||||
|
w.__app._tabLayoutEditor?.dispose();
|
||||||
|
w.__app._tabLayoutEditor = null;
|
||||||
|
w.__app.tabLayout = null;
|
||||||
|
w.__app.activeSessionId = 'one';
|
||||||
|
w.__app.activeWebviewId = null;
|
||||||
|
// The rail was just replaced: force the rebuild a skipped no-op would miss.
|
||||||
|
w.__app._lastTabGroupStructureKey = null;
|
||||||
|
w.__app._applyTabLayout(layout);
|
||||||
|
w.__activation = null;
|
||||||
|
w.__panelsClosed = false;
|
||||||
|
w.__termEscapes = 0;
|
||||||
|
}, LAYOUT);
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = async (selector: string) => (await page.locator(selector).boundingBox())!;
|
||||||
|
async function drag(from: string, to: string, yFraction = 0.5) {
|
||||||
|
const a = await box(from);
|
||||||
|
const b = await box(to);
|
||||||
|
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(a.x + 24, a.y + a.height / 2 + 8, { steps: 3 });
|
||||||
|
await page.mouse.move(b.x + 30, b.y + b.height * yFraction, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
const settled = async () => {
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
await page.evaluate(() => new Promise((r) => setTimeout(r, 20)));
|
||||||
|
};
|
||||||
|
|
||||||
|
it('drags a row from Ungrouped onto a group header: one PUT appending it there', async () => {
|
||||||
|
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
|
||||||
|
await settled();
|
||||||
|
expect(puts).toHaveLength(1);
|
||||||
|
expect(puts[0].baseVersion).toBe(4);
|
||||||
|
expect(puts[0].layout.groups[1].refs).toEqual([
|
||||||
|
{ kind: 'session', id: 'three' },
|
||||||
|
{ kind: 'session', id: 'one' },
|
||||||
|
]);
|
||||||
|
expect(puts[0].layout.ungrouped).toEqual([]);
|
||||||
|
// The click that ends a drag neither selected the row nor toggled the header.
|
||||||
|
expect(await page.evaluate(() => (window as any).__activation)).toBeNull();
|
||||||
|
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||||
|
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drags a row between two rows of another group (upper half = before)', async () => {
|
||||||
|
await drag('.session-tab[data-id="three"]', '.session-tab[data-webview-id="web"]', 0.25);
|
||||||
|
await settled();
|
||||||
|
expect(puts).toHaveLength(1);
|
||||||
|
expect(puts[0].layout.groups[0].refs.map((r: any) => r.id)).toEqual(['two', 'three', 'web']);
|
||||||
|
expect(puts[0].layout.groups[1].refs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drags a group header above another group: a reorder', async () => {
|
||||||
|
await drag('[data-tab-group-header="gy"]', '[data-tab-group-header="gx"]');
|
||||||
|
await settled();
|
||||||
|
expect(puts).toHaveLength(1);
|
||||||
|
expect(puts[0].layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']);
|
||||||
|
expect(await page.locator('.tab-layout-group').first().getAttribute('data-tab-group-id')).toBe('gy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape mid-drag cancels it without a write; a plain click still selects', async () => {
|
||||||
|
const a = await box('.session-tab[data-id="one"]');
|
||||||
|
const b = await box('[data-tab-group-header="gy"]');
|
||||||
|
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
|
||||||
|
expect(await page.locator('.tab-layout-drop-into').count()).toBe(1);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.mouse.up();
|
||||||
|
await settled();
|
||||||
|
expect(puts).toHaveLength(0);
|
||||||
|
|
||||||
|
await page.locator('.session-tab[data-id="two"] .tab-name').click();
|
||||||
|
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a press released outside the rail leaves nothing behind, and Escape still reaches the terminal', async () => {
|
||||||
|
const a = await box('.session-tab[data-id="one"]');
|
||||||
|
// Press on a row and flick out of the rail in ONE move, releasing out there:
|
||||||
|
// the rail never sees the move or the release.
|
||||||
|
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(760, 600);
|
||||||
|
await page.mouse.up();
|
||||||
|
// The release was heard on window: the press is gone before any hover.
|
||||||
|
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
|
||||||
|
// Hovering back with no button down must not turn into a phantom drag.
|
||||||
|
const b = await box('[data-tab-group-header="gy"]');
|
||||||
|
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
|
||||||
|
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into, .tab-layout-drag-active').count()).toBe(0);
|
||||||
|
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
|
||||||
|
|
||||||
|
// A real drag after that, cancelled with Escape, then one more completed.
|
||||||
|
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.mouse.up();
|
||||||
|
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
|
||||||
|
await settled();
|
||||||
|
expect(puts).toHaveLength(1);
|
||||||
|
|
||||||
|
// No drag listener is left in document capture swallowing Escape.
|
||||||
|
await page.locator('#term').focus();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(2);
|
||||||
|
expect(await page.evaluate(() => (window as any).__app._tabLayoutDragKeydown)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each guard holds on its own: buttons-up move, a second press, no stacked Escape listener', async () => {
|
||||||
|
// Synthetic pointer events reach the cases a real mouse cannot isolate (a
|
||||||
|
// release outside the WINDOW never reaches any listener of ours).
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
const rail = document.getElementById('sessionTabs')!;
|
||||||
|
const at = (el: Element) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { clientX: r.left + 20, clientY: r.top + r.height / 2 };
|
||||||
|
};
|
||||||
|
const fire = (target: Element, type: string, init: PointerEventInit) =>
|
||||||
|
target.dispatchEvent(
|
||||||
|
new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...init })
|
||||||
|
);
|
||||||
|
const one = rail.querySelector('.session-tab[data-id="one"] .tab-name')!;
|
||||||
|
const three = rail.querySelector('.session-tab[data-id="three"] .tab-name')!;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// 1. A pending press, then a move with no button down: cancelled, no drag.
|
||||||
|
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
|
||||||
|
fire(three, 'pointermove', { buttons: 0, ...at(three) });
|
||||||
|
out.afterButtonsUp = w.__app._tabLayoutDrag;
|
||||||
|
out.dragClass = rail.querySelectorAll('.tab-layout-dragging').length;
|
||||||
|
|
||||||
|
// 2. An active drag whose release never arrived, then a new press.
|
||||||
|
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
|
||||||
|
fire(three, 'pointermove', { buttons: 1, ...at(three) });
|
||||||
|
out.firstActive = w.__app._tabLayoutDrag?.active === true;
|
||||||
|
const firstListener = w.__app._tabLayoutDragKeydown;
|
||||||
|
fire(three, 'pointerdown', { button: 0, buttons: 1, ...at(three) });
|
||||||
|
out.staleOrigin = rail.querySelectorAll('.tab-layout-dragging').length;
|
||||||
|
out.firstListenerKept = w.__app._tabLayoutDragKeydown === firstListener;
|
||||||
|
fire(one, 'pointermove', { buttons: 1, ...at(one) });
|
||||||
|
fire(one, 'pointerup', { button: 0, buttons: 0, ...at(one) });
|
||||||
|
out.leftover = w.__app._tabLayoutDragKeydown;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
expect(result.afterButtonsUp).toBeNull();
|
||||||
|
expect(result.dragClass).toBe(0);
|
||||||
|
expect(result.firstActive).toBe(true);
|
||||||
|
expect(result.staleOrigin).toBe(0);
|
||||||
|
expect(result.firstListenerKept).toBe(false);
|
||||||
|
expect(result.leftover).toBeNull();
|
||||||
|
await page.locator('#term').focus();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(1);
|
||||||
|
await settled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('committing a group rename by clicking elsewhere leaves focus where the click put it', async () => {
|
||||||
|
await page.evaluate(() => (window as any).__app.startTabGroupRename('gy'));
|
||||||
|
const input = page.locator('.tab-layout-group-rename-input');
|
||||||
|
await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.type('Elsewhere');
|
||||||
|
await page.locator('#term').click();
|
||||||
|
await settled();
|
||||||
|
expect(puts.at(-1).layout.groups[1].name).toBe('Elsewhere');
|
||||||
|
expect(await page.evaluate(() => document.activeElement?.id)).toBe('term');
|
||||||
|
// So Enter goes to the terminal, not to the header (which would collapse it).
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints the inline group editor as you type, then saves the trimmed name', async () => {
|
||||||
|
await page.evaluate(() => (window as any).__app.startTabGroupRename('gx'));
|
||||||
|
const input = page.locator('.tab-layout-group-rename-input');
|
||||||
|
await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.type('Front end');
|
||||||
|
const paint = await input.evaluate((el: HTMLInputElement) => ({
|
||||||
|
value: el.value,
|
||||||
|
width: el.getBoundingClientRect().width,
|
||||||
|
label: getComputedStyle(el.parentElement!).display,
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
}));
|
||||||
|
expect(paint.value).toBe('Front end');
|
||||||
|
// The editor gets the header's free width, not a collapsed ellipsis slot.
|
||||||
|
expect(paint.width).toBeGreaterThan(120);
|
||||||
|
expect(paint.label).toBe('flex');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await settled();
|
||||||
|
expect(puts.at(-1).layout.groups[0].name).toBe('Front end');
|
||||||
|
expect(await page.locator('[data-tab-group-header="gx"] .tab-layout-group-name').textContent()).toBe('Front end');
|
||||||
|
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the group menu from the header glyph and Escape closes only it', async () => {
|
||||||
|
await page.locator('[data-tab-group-header="gy"]').hover();
|
||||||
|
await page.locator('[data-tab-group-header="gy"] .tab-layout-group-menu').click();
|
||||||
|
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
|
||||||
|
// The glyph opened the menu without toggling the group.
|
||||||
|
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
|
||||||
|
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gy');
|
||||||
|
|
||||||
|
await page.locator('[data-tab-group-header="gy"]').click({ button: 'right' });
|
||||||
|
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
|
||||||
|
await page.locator('#elsewhere').click();
|
||||||
|
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -727,10 +727,17 @@ describe('grouped rail tree semantics', () => {
|
|||||||
row('s2').focus();
|
row('s2').focus();
|
||||||
press('F10', { shiftKey: true });
|
press('F10', { shiftKey: true });
|
||||||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
||||||
|
// A web tab's keys open its menu (settings + group moves); settings is one item.
|
||||||
row('w1').focus();
|
row('w1').focus();
|
||||||
press('ContextMenu');
|
press('ContextMenu');
|
||||||
|
const settings = [...document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button')].find(
|
||||||
|
(button) => button.textContent === 'Web tab settings'
|
||||||
|
)!;
|
||||||
|
settings.click();
|
||||||
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
||||||
|
row('w1').focus();
|
||||||
press('F10');
|
press('F10');
|
||||||
|
expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull();
|
||||||
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static guard: the inline rename editor is never line-clamped.
|
||||||
|
*
|
||||||
|
* A vertical rail or sidebar row clamps its name (2 lines, 3 in the detailed
|
||||||
|
* rail), and one shared rule unclamps `.tab-name.tab-name-renaming` so the
|
||||||
|
* editor can lay out as a flex row. A clamping rule MORE specific than that
|
||||||
|
* shared rule wins over it, which is how the detailed rail shipped with its
|
||||||
|
* 3-line clamp around the editor (#534, fixed alongside #526). The behavioural
|
||||||
|
* check lives in test/inline-rename.test.ts, a browser suite the CI gate does
|
||||||
|
* not run, so this pins the cascade from the stylesheet itself: every rule that
|
||||||
|
* clamps a rail or sidebar `.tab-name` must be out-ranked by an unclamp rule,
|
||||||
|
* either the shared one or its own `.tab-name-renaming` twin.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import postcss, { type Rule } from 'postcss';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
|
||||||
|
|
||||||
|
type Specificity = [number, number, number];
|
||||||
|
|
||||||
|
function compare(a: Specificity, b: Specificity): number {
|
||||||
|
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENT = /^-?(?:[\w-]|\\.)+/;
|
||||||
|
|
||||||
|
function closingParen(selector: string, open: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = open; i < selector.length; i++) {
|
||||||
|
if (selector[i] === '(') depth++;
|
||||||
|
else if (selector[i] === ')' && --depth === 0) return i;
|
||||||
|
}
|
||||||
|
return selector.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specificity of one complex selector, with :is()/:not()/:has() taking their
|
||||||
|
* most specific argument and :where() counting nothing. Hand-rolled because
|
||||||
|
* postcss (a dependency) splits selector lists but does not parse selectors;
|
||||||
|
* checked against postcss-selector-parser over every selector in styles.css.
|
||||||
|
*/
|
||||||
|
function specificityOf(selector: string): Specificity {
|
||||||
|
const total: Specificity = [0, 0, 0];
|
||||||
|
let i = 0;
|
||||||
|
while (i < selector.length) {
|
||||||
|
const ch = selector[i];
|
||||||
|
if (ch === '[') {
|
||||||
|
total[1]++;
|
||||||
|
i = selector.indexOf(']', i) + 1;
|
||||||
|
} else if (ch === '#' || ch === '.') {
|
||||||
|
total[ch === '#' ? 0 : 1]++;
|
||||||
|
i += 1 + (selector.slice(i + 1).match(IDENT)?.[0].length ?? 0);
|
||||||
|
} else if (ch === ':') {
|
||||||
|
const element = selector[i + 1] === ':';
|
||||||
|
const start = i + (element ? 2 : 1);
|
||||||
|
const name = selector.slice(start).match(IDENT)?.[0] ?? '';
|
||||||
|
i = start + name.length;
|
||||||
|
let args: string | null = null;
|
||||||
|
if (selector[i] === '(') {
|
||||||
|
const end = closingParen(selector, i);
|
||||||
|
args = selector.slice(i + 1, end);
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
|
if (element) total[2]++;
|
||||||
|
else if (name === 'where') continue;
|
||||||
|
else if (['is', 'not', 'has'].includes(name) && args !== null) {
|
||||||
|
const max = postcss.list
|
||||||
|
.comma(args)
|
||||||
|
.map(specificityOf)
|
||||||
|
.sort((a, b) => compare(b, a))[0] ?? [0, 0, 0];
|
||||||
|
for (let k = 0; k < 3; k++) total[k] += max[k];
|
||||||
|
} else total[1]++;
|
||||||
|
} else if (/[A-Za-z_]/.test(ch)) {
|
||||||
|
total[2]++;
|
||||||
|
i += selector.slice(i).match(IDENT)?.[0].length ?? 1;
|
||||||
|
} else i++;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = { selector: string; specificity: Specificity; order: number; rule: Rule };
|
||||||
|
|
||||||
|
/** Every complex selector in the stylesheet, flattened, with its source order. */
|
||||||
|
function entries(): Entry[] {
|
||||||
|
const out: Entry[] = [];
|
||||||
|
let order = 0;
|
||||||
|
postcss.parse(STYLES_CSS).walkRules((rule) => {
|
||||||
|
order++;
|
||||||
|
for (const selector of rule.selectors) {
|
||||||
|
out.push({ selector: selector.replace(/\s+/g, ' ').trim(), specificity: specificityOf(selector), order, rule });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function declares(rule: Rule, prop: string): string | null {
|
||||||
|
let value: string | null = null;
|
||||||
|
rule.walkDecls(prop, (decl) => {
|
||||||
|
value = decl.value.trim();
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unclamps = (rule: Rule) =>
|
||||||
|
['-webkit-line-clamp', 'line-clamp'].every((prop) => ['unset', 'none'].includes(declares(rule, prop) ?? ''));
|
||||||
|
|
||||||
|
describe('inline rename editor is never line-clamped', () => {
|
||||||
|
const all = entries();
|
||||||
|
const clamping = all.filter(
|
||||||
|
(e) =>
|
||||||
|
/\.tab-name$/.test(e.selector) &&
|
||||||
|
/\.tab-rail|\.session-sidebar/.test(e.selector) &&
|
||||||
|
/^\d+$/.test(declares(e.rule, '-webkit-line-clamp') ?? '')
|
||||||
|
);
|
||||||
|
const shared = all.filter((e) => e.selector.startsWith(':is(') && e.selector.endsWith('.tab-name.tab-name-renaming'));
|
||||||
|
|
||||||
|
it('finds the clamped rail rows and the shared unclamp rule', () => {
|
||||||
|
// The base rail row and the detailed rail card both clamp; if this drops to
|
||||||
|
// zero the selectors moved and the guard below is checking nothing.
|
||||||
|
expect(clamping.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(shared).toHaveLength(1);
|
||||||
|
expect(unclamps(shared[0].rule)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(clamping.map((e) => [e.selector, e] as const))('%s is out-ranked while renaming', (_selector, clamp) => {
|
||||||
|
const twin = all.filter((e) => e.selector === `${clamp.selector}.tab-name-renaming` && unclamps(e.rule));
|
||||||
|
const winners = [...shared, ...twin].filter((u) => {
|
||||||
|
const byWeight = compare(u.specificity, clamp.specificity);
|
||||||
|
return byWeight > 0 || (byWeight === 0 && u.order > clamp.order);
|
||||||
|
});
|
||||||
|
expect(winners.map((w) => w.selector)).not.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
|||||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
function makeApp(
|
||||||
|
opts: {
|
||||||
|
teammates?: number;
|
||||||
|
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||||||
|
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
const fit = vi.fn();
|
const fit = vi.fn();
|
||||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||||
@@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
const app = {
|
const app = {
|
||||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||||
|
// Secondary panes (the split pane's second terminal) are reached through
|
||||||
|
// this helper, so it is wired for real like the geometry chain below.
|
||||||
|
_forEachTile: mixin._forEachTile,
|
||||||
|
_splitPane: opts.splitPane ?? null,
|
||||||
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
||||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||||
// through the one function that floors the result and reports it (#464).
|
// through the one function that floors the result and reports it (#464).
|
||||||
@@ -98,6 +108,23 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('applyTerminalFontWeights', () => {
|
describe('applyTerminalFontWeights', () => {
|
||||||
|
it('reaches an open split pane: same weights, refit AND reported to its PTY', () => {
|
||||||
|
const splitTerminal = fakeTerminal();
|
||||||
|
// fit(), not localFit(): a weight change can move the cell size, and the
|
||||||
|
// split pane's PTY must hear about the new geometry like the primary's does.
|
||||||
|
const fit = vi.fn();
|
||||||
|
const { app } = makeApp({ splitPane: { terminal: splitTerminal, fit } });
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
terminalFontWeightBold: '700',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitTerminal.options.fontWeight).toBe(300);
|
||||||
|
expect(splitTerminal.options.fontWeightBold).toBe(700);
|
||||||
|
expect(fit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('writes both slots to the live terminal', () => {
|
it('writes both slots to the live terminal', () => {
|
||||||
const { app, fit } = makeApp();
|
const { app, fit } = makeApp();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The file-path / URL link provider can serve a second terminal.
|
||||||
|
*
|
||||||
|
* `registerFilePathLinkProvider()` (terminal-ui.js) was hardwired to the
|
||||||
|
* primary terminal and the ACTIVE session: a path clicked anywhere opened
|
||||||
|
* against `activeSessionId`. It now takes an optional target `{ terminal,
|
||||||
|
* getSessionId, setHovered }`, so the split pane's second terminal (and later a
|
||||||
|
* grid tile) gets clickable paths that open against ITS session. With no target
|
||||||
|
* it behaves exactly as before, and only the primary registration is kept on
|
||||||
|
* `_terminalLinkProvider`, which the touch-tap path reads.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type Link = { text: string; activate: (ev: unknown, text: string) => void; hover: () => void; leave: () => void };
|
||||||
|
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||||
|
|
||||||
|
function loadHarness() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const windowRef: Record<string, unknown> = {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: windowRef,
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
return new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-screen xterm stand-in holding `lines`, recording every provider registered on it. */
|
||||||
|
function fakeTerminal(lines: string[]) {
|
||||||
|
const providers: Provider[] = [];
|
||||||
|
return {
|
||||||
|
cols: 80,
|
||||||
|
providers,
|
||||||
|
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
length: lines.length,
|
||||||
|
getLine: (row: number) =>
|
||||||
|
row >= 0 && row < lines.length
|
||||||
|
? {
|
||||||
|
isWrapped: false,
|
||||||
|
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksOn(provider: Provider, line: number): Link[] {
|
||||||
|
let out: Link[] = [];
|
||||||
|
provider.provideLinks(line, (links) => {
|
||||||
|
out = links || [];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = loadHarness();
|
||||||
|
app.activeSessionId = 'primary-session';
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
app.openLogViewerWindow = vi.fn();
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with no target (the primary pane)', () => {
|
||||||
|
it('registers on the primary terminal, keeps the provider for the tap path, opens against the active session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal(['wrote /tmp/shot.png']);
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
expect(app.terminal.providers).toEqual([provider]);
|
||||||
|
expect(app._terminalLinkProvider).toBe(provider);
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/shot.png', 'primary-session');
|
||||||
|
link.hover();
|
||||||
|
expect(app._linkHovered).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with a target (a second pane)', () => {
|
||||||
|
it("registers on the target terminal and opens against the target's session, not the active one", () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal([]);
|
||||||
|
const primaryProvider = app.registerFilePathLinkProvider();
|
||||||
|
const paneTerminal = fakeTerminal(['tail -f /var/log/app.log']);
|
||||||
|
const setHovered = vi.fn();
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider({
|
||||||
|
terminal: paneTerminal,
|
||||||
|
getSessionId: () => 'pane-session',
|
||||||
|
setHovered,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paneTerminal.providers).toEqual([provider]);
|
||||||
|
expect(app.terminal.providers).toEqual([primaryProvider]);
|
||||||
|
// The tap path's provider is still the primary one.
|
||||||
|
expect(app._terminalLinkProvider).toBe(primaryProvider);
|
||||||
|
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'pane-session');
|
||||||
|
|
||||||
|
link.hover();
|
||||||
|
link.leave();
|
||||||
|
expect(setHovered.mock.calls).toEqual([[true], [false]]);
|
||||||
|
expect(app._linkHovered).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the session at click time, so a pane rebound to another session follows it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
let sessionId = 'first';
|
||||||
|
const paneTerminal = fakeTerminal(['see /tmp/a.pdf']);
|
||||||
|
const provider = app.registerFilePathLinkProvider({ terminal: paneTerminal, getSessionId: () => sessionId });
|
||||||
|
|
||||||
|
sessionId = 'second';
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/a.pdf', 'second');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview TerminalTile input rides the app's exactly-once queue, and only
|
||||||
|
* what a human typed is queued.
|
||||||
|
*
|
||||||
|
* Before, the split pane's second terminal sent every xterm `onData` chunk as a
|
||||||
|
* bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down,
|
||||||
|
* never acknowledged an idle alert. It now goes through `app._sendInputAsync`,
|
||||||
|
* over the pane's own socket registered in the app's input-socket map. That
|
||||||
|
* queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on
|
||||||
|
* its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane
|
||||||
|
* drops it, and a focus or mouse report goes out once, ephemeral.
|
||||||
|
*
|
||||||
|
* The socket's lifecycle is pinned here too: a transient drop reconnects on the
|
||||||
|
* primary pane's backoff and refreshes the buffer without leaving a stale
|
||||||
|
* "disconnected" marker under a healthy pane; codes that cannot get better stop
|
||||||
|
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
|
||||||
|
* destroy() cancels a pending reconnect.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
|
||||||
|
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
|
||||||
|
* fit addon and WebSocket are fakes; `connect()` runs for real.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
|
||||||
|
|
||||||
|
class FakeSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static instances: FakeSocket[] = [];
|
||||||
|
readyState = 0;
|
||||||
|
sent: Frame[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: ((ev?: { code: number }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(JSON.parse(data) as Frame);
|
||||||
|
}
|
||||||
|
close = vi.fn(() => {
|
||||||
|
this.readyState = 3;
|
||||||
|
});
|
||||||
|
open() {
|
||||||
|
this.readyState = 1;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
receive(msg: object) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||||
|
}
|
||||||
|
inputFrames() {
|
||||||
|
return this.sent.filter((f) => f.t === 'i');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
|
||||||
|
class FakeFit {
|
||||||
|
static proposed = { cols: 80, rows: 24 };
|
||||||
|
term: FakeTerminal | null = null;
|
||||||
|
fit() {
|
||||||
|
const { cols, rows } = FakeFit.proposed;
|
||||||
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
||||||
|
this.term?.resize(cols, rows);
|
||||||
|
}
|
||||||
|
proposeDimensions() {
|
||||||
|
return { ...FakeFit.proposed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTerminal {
|
||||||
|
static last: FakeTerminal | null = null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
dataCb: ((data: string) => void) | null = null;
|
||||||
|
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||||
|
constructor(options: Record<string, unknown>) {
|
||||||
|
this.options = { ...options };
|
||||||
|
FakeTerminal.last = this;
|
||||||
|
}
|
||||||
|
loadAddon(addon: FakeFit) {
|
||||||
|
addon.term = this;
|
||||||
|
}
|
||||||
|
open() {}
|
||||||
|
onData(cb: (data: string) => void) {
|
||||||
|
this.dataCb = cb;
|
||||||
|
}
|
||||||
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||||
|
focusListeners: Array<() => void> = [];
|
||||||
|
textarea = {
|
||||||
|
addEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners.push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
focusTextarea() {
|
||||||
|
for (const fn of this.focusListeners) fn();
|
||||||
|
}
|
||||||
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||||
|
this.keyHandler = fn;
|
||||||
|
}
|
||||||
|
registerLinkProvider() {}
|
||||||
|
writes: string[] = [];
|
||||||
|
write(data: string, cb?: () => void) {
|
||||||
|
this.writes.push(data);
|
||||||
|
cb?.();
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.writes.push('<CLEAR>');
|
||||||
|
}
|
||||||
|
resizes: Array<[number, number]> = [];
|
||||||
|
resize(cols: number, rows: number) {
|
||||||
|
this.resizes.push([cols, rows]);
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
dispose() {}
|
||||||
|
type(data: string) {
|
||||||
|
this.dataCb?.(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
function loadContext() {
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
|
||||||
|
// running inside this context.
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: FakeSocket,
|
||||||
|
Terminal: FakeTerminal,
|
||||||
|
FitAddon: { FitAddon: FakeFit },
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test' },
|
||||||
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: {
|
||||||
|
isTouchDevice: () => false,
|
||||||
|
isHandheldDevice: () => false,
|
||||||
|
getDeviceType: () => 'desktop',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
|
||||||
|
windowStub,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CodemanApp, windowStub } = loadContext();
|
||||||
|
|
||||||
|
type App = Record<string, unknown> & {
|
||||||
|
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._wsTabNonce = 'nonce-1';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._extraInputSockets = new Map();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
// The key handler's chord gates read the shortcut registry, which reads these.
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tile = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
reconnectNow(): void;
|
||||||
|
fit(opts?: { force?: boolean }): void;
|
||||||
|
detachedSessions?: Set<string>;
|
||||||
|
ws: FakeSocket | null;
|
||||||
|
_reconnectAttempts: number;
|
||||||
|
};
|
||||||
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tile a test creates, destroyed after it. A tile closed with real timers
|
||||||
|
* schedules a real reconnect, and one firing during a LATER test opens a socket
|
||||||
|
* there (FakeSocket.instances is shared), which flaked under full-suite load.
|
||||||
|
*/
|
||||||
|
const liveTiles: Tile[] = [];
|
||||||
|
|
||||||
|
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
||||||
|
windowStub.app = app;
|
||||||
|
const tile = new TerminalTile(
|
||||||
|
's-tile',
|
||||||
|
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
{ mode: 'claude', ...opts }
|
||||||
|
);
|
||||||
|
liveTiles.push(tile);
|
||||||
|
await tile.connect();
|
||||||
|
const ws = FakeSocket.instances.at(-1)!;
|
||||||
|
return { tile, ws, term: FakeTerminal.last! };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||||
|
FakeSocket.instances = [];
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: '' } }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile socket identity', () => {
|
||||||
|
it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
|
||||||
|
const cid = new URL(ws.url).searchParams.get('cid');
|
||||||
|
expect(cid).toBe('c-test:nonce-1:tile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile input through the exactly-once queue', () => {
|
||||||
|
it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('h');
|
||||||
|
term.type('i');
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['h', 1, 'c-test'],
|
||||||
|
['i', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a record on its ACK and acknowledges the session idle alert', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('x');
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.receive({ t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes input typed before the socket opened, in order, once it does', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
// POSTs fail, as they would while the server restarts, so the input waits.
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) }));
|
||||||
|
term.type('a');
|
||||||
|
term.type('b');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([
|
||||||
|
['a', 1],
|
||||||
|
['b', 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => {
|
||||||
|
if (init?.body) posts.push(JSON.parse(init.body));
|
||||||
|
return { ok: true, status: 200, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
term.type('z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what xterm generates never enters the durable queue', () => {
|
||||||
|
it('drops a DA query reply entirely', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[?1;2c');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a mouse report once, without a seq, and never persists it', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[<0;10;5M');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a focus report once, without a seq', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[I');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile leaves the input-socket map', () => {
|
||||||
|
it('on close, so a later keystroke cannot be written into a dead socket', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(true);
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on destroy, while pending input stays queued for the HTTP sweep', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('q');
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMarker = (data: string) => data.includes('[disconnected');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the async buffer refresh settle: the fetch, the body read, the chunked
|
||||||
|
* write AND the load's finally block, which is where a stale marker would be
|
||||||
|
* stamped. Too few turns here and that assertion passes vacuously.
|
||||||
|
*/
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TerminalTile reconnects after a transient drop', () => {
|
||||||
|
it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: 'fresh screen' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
expect(ws2.url).toBe(ws.url);
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The refresh cleared the pane and replayed the current screen, and nothing
|
||||||
|
// after that clear is a marker: the pane is healthy again.
|
||||||
|
const lastClear = term.writes.lastIndexOf('<CLEAR>');
|
||||||
|
expect(lastClear).toBeGreaterThan(-1);
|
||||||
|
expect(term.writes.slice(lastClear)).toContain('fresh screen');
|
||||||
|
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
expect(tile.ws).toBe(ws2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts failed attempts and resets the count only on a successful open', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too
|
||||||
|
expect(tile._reconnectAttempts).toBe(2);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
FakeSocket.instances[2].open();
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1005 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnectNow() skips the backoff, but never replaces an open socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
// The backoff timer it pre-empted must not open a third socket later.
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile stops for good on codes that cannot get better', () => {
|
||||||
|
it.each([
|
||||||
|
[4004, 'the session ended'],
|
||||||
|
[4009, 'the session ended'],
|
||||||
|
[4003, 'the server refused this connection'],
|
||||||
|
[4010, 'another connection took over this pane'],
|
||||||
|
])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code });
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
tile.reconnectNow();
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onExit).toHaveBeenCalledWith(code);
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile ignores a socket it already replaced', () => {
|
||||||
|
it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app, { onExit });
|
||||||
|
ws.open();
|
||||||
|
const lateClose = ws.onclose!;
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
// The server supersedes the old socket by cid; its close arrives late.
|
||||||
|
lateClose({ code: 4010 });
|
||||||
|
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
term.type('still typing');
|
||||||
|
expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile destroy()', () => {
|
||||||
|
it('cancels a pending reconnect and never reports an exit afterwards', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.destroy();
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => {
|
||||||
|
const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z');
|
||||||
|
|
||||||
|
it('announces its size as a desktop viewer when the socket opens', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend an unchanged size, sends a changed one, and force resends', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 100, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
|
||||||
|
|
||||||
|
tile.fit({ force: true });
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-announces an unchanged size on a reconnected socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 28, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(term.cols).toBe(28);
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: NaN, rows: NaN };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
expect([term.cols, term.rows]).toEqual([80, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands aside for a session detached into its own window', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) });
|
||||||
|
ws.open();
|
||||||
|
FakeFit.proposed = { cols: 120, rows: 40 };
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts the column count the PTY reports, keeping its own rows', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 132, r: 50 });
|
||||||
|
|
||||||
|
expect([term.cols, term.rows]).toEqual([132, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the pane alone when the PTY agrees on width', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
const before = term.resizes.length;
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 80, r: 60 });
|
||||||
|
|
||||||
|
expect(term.resizes.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile links and paste follow THIS pane', () => {
|
||||||
|
it('registers the shared link provider on its own terminal, resolving its own session', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const register = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider = register;
|
||||||
|
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string };
|
||||||
|
expect(target.terminal).toBe(term);
|
||||||
|
expect(target.getSessionId()).toBe('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(paste).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
|
||||||
|
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, term } = await connectTile(app);
|
||||||
|
expect(app._focusedTile ?? null).toBeNull();
|
||||||
|
|
||||||
|
term.focusTextarea();
|
||||||
|
expect(app._focusedTile).toBe(tile);
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
expect(app._focusedTile).toBeNull();
|
||||||
|
expect(term.focusListeners).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the server coming back kicks Pane B', () => {
|
||||||
|
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
|
||||||
|
// handleInit needs a whole app to run, so the wiring is pinned by source;
|
||||||
|
// reconnectNow() itself is exercised above.
|
||||||
|
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId');
|
||||||
|
const branch = appSource.slice(start, appSource.indexOf('} else {', start));
|
||||||
|
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// test/split-pane-terminal-unit.test.ts
|
// test/terminal-tile-unit.test.ts
|
||||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
|
||||||
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
||||||
//
|
//
|
||||||
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from
|
// Unit coverage for the two TerminalTile (terminal-tile.js, the split pane's
|
||||||
|
// Pane B until it moved out of terminal-split.js) fixes from
|
||||||
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
||||||
// handler (onclose used to survive it and fire its "disconnected" write into a
|
// handler (onclose used to survive it and fire its "disconnected" write into a
|
||||||
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
||||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
||||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
||||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-tile.js.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
@@ -73,9 +74,9 @@ const fetchMock = vi.fn();
|
|||||||
const rafQueue: Array<() => void> = [];
|
const rafQueue: Array<() => void> = [];
|
||||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||||
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
||||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
|
||||||
|
|
||||||
function loadSplitTerminalPane() {
|
function loadTerminalTile() {
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
// The primary pane's row estimator, reduced to a line count: the pull only
|
||||||
@@ -111,18 +112,18 @@ function loadSplitTerminalPane() {
|
|||||||
});
|
});
|
||||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||||
.SplitTerminalPane;
|
.TerminalTile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitTerminalPane = loadSplitTerminalPane();
|
const TerminalTile = loadTerminalTile();
|
||||||
|
|
||||||
function makePane(
|
function makePane(
|
||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
mount: unknown = {},
|
mount: unknown = {},
|
||||||
opts: { detachedSessions?: Set<string> } = {}
|
opts: { detachedSessions?: Set<string> } = {}
|
||||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
const pane = new TerminalTile('s1', mount, { mode, ...opts });
|
||||||
pane.terminal = {
|
pane.terminal = {
|
||||||
// xterm invokes a write's callback once everything before it is parsed.
|
// xterm invokes a write's callback once everything before it is parsed.
|
||||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||||
@@ -175,7 +176,8 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
|
// Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
|
||||||
|
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
|
||||||
|
|
||||||
/** Lets every microtask the vm-side promise chain queued run. */
|
/** Lets every microtask the vm-side promise chain queued run. */
|
||||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||||
@@ -187,7 +189,7 @@ beforeEach(() => {
|
|||||||
clock = 0;
|
clock = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane.destroy()', () => {
|
describe('TerminalTile.destroy()', () => {
|
||||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
const terminal = pane.terminal;
|
const terminal = pane.terminal;
|
||||||
@@ -210,7 +212,7 @@ describe('SplitTerminalPane.destroy()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane server-refresh single-flight', () => {
|
describe('TerminalTile server-refresh single-flight', () => {
|
||||||
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||||
@@ -329,7 +331,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane scroll-to-top history pull', () => {
|
describe('TerminalTile scroll-to-top history pull', () => {
|
||||||
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
||||||
const pane = makePane('shell');
|
const pane = makePane('shell');
|
||||||
const term = pane.terminal;
|
const term = pane.terminal;
|
||||||
@@ -721,8 +723,11 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
expect(connect).toContain('this._installWheelListener();');
|
expect(connect).toContain('this._installWheelListener();');
|
||||||
expect(connect).toContain('this._onLiveClear();');
|
expect(connect).toContain('this._onLiveClear();');
|
||||||
expect(connect).not.toContain('this.terminal.clear();');
|
expect(connect).not.toContain('this.terminal.clear();');
|
||||||
// The tests below drive the close through _onSocketClosed() directly.
|
// The tests below drive the close through _onSocketClosed() directly; the
|
||||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
// socket's own handler forwards the close event (and its code) there, and
|
||||||
|
// only for the current socket (_openSocket).
|
||||||
|
expect(connect).toContain('this._onSocketClosed(event);');
|
||||||
|
expect(connect).toMatch(/ws\.onclose = \(event\) => \{\s*if \(ws !== this\.ws\) return;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a close with no pull running writes the marker straight away', () => {
|
it('a close with no pull running writes the marker straight away', () => {
|
||||||
@@ -746,9 +751,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
void pane._pullHistory();
|
void pane._pullHistory();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const marker = expect.stringContaining('Pane B disconnected');
|
const marker = expect.stringContaining('[disconnected');
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1003,7 +1008,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
for (const call of pane.terminal.write.mock.calls) {
|
for (const call of pane.terminal.write.mock.calls) {
|
||||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
expect(call[0]).toEqual(expect.not.stringMatching(/\[disconnected/));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Dictation lands in the session it was started for.
|
||||||
|
*
|
||||||
|
* `VoiceInput` (voice-input.js) used to read `app.activeSessionId` when the
|
||||||
|
* transcript ARRIVED, and again when the green send button or the compose
|
||||||
|
* overlay's Send was pressed. Both happen seconds after recording started, so a
|
||||||
|
* user who switched tabs in between had their dictation typed into the other
|
||||||
|
* session. The target is now captured in `start()` (through `_focusedPane()`,
|
||||||
|
* so a second terminal pane can claim it later) and every send path uses it.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed `app` (no jsdom).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const voiceSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/voice-input.js'), 'utf8');
|
||||||
|
|
||||||
|
type Voice = {
|
||||||
|
start: () => void;
|
||||||
|
_insertText: (text: string) => void;
|
||||||
|
_resolveProvider: () => string;
|
||||||
|
_startWebSpeech: () => void;
|
||||||
|
_showComposeOverlay: (text: string) => void;
|
||||||
|
_voiceSendHandler: (() => void) | null;
|
||||||
|
_targetSessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
|
||||||
|
const sendInput = vi.fn(async () => {});
|
||||||
|
const sendInputAsync = vi.fn();
|
||||||
|
const appendText = vi.fn();
|
||||||
|
const showToast = vi.fn();
|
||||||
|
const gear = {
|
||||||
|
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
|
||||||
|
innerHTML: '',
|
||||||
|
title: '',
|
||||||
|
getAttribute: () => null,
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
removeAttribute: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
const app: Record<string, unknown> = {
|
||||||
|
activeSessionId: 'session-a',
|
||||||
|
sessions: new Map([
|
||||||
|
['session-a', {}],
|
||||||
|
['session-b', {}],
|
||||||
|
]),
|
||||||
|
sendInput,
|
||||||
|
_sendInputAsync: sendInputAsync,
|
||||||
|
showToast,
|
||||||
|
terminal: { focus: vi.fn() },
|
||||||
|
_localEchoEnabled: !!opts.localEcho,
|
||||||
|
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
|
||||||
|
};
|
||||||
|
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
setTimeout: (fn: () => void) => fn(),
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval: () => {},
|
||||||
|
app,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key: string) =>
|
||||||
|
key === 'codeman-voice-settings' ? JSON.stringify({ insertMode: opts.insertMode || 'direct' }) : null,
|
||||||
|
setItem: () => {},
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
querySelector: (sel: string) => (sel === '.btn-settings' ? gear : null),
|
||||||
|
createElement: () => ({}),
|
||||||
|
body: { appendChild: () => {} },
|
||||||
|
},
|
||||||
|
window: {},
|
||||||
|
navigator: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${voiceSource}\nglobalThis.__VoiceInput = VoiceInput;`, context);
|
||||||
|
const voice = (context as unknown as { __VoiceInput: Voice }).__VoiceInput;
|
||||||
|
// Recording itself is out of scope: start() only has to pick the target.
|
||||||
|
voice._resolveProvider = () => 'webspeech';
|
||||||
|
voice._startWebSpeech = vi.fn();
|
||||||
|
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dictation target', () => {
|
||||||
|
it('sends to the session recording started in, even after a tab switch', () => {
|
||||||
|
const { voice, app, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b'; // user switched tabs while speaking
|
||||||
|
|
||||||
|
voice._insertText('fix the login bug');
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'fix the login bug', { useMux: true });
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing path when the target is still the active session', () => {
|
||||||
|
const { voice, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('hello');
|
||||||
|
|
||||||
|
expect(sendInput).toHaveBeenCalledWith('hello');
|
||||||
|
expect(sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never types another session dictation into the active local-echo overlay', () => {
|
||||||
|
const { voice, app, appendText, sendInputAsync } = load({ localEcho: true });
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
|
||||||
|
voice._insertText('for session a');
|
||||||
|
|
||||||
|
expect(appendText).not.toHaveBeenCalled();
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'for session a', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still uses the local-echo overlay for the active session', () => {
|
||||||
|
const { voice, appendText, sendInput } = load({ localEcho: true });
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('typed locally');
|
||||||
|
|
||||||
|
expect(appendText).toHaveBeenCalledWith('typed locally');
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes its target from the focused pane when one is reported', () => {
|
||||||
|
const { voice, sendInputAsync } = load({ focused: 'session-b' });
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('into pane b');
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-b', 'into pane b', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the green send button sends Enter to the dictation target', () => {
|
||||||
|
const { voice, app, gear, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
voice._insertText('ship it');
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
|
||||||
|
|
||||||
|
handler();
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenLastCalledWith('session-a', '\r', { useMux: true });
|
||||||
|
// Only the original insert went through sendInput (target was active then).
|
||||||
|
expect(sendInput).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops dictation for a session that closed meanwhile, with a toast', () => {
|
||||||
|
const { voice, app, sendInput, sendInputAsync, showToast } = load();
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
(app.sessions as Map<string, unknown>).delete('session-a');
|
||||||
|
|
||||||
|
voice._insertText('too late');
|
||||||
|
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start with no session at all', () => {
|
||||||
|
const { voice, app, showToast } = load();
|
||||||
|
app.activeSessionId = null;
|
||||||
|
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith('No active session', 'warning');
|
||||||
|
expect(voice._targetSessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview How the workspace hook and statusLine helpers in hooks-config.ts
|
||||||
|
* read an "unknown" answer from the bounded path probe. A dead network mount
|
||||||
|
* elsewhere on the machine (enough of them to engage the probe's stall cap) must
|
||||||
|
* not stop Codeman's hooks from being installed in a healthy workspace, and must
|
||||||
|
* not let the plan-usage exporter be injected over a user's own statusLine. A
|
||||||
|
* workspace that IS on the dead mount is skipped without hanging the caller.
|
||||||
|
*
|
||||||
|
* Real temp directories; only `stat()` of the chosen dead paths is made to hang.
|
||||||
|
* Port: none.
|
||||||
|
*/
|
||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const probe = vi.hoisted(() => {
|
||||||
|
// Short probe timeout so the stalls below cost ~100 ms each, read at import.
|
||||||
|
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||||
|
return { dead: new Set<string>(), releases: [] as Array<() => void> };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
/** A call on a dead path never settles (a hard mount), until afterEach releases it. */
|
||||||
|
const hangOnDead = <F extends (...a: never[]) => unknown>(real: F): F =>
|
||||||
|
((path: string, ...rest: unknown[]) => {
|
||||||
|
for (const dead of probe.dead) {
|
||||||
|
if (String(path) === dead || String(path).startsWith(dead + '/')) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
|
||||||
|
void resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
||||||
|
}) as unknown as F;
|
||||||
|
const hung = {
|
||||||
|
stat: hangOnDead(actual.stat),
|
||||||
|
lstat: hangOnDead(actual.lstat),
|
||||||
|
readFile: hangOnDead(actual.readFile),
|
||||||
|
realpath: hangOnDead(actual.realpath),
|
||||||
|
};
|
||||||
|
return { ...actual, ...hung, default: { ...actual, ...hung } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { applyWorkspaceHooks, resolveStatusLineCliCommand, stripCaseEnvKeys } from '../src/hooks-config.js';
|
||||||
|
import { probePath } from '../src/utils/index.js';
|
||||||
|
import { MAX_STALLED_PATH_PROBES } from '../src/config/path-probe.js';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-unreachable-mount-'));
|
||||||
|
|
||||||
|
/** Stall `count` paths on unrelated "mounts" until afterEach releases them. */
|
||||||
|
async function stallUnrelatedMounts(count: number): Promise<void> {
|
||||||
|
const paths = Array.from({ length: count }, (_, i) => `/mnt/dead-nas-${i}/project`);
|
||||||
|
paths.forEach((p) => probe.dead.add(p));
|
||||||
|
expect(await Promise.all(paths.map((p) => probePath(p)))).toEqual(paths.map(() => 'unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let warn: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
probe.dead.clear();
|
||||||
|
probe.releases.splice(0).forEach((release) => release());
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace helpers while other mounts are unreachable', () => {
|
||||||
|
it('installs hooks in a healthy workspace while the stall cap is engaged', async () => {
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
const workspace = join(root, 'healthy-a');
|
||||||
|
mkdirSync(workspace);
|
||||||
|
|
||||||
|
await applyWorkspaceHooks(workspace, true);
|
||||||
|
|
||||||
|
const settings = join(workspace, '.claude', 'settings.local.json');
|
||||||
|
expect(existsSync(settings)).toBe(true);
|
||||||
|
expect(readFileSync(settings, 'utf-8')).toContain('/api/hook-event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a deleted workspace deleted while the stall cap is engaged', async () => {
|
||||||
|
// The cap refuses the probe ("unknown" without a stat), which must not read as
|
||||||
|
// "go ahead": installing would mkdir -p the deleted repo back into existence.
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
const workspace = join(root, 'deleted-repo');
|
||||||
|
expect(await probePath(workspace)).toBe('unknown');
|
||||||
|
|
||||||
|
await applyWorkspaceHooks(workspace, true);
|
||||||
|
await applyWorkspaceHooks(workspace, false);
|
||||||
|
|
||||||
|
expect(existsSync(workspace)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs hooks in a healthy workspace while fewer unrelated paths are stalled than the cap', async () => {
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES - 1);
|
||||||
|
const workspace = join(root, 'healthy-b');
|
||||||
|
mkdirSync(workspace);
|
||||||
|
|
||||||
|
await applyWorkspaceHooks(workspace, true);
|
||||||
|
|
||||||
|
expect(existsSync(join(workspace, '.claude', 'settings.local.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips, without hanging, a workspace that sits on the dead mount', async () => {
|
||||||
|
const workspace = '/mnt/dead-nas-x/project';
|
||||||
|
probe.dead.add('/mnt/dead-nas-x');
|
||||||
|
expect(await probePath(workspace)).toBe('unknown');
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
await applyWorkspaceHooks(join('/mnt/dead-nas-x', 'project'), true);
|
||||||
|
expect(Date.now() - started).toBeLessThan(1_000);
|
||||||
|
expect(
|
||||||
|
warn.mock.calls.some(
|
||||||
|
(c: unknown[]) => /hooks/i.test(String(c[0])) && String(c[0]).includes('/mnt/dead-nas-x/project')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a superseded env key from a healthy workspace while the stall cap is engaged', async () => {
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
const workspace = join(root, 'strip-env');
|
||||||
|
mkdirSync(join(workspace, '.claude'), { recursive: true });
|
||||||
|
const settings = join(workspace, '.claude', 'settings.local.json');
|
||||||
|
writeFileSync(settings, JSON.stringify({ env: { CLAUDE_CODE_STALE: '1', USER_KEEP: '2' } }));
|
||||||
|
|
||||||
|
await stripCaseEnvKeys(workspace, ['CLAUDE_CODE_STALE']);
|
||||||
|
|
||||||
|
expect(JSON.parse(readFileSync(settings, 'utf-8')).env).toEqual({ USER_KEEP: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never injects the exporter over a user's own statusLine while the cap is engaged", async () => {
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
const workspace = join(root, 'own-statusline');
|
||||||
|
mkdirSync(join(workspace, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(workspace, '.claude', 'settings.local.json'),
|
||||||
|
JSON.stringify({ statusLine: { type: 'command', command: 'my-own-statusline' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await resolveStatusLineCliCommand(workspace, true)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still injects the exporter in a healthy workspace without one while the cap is engaged', async () => {
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
const workspace = join(root, 'no-statusline');
|
||||||
|
mkdirSync(workspace);
|
||||||
|
|
||||||
|
expect(await resolveStatusLineCliCommand(workspace, true)).toMatch(/statusline-exporter\.sh$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips, within the probe timeout, dead workspaces whose probes the bulk cap refused', async () => {
|
||||||
|
// Two unrelated stalls engage the bulk cap, so probes of these two paths are
|
||||||
|
// refused without a stat. Their lstat and readFile hang too: a helper that
|
||||||
|
// touched them directly would never return and would hold a threadpool worker.
|
||||||
|
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
|
||||||
|
probe.dead.add('/mnt/dead-nas-c');
|
||||||
|
probe.dead.add('/mnt/dead-nas-d');
|
||||||
|
const within = <T>(work: Promise<T>) =>
|
||||||
|
Promise.race([work, new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000))]);
|
||||||
|
|
||||||
|
expect(await within(applyWorkspaceHooks('/mnt/dead-nas-c/project', true))).toBeUndefined();
|
||||||
|
expect(await within(resolveStatusLineCliCommand('/mnt/dead-nas-d/project', true))).toBeUndefined();
|
||||||
|
expect(existsSync('/mnt/dead-nas-c/project')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject the exporter into a workspace on the dead mount', async () => {
|
||||||
|
probe.dead.add('/mnt/dead-nas-y');
|
||||||
|
expect(await probePath('/mnt/dead-nas-y/project')).toBe('unknown');
|
||||||
|
|
||||||
|
expect(await resolveStatusLineCliCommand('/mnt/dead-nas-y/project', true)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user