mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107e87e457 | ||
|
|
09daf0fe49 | ||
|
|
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",
|
||||
"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.",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# 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
|
||||
|
||||
### 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.
|
||||
|
||||
**Version**: 1.34.0 (must match `package.json`)
|
||||
**Version**: 1.35.0 (must match `package.json`)
|
||||
|
||||
## 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`
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
@@ -249,7 +250,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
@@ -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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
**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)
|
||||
|
||||
**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 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-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)
|
||||
|
||||
|
||||
@@ -444,8 +444,11 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## 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
|
||||
- **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)
|
||||
- **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
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.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-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -35,6 +36,9 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.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-auto-collapse.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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -175,15 +175,19 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
|
||||
|
||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||
|
||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache.
|
||||
|
||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
||||
**Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get `<g class="lineage-family lineage-family--focus">`: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap).
|
||||
|
||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||
**Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family `<g class="lineage-family">`, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working".
|
||||
|
||||
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
|
||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines paint OVER it, which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. The sidebar layout draws no lineage.
|
||||
|
||||
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since clamping would point at a tab that is not there, and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||
|
||||
History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely.
|
||||
|
||||
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2"; per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the lines below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
||||
|
||||
### Auto-named sessions (first prompt → tab title)
|
||||
|
||||
@@ -296,14 +300,16 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
|
||||
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
|
||||
|
||||
⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe:
|
||||
⚠️ **The frontend READS and EDITS the layout.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections and edits it. The rules that keep it safe:
|
||||
|
||||
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing.
|
||||
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
|
||||
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
|
||||
- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible.
|
||||
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||
- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||
|
||||
@@ -319,7 +325,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||
|
||||
⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints.
|
||||
⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir, and skips one that does not answer, such as a linked case on an unreachable mount, without waiting on it: `applyWorkspaceHooks` asks the bounded path probe, retries one capacity-refused probe past the bulk cap, and skips whatever is still "unknown" rather than touching it with an unbounded `lstat`). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints.
|
||||
|
||||
The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path.
|
||||
|
||||
@@ -769,7 +775,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
||||
|
||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
|
||||
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
|
||||
|
||||
| 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). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
||||
## Lineage
|
||||
|
||||
A create request can name the session that spawned it, through a body field or a header, and
|
||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
||||
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||
|
||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||
failing the spawn, because a cosmetic field must never break a worker.
|
||||
|
||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
||||
enforces it.
|
||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||
- The desktop home tab rail, which needs a wide window.
|
||||
- Lineage arcs, which are a desktop overlay.
|
||||
- Lineage lines, which are a desktop overlay.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
||||
|
||||
| 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. |
|
||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
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
|
||||
@@ -82,7 +89,7 @@ every session or only the active tab.
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
@@ -145,8 +152,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
### System
|
||||
|
||||
`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
|
||||
**Users** administration entry is injected here.
|
||||
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
||||
`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
|
||||
|
||||
@@ -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_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_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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| **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`
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
@@ -84,13 +84,18 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||
|
||||
### Lineage arcs
|
||||
### Lineage lines
|
||||
|
||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
||||
how a fan-out of eight workers stays readable.
|
||||
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
||||
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
||||
working. It is how a fan-out of eight workers stays readable.
|
||||
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
||||
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
||||
lines, so switching tabs never changes the header height.
|
||||
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
||||
skipped for tabs scrolled out of the strip.
|
||||
|
||||
## Header controls
|
||||
@@ -124,6 +129,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
|
||||
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
|
||||
|
||||
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
||||
|
||||
@@ -22,13 +22,18 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||
eight workers looks like a stalled terminal for several minutes.
|
||||
|
||||
## Session lineage arcs
|
||||
## Session lineage lines
|
||||
|
||||
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
||||
whole sessions started by an agent through the API.
|
||||
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||
agent through the API.
|
||||
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
||||
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
||||
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
||||
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
||||
dashed branch means that child is working.
|
||||
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
||||
for tabs scrolled out of view.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"author": {
|
||||
"name": "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`;
|
||||
- `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`);
|
||||
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
|
||||
`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`;
|
||||
- `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`);
|
||||
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
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* @module config/dependency-registry
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { enabledClis } from './cli-registry/registry.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.
|
||||
*/
|
||||
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. */
|
||||
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
|
||||
// (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.
|
||||
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 { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
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 { dataPath } from './config/instance.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
|
||||
@@ -558,7 +603,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
if (keysToRemove.length === 0) return;
|
||||
|
||||
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
|
||||
if (!existsSync(settingsPath)) return;
|
||||
if (!(await pathExistsForWrite(settingsPath))) return;
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
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> {
|
||||
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
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> {
|
||||
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
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> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
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> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
if (!(await pathExistsForWrite(claudeDir))) {
|
||||
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.
|
||||
*/
|
||||
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) => {
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
@@ -820,7 +865,17 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
||||
*/
|
||||
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
|
||||
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());
|
||||
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
|
||||
} catch {
|
||||
@@ -883,7 +938,7 @@ export function generateStatusLineCommand(): string {
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
if (await pathExistsForWrite(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
@@ -898,7 +953,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
const desired = generateStatusLineCommand();
|
||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||
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
|
||||
} else {
|
||||
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> {
|
||||
if (!existsSync(settingsPath)) return undefined;
|
||||
if (await absentOrUnreachable(settingsPath)) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
const current = parsed.statusLine as { command?: unknown } | undefined;
|
||||
@@ -1103,9 +1158,21 @@ export async function resolveStatusLineCliCommand(
|
||||
): Promise<string | undefined> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
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 {
|
||||
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;
|
||||
if (current && typeof current.command === 'string') {
|
||||
if (current.command.includes(STATUSLINE_MARKER)) {
|
||||
|
||||
@@ -157,6 +157,13 @@ export interface CaseInfo {
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
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
|
||||
* (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;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
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';
|
||||
|
||||
export interface EnvDetectionInputs {
|
||||
@@ -62,6 +64,8 @@ export interface ProbeHost {
|
||||
environment: ProbeEnvironment;
|
||||
which(bin: string): string | null;
|
||||
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;
|
||||
windowsProgramRoots(): string[];
|
||||
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.resolver.kind === 'path') {
|
||||
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
|
||||
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
|
||||
for (const bin of bins) {
|
||||
const resolved = host.which(bin);
|
||||
if (resolved) {
|
||||
const out = host.runVersion(bin, [versionArg ?? '--version']);
|
||||
// The same candidate order and the same per-candidate test as the run mode's resolver
|
||||
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
|
||||
// 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;
|
||||
// A generic binary name that prints the wrong thing is some OTHER program (see
|
||||
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; 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".
|
||||
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
|
||||
// the alternative is claiming a tool is installed that the feature's own resolver
|
||||
// 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;
|
||||
return finalize(base, tool, resolved, version);
|
||||
return finalize(base, tool, candidate, version);
|
||||
}
|
||||
}
|
||||
return { ...base, status: 'missing', installHint };
|
||||
@@ -132,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
|
||||
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 {
|
||||
try {
|
||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const first = out.split(/\r?\n/)[0]?.trim();
|
||||
return first && existsSync(first) ? first : null;
|
||||
@@ -151,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// 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], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const out = execFileSync(
|
||||
'powershell.exe',
|
||||
['-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();
|
||||
return out || null;
|
||||
} catch {
|
||||
@@ -209,6 +235,7 @@ export function createRealHost(): ProbeHost {
|
||||
environment,
|
||||
which: safeWhich,
|
||||
fileExists: existsSync,
|
||||
isExecutableFile: isExecutableRegularFile,
|
||||
runVersion: safeRunVersion,
|
||||
windowsProgramRoots: listWindowsProgramRoots,
|
||||
windowsFileVersion: readWindowsFileVersion,
|
||||
|
||||
@@ -68,3 +68,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.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}` };
|
||||
}
|
||||
}
|
||||
+721
-23
@@ -577,14 +577,23 @@ const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
*/
|
||||
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 {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
||||
// Owner tab layout: read via GET /api/tab-layout and edited from the vertical
|
||||
// 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.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
@@ -866,6 +875,8 @@ class CodemanApp {
|
||||
// Flush the durable queue synchronously when the page is hidden/closed —
|
||||
// debounced persistence may have a pending write we mustn't lose on reload.
|
||||
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', () => {
|
||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||
// A background tab's timers are throttled, so the 5s watchdog may not
|
||||
@@ -1267,6 +1278,16 @@ class CodemanApp {
|
||||
|
||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||
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.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
@@ -5220,6 +5241,9 @@ class CodemanApp {
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
this._scrollActiveTabIntoView(sessionId);
|
||||
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
|
||||
// selection change is a redraw whenever any lineage exists at all.
|
||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5612,13 +5636,15 @@ class CodemanApp {
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||
// rail, which can show connectors with zero lineage edges too) — the
|
||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: any lineage at all (not just what is
|
||||
// drawn now: a parentSessionId or a child's working state can arrive on this
|
||||
// path and change the selected family's tree), or, in a VERTICAL list (the
|
||||
// sidebar, where lineage is skipped, or the rail, which can show connectors
|
||||
// with zero lineage edges too), the subagent/ultracode connectors, whose
|
||||
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
|
||||
// refreshed _lineageTotalEdges.
|
||||
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
}
|
||||
@@ -5630,6 +5656,10 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
// Lineage routing room (session-lineage.js) changes the strip's padding and
|
||||
// row gap, so it is decided before the wrap is measured below.
|
||||
this._syncLineageGutter?.();
|
||||
|
||||
// The sidebar list is a single vertical column with its own scroller —
|
||||
// there is no row to overflow, and measuring it would fight the CSS.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
@@ -5695,6 +5725,8 @@ class CodemanApp {
|
||||
_fullRenderSessionTabs() {
|
||||
this.closeTabRailActionMenu?.();
|
||||
if (this._inlineRenameActive) return;
|
||||
// The group menu's trigger is about to be replaced.
|
||||
this.closeTabGroupMenu();
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
||||
@@ -5724,9 +5756,11 @@ class CodemanApp {
|
||||
// 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
|
||||
// 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);
|
||||
this._tabFocusIdentity = null;
|
||||
this._tabRefocusAfterEdit = false;
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
@@ -5887,6 +5921,9 @@ class CodemanApp {
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
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
|
||||
this.setupTabKeyboardNavigation(container);
|
||||
@@ -6211,18 +6248,22 @@ class CodemanApp {
|
||||
if (groupId) toggle();
|
||||
else this._activateTabRow(current);
|
||||
break;
|
||||
case 'F2':
|
||||
if (!groupId || !this.startTabGroupRename(groupId)) return;
|
||||
break;
|
||||
case 'F10':
|
||||
case 'ContextMenu':
|
||||
case 'ContextMenu': {
|
||||
if (e.key === 'F10' && !e.shiftKey) return;
|
||||
if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
||||
current.dataset.id
|
||||
);
|
||||
const synthetic = { preventDefault() {}, stopPropagation() {}, currentTarget: current };
|
||||
if (groupId) {
|
||||
this.openTabGroupMenu(synthetic, groupId);
|
||||
} else if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(synthetic, current.dataset.id);
|
||||
} else if (current.dataset.webviewId) {
|
||||
this.showWebviewModal?.(current.dataset.webviewId);
|
||||
this.openTabWebviewMenu(synthetic, current.dataset.webviewId);
|
||||
} else return;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -6312,7 +6353,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
|
||||
@@ -6320,7 +6361,7 @@ class CodemanApp {
|
||||
// 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
|
||||
// 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() {
|
||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||
@@ -6356,6 +6397,12 @@ class CodemanApp {
|
||||
// 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 (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();
|
||||
}
|
||||
|
||||
@@ -6372,6 +6419,27 @@ class CodemanApp {
|
||||
// An overtaken response is already dropped by the coordinator; this guards a
|
||||
// reordering between the coordinator and an SSE-triggered reload.
|
||||
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;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
@@ -6384,6 +6452,11 @@ class CodemanApp {
|
||||
// rail (always so on the flat rail, which is every owner without groups).
|
||||
// Rebuild only when what the rail would draw actually changed.
|
||||
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). */
|
||||
@@ -6446,6 +6519,629 @@ class CodemanApp {
|
||||
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
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -6457,9 +7153,9 @@ class CodemanApp {
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
||||
// "move into that group", and the server would re-rank it within its old
|
||||
// group anyway. Grouped editing comes with its own drag model.
|
||||
// The grouped rail opts out too: a flat-order drag cannot express "move
|
||||
// into that group", and the server would re-rank it within its old group
|
||||
// anyway. It has its own pointer drag (_bindTabLayoutPointerDrag).
|
||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
@@ -6557,7 +7253,9 @@ class CodemanApp {
|
||||
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
||||
* session:orderChanged comes back, and this client would keep a swapped
|
||||
* 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) {
|
||||
const projection = this._projectTabGroups();
|
||||
|
||||
+228
-115
@@ -296,139 +296,251 @@ function computeTabScrollLeft(input) {
|
||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||||
}
|
||||
|
||||
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
|
||||
// Session lineage lines: geometry for the lines joining a tab to the tabs it
|
||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||
//
|
||||
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
|
||||
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||
// several children of one parent nest instead of overprinting.
|
||||
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
||||
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
||||
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
||||
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
||||
// its siblings along the shared part.
|
||||
//
|
||||
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||
// row once, and comes back up under the child. Same formula, no branch.
|
||||
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
||||
// child hanging below the strip, which in a wrapped strip crossed every lower
|
||||
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
||||
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
||||
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
||||
// the gap right under it) or along the SPINE, a channel left of every row that
|
||||
// joins the gaps of different rows. styles.css reserves that room
|
||||
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
||||
// row's gap, and the spine channel on the left of a wrapped strip).
|
||||
//
|
||||
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||
// drawn through them (the retune's own first draft had exactly that regression).
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 22;
|
||||
const LINEAGE_DIP_MAX_PX = 64;
|
||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
||||
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
||||
// taller), or siblings in one row would hang their bus at different heights.
|
||||
//
|
||||
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
||||
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
||||
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
||||
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
||||
// would point at a tab that is not there.
|
||||
const LINEAGE_CORNER_RADIUS_PX = 10;
|
||||
// Families drawn together take separate lanes: gap lines this far apart, spines
|
||||
// LINEAGE_SPINE_STEP_PX apart.
|
||||
const LINEAGE_LANE_STEP_PX = 3.5;
|
||||
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
||||
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
||||
const LINEAGE_MAX_LANES = 3;
|
||||
const LINEAGE_SPINE_INSET_PX = 6;
|
||||
const LINEAGE_SPINE_STEP_PX = 4;
|
||||
// The last row has no row below it; with no strip rect to measure, its gap is
|
||||
// taken to be this deep.
|
||||
const LINEAGE_LAST_GAP_PX = 12;
|
||||
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||
// How far the vertical rail's track sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
|
||||
// to the window frame. The rail reserves the channel itself
|
||||
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
||||
// still clamped to stay left of every endpoint.
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
|
||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||
// (session-lineage.js). Every arc leaving one tab shares its colour however many
|
||||
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
|
||||
// (session-lineage.js). Every line leaving one tab shares its colour however many
|
||||
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
|
||||
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||
// which every skin block tunes for its own background, so a lone family keeps the
|
||||
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||
// terminal text on every skin.
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
|
||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||
|
||||
function computeLineagePath(input) {
|
||||
const parent = input?.parent;
|
||||
const child = input?.child;
|
||||
if (!parent || !child) return null;
|
||||
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
||||
function lineageRect(rect) {
|
||||
if (!rect) return null;
|
||||
const left = Number(rect.left);
|
||||
const top = Number(rect.top);
|
||||
const width = Number(rect.width);
|
||||
const height = Number(rect.height);
|
||||
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
cx: left + width / 2,
|
||||
cy: top + height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
const pw = Number(parent.width) || 0;
|
||||
const ph = Number(parent.height) || 0;
|
||||
const cw = Number(child.width) || 0;
|
||||
const ch = Number(child.height) || 0;
|
||||
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
||||
/**
|
||||
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
||||
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
||||
* gap for everyone in it.
|
||||
*/
|
||||
function computeLineageRows(rects) {
|
||||
const rows = [];
|
||||
for (const raw of rects || []) {
|
||||
const r = lineageRect(raw);
|
||||
if (!r) continue;
|
||||
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
|
||||
if (row) {
|
||||
row.top = Math.min(row.top, r.top);
|
||||
row.bottom = Math.max(row.bottom, r.bottom);
|
||||
} else {
|
||||
rows.push({ top: r.top, bottom: r.bottom });
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => a.top - b.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
|
||||
* (never more than half of either segment, so short stems stay short). Repeated and
|
||||
* collinear points are dropped first, so a degenerate corner draws nothing odd.
|
||||
*/
|
||||
function lineagePolylinePath(points, radius) {
|
||||
const pts = [];
|
||||
for (const p of points) {
|
||||
const prev = pts[pts.length - 1];
|
||||
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
|
||||
const before = pts[pts.length - 2];
|
||||
if (before && prev) {
|
||||
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
|
||||
if (Math.abs(cross) < 0.01) pts.pop();
|
||||
}
|
||||
pts.push(p);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const [x0, y0] = pts[i - 1];
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[i + 1];
|
||||
const lenIn = Math.hypot(x1 - x0, y1 - y0);
|
||||
const lenOut = Math.hypot(x2 - x1, y2 - y1);
|
||||
const r = Math.min(radius, lenIn / 2, lenOut / 2);
|
||||
if (!(r > 0.5)) {
|
||||
d += ` L ${r1(x1)} ${r1(y1)}`;
|
||||
continue;
|
||||
}
|
||||
const ax = x1 - ((x1 - x0) / lenIn) * r;
|
||||
const ay = y1 - ((y1 - y0) / lenIn) * r;
|
||||
const bx = x1 + ((x2 - x1) / lenOut) * r;
|
||||
const by = y1 + ((y2 - y1) / lenOut) * r;
|
||||
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
|
||||
}
|
||||
const last = pts[pts.length - 1];
|
||||
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes from one parent tab to each of its children.
|
||||
*
|
||||
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
|
||||
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
|
||||
* derived from it); `lane`/`laneCount` separate families drawn together.
|
||||
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
|
||||
*/
|
||||
function computeLineageTree(input) {
|
||||
const parent = lineageRect(input?.parent);
|
||||
if (!parent) return null;
|
||||
const strip = lineageRect(input?.strip);
|
||||
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||||
const strip = input?.strip;
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pLeft = Number(parent.left);
|
||||
const cLeft = Number(child.left);
|
||||
const pTop = Number(parent.top);
|
||||
const cTop = Number(child.top);
|
||||
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
||||
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
||||
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
||||
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
||||
const children = [];
|
||||
for (const child of input?.children || []) {
|
||||
const rect = lineageRect(child?.rect);
|
||||
if (rect) children.push({ id: child.id, rect });
|
||||
}
|
||||
|
||||
const tol = LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
|
||||
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
|
||||
const routes = [];
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
const py = pTop + ph / 2;
|
||||
const cy = cTop + ch / 2;
|
||||
if (strip && Number(strip.height) > 0) {
|
||||
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (py < min || py > max || cy < min || cy > max) return null;
|
||||
// The rail: one track down the empty left gutter, shared by every sibling.
|
||||
if (!inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||||
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||
const trackX = Math.min(
|
||||
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
|
||||
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
|
||||
);
|
||||
for (const { id, rect } of visible) {
|
||||
const points = [
|
||||
[parent.left, parent.cy],
|
||||
[trackX, parent.cy],
|
||||
[trackX, rect.cy],
|
||||
[rect.left, rect.cy],
|
||||
];
|
||||
const d = lineagePolylinePath(points, radius);
|
||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
|
||||
}
|
||||
|
||||
const stripLeft =
|
||||
strip && Number.isFinite(Number(strip.left))
|
||||
? Number(strip.left)
|
||||
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||
const requestedTrack =
|
||||
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
|
||||
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
|
||||
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
|
||||
return { d, endX: cLeft, endY: cy, sameRow: false };
|
||||
return { routes };
|
||||
}
|
||||
|
||||
const px = pLeft + pw / 2;
|
||||
const cx = cLeft + cw / 2;
|
||||
if (strip && Number(strip.width) > 0) {
|
||||
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (px < min || px > max || cx < min || cx > max) return null;
|
||||
if (!inStripX(parent) || !inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
|
||||
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
||||
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
|
||||
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
|
||||
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
|
||||
// gap never pushes a lane into the tabs on either side of it.
|
||||
const gapUnder = (i) => {
|
||||
const row = rows[i];
|
||||
const next = rows[i + 1];
|
||||
let bottom;
|
||||
if (next) bottom = next.top;
|
||||
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
|
||||
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
|
||||
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
|
||||
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
|
||||
};
|
||||
const pRow = rowOf(parent);
|
||||
const gp = gapUnder(pRow);
|
||||
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
|
||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
|
||||
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
|
||||
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
|
||||
|
||||
for (const { id, rect } of visible) {
|
||||
const cRow = rowOf(rect);
|
||||
const gc = gapUnder(cRow);
|
||||
const points =
|
||||
cRow === pRow
|
||||
? [
|
||||
[parent.cx, parent.bottom],
|
||||
[parent.cx, gp],
|
||||
[rect.cx, gp],
|
||||
[rect.cx, rect.bottom],
|
||||
]
|
||||
: [
|
||||
[parent.cx, parent.bottom],
|
||||
[parent.cx, gp],
|
||||
[spineX, gp],
|
||||
[spineX, gc],
|
||||
[rect.cx, gc],
|
||||
[rect.cx, rect.bottom],
|
||||
];
|
||||
const d = lineagePolylinePath(points, radius);
|
||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
|
||||
}
|
||||
return { routes };
|
||||
}
|
||||
|
||||
const pBottom = pTop + ph;
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||
// sitting above further rows (see the corridor note above the constants).
|
||||
const span = Math.abs(cx - px);
|
||||
const stripBottom =
|
||||
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||
? Number(strip.top) + Number(strip.height)
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||
const dip =
|
||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||
depth * LINEAGE_SIBLING_STEP_PX;
|
||||
const yc = baseline + dip;
|
||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||
return { d, endX: cx, endY: cBottom, sameRow };
|
||||
function roundPoints(points) {
|
||||
return points.map(([x, y]) => [r1(x), r1(y)]);
|
||||
}
|
||||
|
||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||
@@ -961,12 +1073,13 @@ if (typeof window !== 'undefined') {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
window.CodemanLineage = {
|
||||
computePath: computeLineagePath,
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
computeTree: computeLineageTree,
|
||||
computeRows: computeLineageRows,
|
||||
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
|
||||
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
|
||||
MAX_LANES: LINEAGE_MAX_LANES,
|
||||
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
|
||||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||||
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
|
||||
@@ -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 actions': '会话操作',
|
||||
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': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
@@ -770,6 +788,22 @@
|
||||
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
|
||||
'Letters, numbers, hyphens, underscores only. Created in ~/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 导出',
|
||||
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
|
||||
'Runs inside an isolated container. Multiple sessions can share the same container.':
|
||||
@@ -934,6 +968,13 @@
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^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) {
|
||||
const match = source.match(pattern);
|
||||
|
||||
@@ -567,6 +567,19 @@
|
||||
<div class="file-browser-status" id="fileBrowserStatus"></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 -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -771,6 +784,13 @@
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1935,6 +1955,26 @@
|
||||
</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-head"><h4>Subagent windows</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2113,7 +2153,7 @@
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
|
||||
<span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2829,6 +2869,20 @@
|
||||
</div>
|
||||
<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-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2969,18 +3023,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>
|
||||
<h2>Create New</h2>
|
||||
</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">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
||||
<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" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</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">
|
||||
<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">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>
|
||||
@@ -3870,6 +3936,7 @@
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.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="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
/**
|
||||
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
|
||||
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
|
||||
*
|
||||
* A session that starts another session (the `codeman` agent skill spawning a worker,
|
||||
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
|
||||
* state server-side. This module turns that field into the same kind of glowing
|
||||
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
||||
* glance which tab spawned which.
|
||||
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
||||
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
||||
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
|
||||
*
|
||||
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
|
||||
* active tab spawned, and the family it belongs to as a child, get the
|
||||
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
|
||||
* it). Drawing ONLY the selected family was tried first and rejected by the owner
|
||||
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
|
||||
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
|
||||
* lineage exists (`_lineageTotalEdges`).
|
||||
*
|
||||
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||
*
|
||||
* Two constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
||||
* Constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
|
||||
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
|
||||
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
|
||||
* strip is also a scroller where both endpoints are rarely on screen at once.
|
||||
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
|
||||
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
|
||||
* family exists, never on the selection, so switching tabs never resizes the header
|
||||
* (and with it the terminal and the PTY).
|
||||
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
|
||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||
*/
|
||||
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyLineageLineSettings() {
|
||||
const prev = this._lineageLinesOn;
|
||||
const next = this._syncLineageLinesEnabled();
|
||||
if (prev !== next) this.updateConnectionLines();
|
||||
if (prev !== next) {
|
||||
// The reserved routing room follows the setting; updateTabOverflowMode()
|
||||
// re-syncs it and re-measures the wrap with the new padding.
|
||||
this.updateTabOverflowMode?.();
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Every parent → child pair worth drawing, with the child's index among its siblings
|
||||
* (that index is what nests sibling arcs instead of overprinting them).
|
||||
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
||||
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
|
||||
* channel on the left of a wrapped strip (styles.css). Called at the top of
|
||||
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
|
||||
* measured.
|
||||
*
|
||||
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
|
||||
* strip's own left-to-right order, which is what the user sees.
|
||||
* Keyed on whether any family exists at all, never on which one is selected: a
|
||||
* class that followed the selection would grow and shrink the header on every tab
|
||||
* switch, and the header's height is the terminal's height.
|
||||
*
|
||||
* Only the header strip routes through reserved gaps. The vertical rail keeps its
|
||||
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
|
||||
*/
|
||||
_syncLineageGutter() {
|
||||
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
|
||||
this._lineageTotalEdges = edges.length;
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
const want = edges.length > 0 && !this._isVerticalTabList?.();
|
||||
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
|
||||
},
|
||||
|
||||
/**
|
||||
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
|
||||
* sessions Map so sibling order follows the strip's own left-to-right order, which
|
||||
* is what the user sees.
|
||||
*/
|
||||
_collectLineageEdges() {
|
||||
const edges = [];
|
||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||
const seenPerParent = new Map();
|
||||
for (const id of order) {
|
||||
const session = this.sessions.get(id);
|
||||
const parentId = session && session.parentSessionId;
|
||||
// A parent that is gone (closed, or never came back after a restart) draws
|
||||
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||
const depth = seenPerParent.get(parentId) || 0;
|
||||
seenPerParent.set(parentId, depth + 1);
|
||||
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
||||
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
||||
}
|
||||
return edges;
|
||||
},
|
||||
|
||||
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
||||
_lineageFamilies(edges) {
|
||||
const families = new Map();
|
||||
for (const edge of edges) {
|
||||
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
|
||||
families.get(edge.parentId).push(edge);
|
||||
}
|
||||
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
||||
* Parent ids of the families the selection emphasizes: the family the selected tab
|
||||
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
|
||||
* when a web tab holds the stage, or the selected tab has no lineage at all.
|
||||
*/
|
||||
_lineageFocusParents(edges) {
|
||||
const parents = new Set();
|
||||
const focus = this.activeWebviewId ? null : this.activeSessionId;
|
||||
if (!focus) return parents;
|
||||
for (const edge of edges) {
|
||||
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
|
||||
}
|
||||
return parents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
||||
*
|
||||
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
|
||||
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
|
||||
* matter how many workers it spawns, so the strip reads as "these five came from
|
||||
* w1, those two came from w2". Keying it per child instead gave one tab's own
|
||||
* children a different colour each, which is the thing the colours exist to tell
|
||||
* apart. A child that goes on to spawn its own workers is a parent in its turn and
|
||||
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
|
||||
* gets its own colour for the lines BELOW it, so a chain changes colour at each
|
||||
* generation while each generation's fan-out stays uniform.
|
||||
*
|
||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
||||
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
|
||||
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
|
||||
* flicker). An empty string means "no override": the CSS falls back to
|
||||
* flicker). The draw pass claims a colour for EVERY family in strip order before it
|
||||
* draws anything, so neither the draw order (the selected family goes last) nor
|
||||
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
|
||||
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
||||
*/
|
||||
_lineageColorFor(parentId) {
|
||||
@@ -140,19 +202,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
_appendLineageConnectionLines(svg, rects) {
|
||||
this._lineageEdgeCount = 0;
|
||||
if (!svg || !this._lineageLinesEnabled()) return;
|
||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
||||
// sidebar, so every arc would draw a giant loop to the foot of the list.
|
||||
// Parent/child adjacency reads fine in a vertical list without arcs; a
|
||||
// sideways lineage shape is a follow-up with its own visual tuning, not a
|
||||
// by-product of a layout port.
|
||||
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
||||
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
||||
// channel; parent/child adjacency reads fine there without lines.
|
||||
if (this.isSessionSidebarActive?.()) return;
|
||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
||||
if (!compute) return;
|
||||
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
||||
if (!computeTree) return;
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
this._lineageTotalEdges = edges.length;
|
||||
if (edges.length === 0) return;
|
||||
// Claim colours in strip order for every family before drawing (_lineageColorFor).
|
||||
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
||||
const families = this._lineageFamilies(edges);
|
||||
const focusParents = this._lineageFocusParents(edges);
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -162,9 +225,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
// to anchor to, so its end of the line moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
||||
// its parent would be a line from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
@@ -173,71 +236,125 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||
if (rects.has(endpoint.key)) continue;
|
||||
const measure = (endpoint) => {
|
||||
if (!rects.has(endpoint.key)) {
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
}
|
||||
return rects.get(endpoint.key);
|
||||
};
|
||||
const resolvedFamilies = [];
|
||||
for (const family of families) {
|
||||
const parentEndpoint = resolveEndpoint(family.parentId);
|
||||
const parentRect = measure(parentEndpoint);
|
||||
if (!parentRect) continue;
|
||||
const children = [];
|
||||
for (const edge of family.edges) {
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (childEndpoint.key === parentEndpoint.key) continue;
|
||||
const rect = measure(childEndpoint);
|
||||
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
|
||||
}
|
||||
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
|
||||
}
|
||||
if (resolvedFamilies.length === 0) return;
|
||||
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
|
||||
// gap under its tallest tab), web tabs included. The rail needs none.
|
||||
const tabRects = [];
|
||||
if (orientation === 'horizontal') {
|
||||
for (const tab of strip.querySelectorAll('.session-tab')) {
|
||||
const id = tab.getAttribute('data-id');
|
||||
const key = id ? 'tab:' + id : null;
|
||||
if (key && rects.has(key)) tabRects.push(rects.get(key));
|
||||
else {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (key) rects.set(key, rect);
|
||||
tabRects.push(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
||||
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
||||
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
||||
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
||||
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
||||
const drawOrder = resolvedFamilies
|
||||
.map((resolved, index) => ({
|
||||
...resolved,
|
||||
lane: index % laneCount,
|
||||
focus: focusParents.has(resolved.family.parentId),
|
||||
}))
|
||||
.sort((a, b) => a.focus - b.focus);
|
||||
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
|
||||
const geom = computeTree({
|
||||
parent: parentRect,
|
||||
child: childRect,
|
||||
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
||||
strip: stripRect,
|
||||
depth: edge.depth,
|
||||
tabs: tabRects,
|
||||
orientation,
|
||||
lane,
|
||||
laneCount,
|
||||
});
|
||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', geom.d);
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
const color = this._lineageColorFor(edge.parentId);
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||
line.setAttribute('data-parent-tab', edge.parentId);
|
||||
line.setAttribute('data-child-tab', edge.childId);
|
||||
svg.appendChild(line);
|
||||
|
||||
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
||||
// markers need a <defs> block and fight the dash pattern.
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(geom.endX));
|
||||
dot.setAttribute('cy', String(geom.endY));
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
if (!geom || geom.routes.length === 0) continue;
|
||||
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
||||
const color = this._lineageColorFor(family.parentId);
|
||||
// One group per family: it carries the translucency, so the stretches its
|
||||
// routes share (the trunk) do not stack into a brighter line than the branches,
|
||||
// and the emphasis for the selected tab's families (styles.css).
|
||||
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
||||
group.setAttribute('data-parent-tab', family.parentId);
|
||||
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
||||
// shared trunk and only the working child's own branch shows its dashes.
|
||||
const routes = geom.routes
|
||||
.map((route) => ({ route, child: byId.get(route.id) }))
|
||||
.filter((r) => r.child)
|
||||
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
||||
for (const { route, child } of routes) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', route.d);
|
||||
// `status` is the CHILD's, which is the interesting end: a working child's
|
||||
// route is dashed (and marches, motion permitting).
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
line.setAttribute(
|
||||
'class',
|
||||
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
||||
);
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
||||
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
||||
line.setAttribute('data-parent-tab', family.parentId);
|
||||
line.setAttribute('data-child-tab', child.edge.childId);
|
||||
group.appendChild(line);
|
||||
}
|
||||
// Direction marker at each CHILD end, after every path so no stroke covers it.
|
||||
for (const { route, child } of routes) {
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(route.endX));
|
||||
dot.setAttribute('cy', String(route.endY));
|
||||
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
||||
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
||||
dot.setAttribute('r', '2.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', child.edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
group.appendChild(dot);
|
||||
}
|
||||
svg.appendChild(group);
|
||||
this._lineageEdgeCount += routes.length;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
||||
* scroll moves both endpoints without firing any render, so the arcs would slide off
|
||||
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
* scroll moves both endpoints without firing any render, so the lines would slide
|
||||
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
*
|
||||
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||
*/
|
||||
@@ -248,11 +365,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._lineageScrollHandler = () => {
|
||||
// Sidebar layout and the vertical rail scroll the SAME element
|
||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||
// would never redraw them).
|
||||
// rects too (the sidebar skips lineage entirely, and the rail can show
|
||||
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
||||
// never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
|
||||
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
|
||||
// and close icons by transitioning their padding (styles.css), so it keeps
|
||||
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
|
||||
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
|
||||
// which left a family's trunk hanging under the tab's OLD position. Redraw once
|
||||
// a size transition inside the strip ends (coalesced, like every other redraw).
|
||||
this._lineageTransitionHandler = (event) => {
|
||||
const prop = event.propertyName || '';
|
||||
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
|
||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('transitionend', this._lineageTransitionHandler);
|
||||
},
|
||||
});
|
||||
|
||||
+178
-24
@@ -1874,10 +1874,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get case path first
|
||||
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 (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2084,10 +2088,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
// Get the case path
|
||||
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 (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2589,6 +2597,62 @@ Object.assign(CodemanApp.prototype, {
|
||||
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() {
|
||||
if (!this.editingSessionId) return;
|
||||
// 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');
|
||||
|
||||
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 originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
|
||||
const restoreOriginalChildren = () => {
|
||||
@@ -2898,13 +2966,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.value = parsed ? parsed.suffix : (shownName || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
||||
// should give the whole line to the input.
|
||||
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
|
||||
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;`;
|
||||
// should give the whole line to the input. The header editor may shrink to
|
||||
// nothing, while a rail or sidebar row always keeps room to type.
|
||||
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);
|
||||
input.focus();
|
||||
@@ -2954,22 +3026,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const suffix = input.value.trim();
|
||||
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;
|
||||
|
||||
// Skip the API call if the session vanished between focus and blur.
|
||||
const stillExists = this.sessions.has(sessionId);
|
||||
if (stillExists && fullName !== session.name) {
|
||||
const confirmed = await this._putSessionName(sessionId, fullName);
|
||||
// Skip the API call if the session vanished between focus and blur. The
|
||||
// queue applies the confirmed name to this.sessions before the re-render
|
||||
// below repaints from it (see _applyLocalSessionName()).
|
||||
if (this.sessions.has(sessionId)) {
|
||||
const result = await this._queueInlineSessionName(sessionId, fullName);
|
||||
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
|
||||
if (confirmed === null) {
|
||||
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);
|
||||
}
|
||||
// The queue reports a failure itself; the editor only puts its label back.
|
||||
if (result.status === 'failed') restoreOriginalChildren();
|
||||
}
|
||||
// Re-render tabs to restore full tab structure
|
||||
completeCurrentRename();
|
||||
@@ -3098,6 +3168,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
showCreateCaseModal() {
|
||||
document.getElementById('newCaseName').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('linkCasePath').value = '';
|
||||
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() {
|
||||
const name = document.getElementById('newCaseName').value.trim();
|
||||
const description = document.getElementById('newCaseDescription').value.trim();
|
||||
@@ -3282,10 +3427,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// One-click "Run in Docker": create the case folder AND a container, then start
|
||||
// a session inside it. Optional expandable settings override the defaults.
|
||||
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 payload = inDocker
|
||||
? { name, description, ...this._collectDockerQuickSettings() }
|
||||
: { name, description };
|
||||
: customFolder
|
||||
? { name, description, path: this._newCaseTargetPath() }
|
||||
: { name, description };
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
@@ -3307,7 +3459,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Start a session INSIDE the container (routes through quick-start).
|
||||
await this.runClaude();
|
||||
} 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 {
|
||||
this.showToast(data.error || 'Failed to create case', 'error');
|
||||
|
||||
@@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
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('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? 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)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// 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' : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
async mcpSync(apply) {
|
||||
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) {
|
||||
const el = this.$('updateResult');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
@@ -2422,6 +2500,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -2674,6 +2754,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||
showGitStatus: _sgs,
|
||||
gitStatusTree: _gst,
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
@@ -3613,6 +3696,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -3961,7 +4048,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
@@ -4373,4 +4460,5 @@ document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
window.app?._applyDoctorAdminGate?.();
|
||||
});
|
||||
|
||||
+604
-64
@@ -596,6 +596,22 @@ body {
|
||||
max-height: var(--tab-strip-max-height, 40vh);
|
||||
}
|
||||
|
||||
/* Lineage routing room (`_syncLineageGutter()` in session-lineage.js, geometry in
|
||||
computeLineageTree, constants.js). Present only while some tab has spawned
|
||||
another, and never toggled by the selection, so switching tabs cannot resize
|
||||
the header (and with it the terminal). The bottom padding is the last row's
|
||||
gap; a wrapped strip also widens the row gap the routes run in, and opens the
|
||||
spine channel on the left that joins one row's gap to another's. */
|
||||
.session-tabs.lineage-tree {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.session-tabs.lineage-tree.tabs-two-rows,
|
||||
.session-tabs.lineage-tree.tabs-auto-wrap {
|
||||
row-gap: 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.session-tabs::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 0;
|
||||
@@ -736,6 +752,86 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v
|
||||
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 {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1601,9 +1697,10 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
|
||||
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
|
||||
the endpoint from the element's own computed value: a subagent line rests at
|
||||
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
|
||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
||||
wrong opacity and snaps it when the class comes off. */
|
||||
0.9, a lineage line at 1 (its `.lineage-family` group carries the
|
||||
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
|
||||
above still does - lands every lineage line on the wrong opacity and snaps it
|
||||
when the class comes off. */
|
||||
html[data-line-anim="blur"] .connection-line.line-enter {
|
||||
animation-name: line-enter-blur;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
@@ -1768,22 +1865,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
-webkit-line-clamp: unset;
|
||||
line-clamp: unset;
|
||||
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(
|
||||
html[data-tab-orientation='vertical'] .tab-rail,
|
||||
html[data-session-list='sidebar'] .session-sidebar
|
||||
) .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(
|
||||
html[data-tab-orientation='vertical'] .tab-rail,
|
||||
html[data-session-list='sidebar'] .session-sidebar
|
||||
) .tab-name-renaming .tab-rename-input {
|
||||
flex: 1 1 0;
|
||||
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 */
|
||||
@@ -10556,112 +10682,121 @@ kbd {
|
||||
}
|
||||
|
||||
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
|
||||
Deliberately quieter and thinner than the subagent lines above so the two
|
||||
layers read as different things in the same SVG.
|
||||
One quiet orthogonal tree per spawning tab, every family always drawn and
|
||||
routed through the gaps between tab rows (computeLineageTree in constants.js).
|
||||
The SELECTED tab's families (what it spawned, and the family it was spawned
|
||||
into) are emphasized by `.lineage-family--focus`: thicker, full opacity, a
|
||||
larger end dot, drawn last (owner call 2026-10-07: see every connection always,
|
||||
thicker when selected). The routing room itself is reserved on the strip by
|
||||
`.session-tabs.lineage-tree` near the top of this file.
|
||||
|
||||
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
|
||||
corners and a 1px dark outline in place of the old double glow. The old lines
|
||||
were glowing dashed beziers that hung into the terminal, and with ten children
|
||||
across a wrapped strip they read as a tangle. The lines no longer cross text,
|
||||
so they no longer need a glow to survive it. Dashes now MEAN something: only a
|
||||
working child's route is dashed.
|
||||
|
||||
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||
skin block already defines and tunes for its own background; a lone arc therefore
|
||||
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||
surprising place.
|
||||
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
|
||||
first-seen order). The first spawning tab gets no override and falls through to
|
||||
--session-blue, which EVERY skin block already defines and tunes for its own
|
||||
background. Do not add a per-skin `.lineage-line` override inside the
|
||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to
|
||||
(0,2,1) and would outrank this one from a surprising place.
|
||||
|
||||
Opacity lives on the family GROUP (`.lineage-family`), not on the paths: a
|
||||
parent's routes overlap exactly along the trunk, and translucent paths would
|
||||
stack there into a brighter line than the branches. */
|
||||
.lineage-family {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.lineage-family--focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||
own dim foreground and lost contrast the moment it crossed text. Hue therefore no
|
||||
longer separates this layer from the subagent lines, so the separation rests
|
||||
entirely on SHAPE (this one hangs under the strip and never reaches a window),
|
||||
weight and dash: keep those differences intact. Per skin the two are not even the
|
||||
same blue, since --session-blue is tuned per palette while the subagent rule
|
||||
hardcodes #3b82f6. */
|
||||
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
|
||||
with a single 5px glow, which reads on a design mock and disappears on a real
|
||||
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
|
||||
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
|
||||
separate, and the second, wider glow is what buys the contrast instead: it lifts
|
||||
the line off the terminal without thickening it. Dashes scale with the stroke
|
||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||
exactly two dash cycles, so it has to move with them. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: none;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
stroke-linejoin: round;
|
||||
opacity: 1;
|
||||
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
filter: var(--line-glow);
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
the line exists to signal, and pairing the brightness with the marching dashes
|
||||
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
||||
/* The selected tab's families: thicker (the group above makes them opaque). */
|
||||
.lineage-family--focus .connection-line.lineage-line {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* A working child's route is dashed even with motion turned off; the march
|
||||
below is the extra. Routes start at the PARENT, so dashes on the shared trunk
|
||||
stay in phase between working siblings. */
|
||||
.connection-line.lineage-line--working {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line:hover {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
/* The radius rides a variable so the pulse below can breathe from whichever size
|
||||
the dot rests at; the `r` attribute session-lineage.js sets is only a fallback. */
|
||||
.lineage-line-dot {
|
||||
--lineage-dot-r: 2.5px;
|
||||
r: var(--lineage-dot-r);
|
||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
.lineage-family--focus .lineage-line-dot {
|
||||
--lineage-dot-r: 3.5px;
|
||||
}
|
||||
|
||||
/* A line re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
stroke-dasharray: 1 5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.62;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.lineage-line-dot.lineage-line-dot--proxied {
|
||||
opacity: 0.46;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
/* The working route marches from parent to child, so the line itself carries
|
||||
the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.connection-line.lineage-line--working {
|
||||
animation: lineage-flow 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.lineage-line-dot--working {
|
||||
opacity: 1;
|
||||
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
||||
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
||||
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
||||
iteration. */
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
|
||||
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
|
||||
them out of step makes the dashes jump once per iteration. */
|
||||
@keyframes lineage-flow {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
stroke-dashoffset: -14;
|
||||
}
|
||||
}
|
||||
|
||||
/* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
|
||||
@keyframes lineage-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.75;
|
||||
r: 3.5;
|
||||
r: var(--lineage-dot-r);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 4.5;
|
||||
r: calc(var(--lineage-dot-r) + 0.8px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18815,6 +18950,13 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
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 {
|
||||
font-size: 0.66rem;
|
||||
margin-top: 0.1rem;
|
||||
@@ -19274,3 +19416,401 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
font-size: 13px;
|
||||
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`
|
||||
* 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,
|
||||
* and which rows a collapsed group hides.
|
||||
@@ -12,6 +12,10 @@
|
||||
* byte-identical to the flat rail's row.
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* 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
|
||||
* 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;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
const copyRefs = (value) =>
|
||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
||||
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||
// 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
|
||||
@@ -46,6 +58,7 @@
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return {
|
||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
|
||||
groups: groups
|
||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||
.map((group) => ({
|
||||
@@ -276,10 +289,17 @@
|
||||
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
||||
return (
|
||||
`<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-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>`
|
||||
);
|
||||
})
|
||||
@@ -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 = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
@@ -347,8 +736,15 @@
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
MAX_GROUPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
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;
|
||||
menu?.remove();
|
||||
if (this._tabRailActionMenuOutside) {
|
||||
@@ -325,6 +327,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ 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)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
|
||||
+148
-18
@@ -50,6 +50,8 @@ import {
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.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 { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||
import {
|
||||
@@ -163,6 +165,9 @@ function gitDiagnosticLine(stderr: string): string {
|
||||
* the clone response says so out loud instead of silently merging into them.
|
||||
*/
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -266,7 +271,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: casePath,
|
||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
|
||||
});
|
||||
@@ -281,15 +286,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
if (admin) {
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
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({
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
...(state === 'unknown' ? { unreachable: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +342,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const dockerCaseInfo: CaseInfo = {
|
||||
name: dockerCase.name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
@@ -424,8 +433,109 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
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)));
|
||||
if (!casePath) {
|
||||
@@ -1618,7 +1728,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return {
|
||||
name,
|
||||
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
|
||||
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: host.id,
|
||||
@@ -1633,16 +1743,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
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 {
|
||||
name,
|
||||
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 }),
|
||||
};
|
||||
});
|
||||
@@ -1660,7 +1786,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
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: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-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 { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
|
||||
@@ -174,6 +174,7 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const stat = statSync(workingDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||||
}
|
||||
} catch {
|
||||
const kind = await probePathKind(workingDir, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
describeUnknownPath('workingDir', workingDir, { pastCap: true })
|
||||
);
|
||||
}
|
||||
if (kind === 'absent') {
|
||||
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).
|
||||
@@ -3694,9 +3702,21 @@ export function registerSessionRoutes(
|
||||
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,
|
||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
||||
if (localCaseState === 'absent') {
|
||||
try {
|
||||
mkdirSync(resolvedCasePath, { recursive: true });
|
||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||
|
||||
@@ -668,6 +668,13 @@ export const CreateCaseSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||
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,
|
||||
registerRebootRestoreRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerGitStatusRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -202,6 +203,7 @@ import {
|
||||
registerTabLayoutRoutes,
|
||||
registerMcpSyncRoutes,
|
||||
registerWebhookRoutes,
|
||||
registerDoctorRoutes,
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
readCustomModelEndpointsEnabled,
|
||||
@@ -1121,6 +1123,7 @@ export class WebServer extends EventEmitter {
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerRebootRestoreRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerGitStatusRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
@@ -1143,6 +1146,7 @@ export class WebServer extends EventEmitter {
|
||||
configDir: getDataDir(),
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
registerDoctorRoutes(this.app);
|
||||
registerCustomModelRoutes(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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
detectEnvironment,
|
||||
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
|
||||
environment: env,
|
||||
which: () => null,
|
||||
fileExists: () => false,
|
||||
isExecutableFile: () => false,
|
||||
runVersion: () => null,
|
||||
windowsProgramRoots: () => [],
|
||||
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', () => {
|
||||
it('maps every tool to a result', () => {
|
||||
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
|
||||
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
|
||||
expect(typeof host.which).toBe('function');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -166,8 +166,9 @@ describe('entrance animation styles', () => {
|
||||
expect(blur).not.toBeNull();
|
||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
||||
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
|
||||
// number here snaps them.
|
||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => {
|
||||
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', () => {
|
||||
const dom = makeDom('<span class="logo">Codeman</span>');
|
||||
const api = dom.window.CodemanI18n;
|
||||
|
||||
+504
-40
@@ -13,7 +13,8 @@
|
||||
* 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.
|
||||
*
|
||||
* 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';
|
||||
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3164;
|
||||
const ORDERING_PORT = 3165;
|
||||
const LONG_PREFIX_PORT = 3192;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('Inline rename input', () => {
|
||||
@@ -581,54 +584,515 @@ describe('Inline rename input', () => {
|
||||
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 () => {
|
||||
await resetState();
|
||||
const id = 'vertical-live-input';
|
||||
// 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();
|
||||
const id = `vertical-live-input-${detail}`;
|
||||
|
||||
await page.evaluate((sessionId) => {
|
||||
await page.evaluate(
|
||||
({ sessionId, detail }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||
document.documentElement.dataset.tabRailDetail = detail;
|
||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML =
|
||||
`<span class="tab-name" data-session-id="${sessionId}">` +
|
||||
'<span class="tab-name-prefix">w9-case: </span>old</span>';
|
||||
rail.appendChild(tab);
|
||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||
app.startInlineRename(sessionId);
|
||||
},
|
||||
{ sessionId: id, detail }
|
||||
);
|
||||
|
||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||
const input = label.locator('input.tab-rename-input');
|
||||
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await page.keyboard.type('edited title');
|
||||
|
||||
expect(await input.inputValue()).toBe('edited title');
|
||||
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
|
||||
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
|
||||
const settled = await page.evaluate((sessionId) => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
app._activeRename?.cancel();
|
||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
const result = {
|
||||
classActive: label.classList.contains('tab-name-renaming'),
|
||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||
};
|
||||
document.documentElement.dataset.tabOrientation = 'horizontal';
|
||||
return result;
|
||||
}, id);
|
||||
|
||||
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: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
tabLayout: unknown;
|
||||
startTabGroupRename: (groupId: string) => boolean;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML =
|
||||
`<span class="tab-name" data-session-id="${sessionId}">` +
|
||||
'<span class="tab-name-prefix">w9-case: </span>old</span>';
|
||||
rail.appendChild(tab);
|
||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||
app.startInlineRename(sessionId);
|
||||
}, id);
|
||||
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);
|
||||
|
||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||
const input = label.locator('input.tab-rename-input');
|
||||
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await page.keyboard.type('edited title');
|
||||
|
||||
expect(await input.inputValue()).toBe('edited title');
|
||||
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
|
||||
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
|
||||
const settled = await page.evaluate((sessionId) => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
app._activeRename?.cancel();
|
||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
return {
|
||||
classActive: label.classList.contains('tab-name-renaming'),
|
||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||
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;
|
||||
};
|
||||
}, id);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,28 @@
|
||||
* 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 fastifyCookie from '@fastify/cookie';
|
||||
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 { registerCaseRoutes } from '../../src/web/routes/case-routes.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 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/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
created.push('hooked');
|
||||
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).
|
||||
*/
|
||||
|
||||
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 fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.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
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -35,6 +46,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
@@ -74,6 +86,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
@@ -127,6 +140,12 @@ describe('case-routes', () => {
|
||||
// Default: existsSync returns false, readFile throws ENOENT
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
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 () => {
|
||||
@@ -210,6 +229,53 @@ describe('case-routes', () => {
|
||||
// Should have both regular and linked cases
|
||||
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', () => {
|
||||
@@ -670,6 +736,66 @@ describe('case-routes', () => {
|
||||
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 () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
@@ -704,6 +830,16 @@ describe('case-routes', () => {
|
||||
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 () => {
|
||||
const fixPlanContent = [
|
||||
'# 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']);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+605
-325
File diff suppressed because it is too large
Load Diff
@@ -451,8 +451,16 @@ describe('browser wiring', () => {
|
||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||
});
|
||||
|
||||
it('never writes the layout from the browser in this slice', () => {
|
||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
||||
it('writes the layout only through the edit coordinator (and its keepalive twin)', () => {
|
||||
// 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(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
press('F10', { shiftKey: true });
|
||||
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();
|
||||
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');
|
||||
row('w1').focus();
|
||||
press('F10');
|
||||
expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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