mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a93c871f | ||
|
|
46b85fa6d0 | ||
|
|
aa463d9d28 | ||
|
|
9eac5ecf01 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Files panel: when a session's agent moves into a git worktree part way through (Claude Code's EnterWorktree), the panel now lists that worktree instead of the main checkout, follows the agent back out, and shows a strip that names the worktree with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder as before.
|
||||
@@ -183,7 +183,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`), and a check that must still hold when the entry is written (the name is free) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`.
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
@@ -238,6 +238,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ Only a folder strictly below `workingDir` is ever an answer, which is the whole security argument: the walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
|
||||
|
||||
**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 on its own (it never fetches; fetching is the separate path below). ⚠️ 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).
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
|
||||
@@ -474,7 +474,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
|
||||
|
||||
### Clone a repository as a case
|
||||
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
|
||||
|
||||
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
|
||||
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
|
||||
|
||||
@@ -39,6 +39,19 @@ name.
|
||||
|
||||
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
|
||||
|
||||
## When an agent moves into a worktree
|
||||
|
||||
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
|
||||
and the session's tab stays on the case. While the session's pane is in a worktree inside the
|
||||
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
|
||||
editing. A strip under the panel's title names the worktree, and its **Session folder** button
|
||||
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
|
||||
in and back out on its own while it is open.
|
||||
|
||||
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
|
||||
only serves files inside the session's folder. Open such a worktree as a case of its own with
|
||||
Add Case > Link Existing.
|
||||
|
||||
## Removing one
|
||||
|
||||
Two ways, and both use `git worktree remove` **without** `--force`:
|
||||
|
||||
@@ -6,8 +6,9 @@ until you get home.
|
||||
|
||||
## The File Viewer
|
||||
|
||||
A panel that browses the active session's working directory. Its header button is on by
|
||||
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
A panel that browses the active session's working directory, or the git worktree its agent
|
||||
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
|
||||
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
|
||||
It renders what it can:
|
||||
|
||||
|
||||
Generated
+10
-28
@@ -63,7 +63,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
@@ -9736,50 +9736,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Which linked git worktree a session's pane is in.
|
||||
*
|
||||
* Claude Code's worktree feature moves the running `claude` process into
|
||||
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
|
||||
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
|
||||
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
|
||||
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
|
||||
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
|
||||
* answers the narrower question: is the pane inside a linked worktree, and which one.
|
||||
*
|
||||
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
|
||||
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
|
||||
* changes, and a file read starts no process, no repository config and no filter.
|
||||
*
|
||||
* Only a folder at or below `workingDir` is ever an answer, and that is the whole security
|
||||
* argument: the file routes already serve everything under `workingDir`, so following a pane
|
||||
* there widens nothing, and a planted `.git` file can only move where the listing STARTS inside
|
||||
* that same folder. A worktree outside it (`git worktree add ../feature`) stays unfollowed, since
|
||||
* listing it would need a second confinement root. Nothing here is a confinement decision: the
|
||||
* routes keep judging every path against `workingDir`.
|
||||
*
|
||||
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
|
||||
* `unknown` answer stops the walk rather than being read as "no `.git` here".
|
||||
*
|
||||
* @module live-worktree
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** A linked worktree a session's pane is in. */
|
||||
export interface LiveWorktree {
|
||||
/** The worktree folder's name, for display. */
|
||||
name: string;
|
||||
/** Absolute path, spelled like the session's `workingDir`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** More than any real `.git` file. It is read only to recognise the first line. */
|
||||
const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Whether the text of a `.git` file names a linked worktree's git dir
|
||||
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
|
||||
* The path may be relative: `git worktree add --relative-paths` writes it that way.
|
||||
*/
|
||||
export function isLinkedWorktreeGitFile(text: string): boolean {
|
||||
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
if (!gitDir) return false;
|
||||
const segments = gitDir.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
|
||||
}
|
||||
|
||||
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
|
||||
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
|
||||
const dotGit = join(dir, '.git');
|
||||
const kind = await probePathKind(dotGit);
|
||||
if (kind === 'absent') return 'none';
|
||||
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
|
||||
if (kind !== 'file') return 'stop';
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// lstat, so a `.git` that is a symlink or a FIFO is refused before anything opens it:
|
||||
// opening a FIFO waits for a writer and would hold a threadpool worker for good.
|
||||
if (!(await fs.lstat(dotGit)).isFile()) return 'stop';
|
||||
handle = await fs.open(dotGit, 'r');
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
|
||||
} catch {
|
||||
return 'stop';
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
|
||||
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
|
||||
* read. `workingDir` itself is never an answer: a session started in a worktree is already
|
||||
* listing it. Never throws.
|
||||
*/
|
||||
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
|
||||
if (!cwd?.startsWith('/')) return undefined;
|
||||
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
|
||||
const under = root === '/' ? '/' : `${root}/`;
|
||||
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
|
||||
const found = await classifyDotGit(dir);
|
||||
if (found === 'worktree') return { name: basename(dir), path: dir };
|
||||
if (found === 'stop') return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import type { LiveWorktree } from './live-worktree.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -766,6 +767,8 @@ export class Session extends EventEmitter {
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/** See {@link liveWorktree}. */
|
||||
private _liveWorktree: LiveWorktree | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
@@ -1576,6 +1579,22 @@ export class Session extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
|
||||
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
|
||||
* never a confinement root, like `paneCwd`: it is always a folder the file routes already serve.
|
||||
*/
|
||||
get liveWorktree(): LiveWorktree | undefined {
|
||||
return this._liveWorktree;
|
||||
}
|
||||
|
||||
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
|
||||
setLiveWorktree(next: LiveWorktree | undefined): boolean {
|
||||
if (next?.path === this._liveWorktree?.path) return false;
|
||||
this._liveWorktree = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
@@ -2300,6 +2319,7 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
liveWorktree: this._liveWorktree,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
import type { GitFetchStatus } from '../git-fetch.js';
|
||||
import type { LiveWorktree } from '../live-worktree.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -847,6 +848,15 @@ export interface SessionState {
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
|
||||
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
|
||||
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
|
||||
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
|
||||
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
|
||||
* only starts its listing here.
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
|
||||
* name to the absolute folder it stands for.
|
||||
*
|
||||
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
|
||||
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
|
||||
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
|
||||
* file directly; a write is atomic, so they never see half of one.
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
|
||||
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
|
||||
* into one each used to read the file, check it and write it back on their own. Two clones that
|
||||
* claimed one name and finished together both passed the check, both answered success, and the
|
||||
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
|
||||
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
|
||||
* caller only, and nothing is written.
|
||||
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a
|
||||
* reader never sees a half-written file (quick-start would read one as an empty registry and resolve
|
||||
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
|
||||
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
|
||||
* cannot be read or is not a JSON object is left alone and the write throws
|
||||
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
|
||||
* and unlink every other case.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getErrorMessage } from '../types.js';
|
||||
|
||||
export type LinkedCases = Record<string, string>;
|
||||
|
||||
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
|
||||
export function linkedCasesPath(): string {
|
||||
return dataPath('linked-cases.json');
|
||||
}
|
||||
|
||||
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
|
||||
export class LinkedCasesWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LinkedCasesWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
|
||||
);
|
||||
}
|
||||
if (raw.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as LinkedCases;
|
||||
}
|
||||
|
||||
async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise<void> {
|
||||
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
|
||||
const target = await fs.realpath(path).catch(() => path);
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
|
||||
const mode = await fs.stat(target).then(
|
||||
(s) => s.mode & 0o777,
|
||||
() => undefined
|
||||
);
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
|
||||
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
|
||||
if (mode !== undefined) await fs.chmod(tmp, mode);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Run one change to the linked-cases registry. `fn` receives a copy of the current registry and
|
||||
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
|
||||
* (its error rejects this call as thrown). Resolves with the registry before and after the change
|
||||
* (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own
|
||||
* caller only; the chain keeps going.
|
||||
*/
|
||||
export function mutateLinkedCases(
|
||||
fn: (current: LinkedCases) => LinkedCases | undefined
|
||||
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const path = linkedCasesPath();
|
||||
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
|
||||
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const after = fn({ ...before });
|
||||
if (after) await writeLinkedCasesFile(path, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -2458,6 +2458,8 @@ class CodemanApp {
|
||||
}
|
||||
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
|
||||
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
|
||||
// The agent moved into or out of a git worktree while the Files panel is open (#765).
|
||||
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
|
||||
@@ -1133,6 +1133,9 @@
|
||||
'Filter by date range': '按日期范围筛选',
|
||||
'Filter by session status': '按会话状态筛选',
|
||||
'Filter files...': '筛选文件…',
|
||||
Worktree: '工作树',
|
||||
'Session folder': '会话文件夹',
|
||||
'Back to worktree': '返回工作树',
|
||||
'Any status': '任意状态',
|
||||
'Any time': '任意时间',
|
||||
'Last hour': '最近一小时',
|
||||
|
||||
@@ -617,6 +617,11 @@
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-browser-root" id="fileBrowserRoot" hidden>
|
||||
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
|
||||
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
|
||||
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()"></button>
|
||||
</div>
|
||||
<div class="file-browser-search">
|
||||
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
|
||||
</div>
|
||||
|
||||
@@ -3282,6 +3282,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
matches: [],
|
||||
deferredDirectoryTarget: null,
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
// The person asked for the session folder while the agent is in a worktree (#765).
|
||||
sessionRootPinned: false,
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
@@ -3304,6 +3306,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3314,6 +3317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
|
||||
const panel = this.$?.('fileBrowserPanel');
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
@@ -3346,6 +3350,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3356,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
if (treeEl) treeEl.innerHTML = '';
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
@@ -3448,6 +3454,60 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
this.renderFileBrowserTree(normalState.sessionId);
|
||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||
this._renderFileBrowserRoot(normalState.data);
|
||||
},
|
||||
|
||||
// The strip under the panel header, present only while the session's pane is in a linked
|
||||
// worktree (#765): it says which of the two folders is listed and switches between them.
|
||||
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
|
||||
// than the session's state a moment later.
|
||||
_renderFileBrowserRoot(data) {
|
||||
const strip = this.$?.('fileBrowserRoot');
|
||||
if (!strip) return;
|
||||
const worktree = data?.worktree;
|
||||
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
|
||||
strip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const inWorktree = data.root === worktree.path;
|
||||
const label = this.$?.('fileBrowserRootLabel');
|
||||
const name = this.$?.('fileBrowserRootName');
|
||||
const button = this.$?.('fileBrowserRootSwitch');
|
||||
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
|
||||
if (name) name.textContent = inWorktree ? worktree.name : '';
|
||||
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
|
||||
strip.title = typeof data.root === 'string' ? data.root : '';
|
||||
strip.hidden = false;
|
||||
},
|
||||
|
||||
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
|
||||
// a worktree, so the panel always asks unless the person chose the session folder.
|
||||
_fileBrowserRootParam() {
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
|
||||
},
|
||||
|
||||
toggleFileBrowserRoot() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
state.sessionRootPinned = !state.sessionRootPinned;
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Called on every session update of the active session. Compares with what the listing on
|
||||
// screen reported rather than with the previous update, so a load already in flight when the
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
|
||||
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
|
||||
_followFileBrowserWorktree(session) {
|
||||
const state = this._fileBrowserState;
|
||||
if (!state || state.ownerSessionId !== session.id) return;
|
||||
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
|
||||
const normal = state.normalState;
|
||||
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
|
||||
const live = session.liveWorktree?.path ?? null;
|
||||
const listed = normal.data?.worktree?.path ?? null;
|
||||
if (live === listed) return;
|
||||
// The pin was about the worktree the agent has just left.
|
||||
state.sessionRootPinned = false;
|
||||
this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
_validateFileBrowserTreeEnvelope(result) {
|
||||
@@ -3552,14 +3612,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
|
||||
// root), then re-run the search in the box against the new tree.
|
||||
async _reloadFileBrowserKeepingQuery() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
@@ -3633,7 +3699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
const result = await res.json();
|
||||
@@ -3820,6 +3886,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
rawInput,
|
||||
query,
|
||||
rootParam: this._fileBrowserRootParam(),
|
||||
timer: null,
|
||||
};
|
||||
state.view = 'search-pending';
|
||||
@@ -3834,7 +3901,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
request.timer = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const result = await res.json();
|
||||
|
||||
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-browser-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-root-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-browser-root-switch {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-browser-root-switch:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-search {
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -86,7 +86,6 @@ import {
|
||||
SettingsWriteRefusedError,
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -127,6 +126,7 @@ import {
|
||||
writeRemoteHosts,
|
||||
} from '../../remote-hosts.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
@@ -212,21 +212,11 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
|
||||
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
|
||||
* section and refuses one that does not parse.
|
||||
*/
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
}
|
||||
|
||||
/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */
|
||||
function linkedConflict(message: string): Error {
|
||||
return Object.assign(new Error(message), { conflict: true });
|
||||
}
|
||||
const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true;
|
||||
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
@@ -563,11 +553,15 @@ export function registerCaseRoutes(
|
||||
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
// The check above ran before the scaffold; a case linked since then wins and this call is undone.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was just linked`);
|
||||
return { ...current, [name]: 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 } } };
|
||||
@@ -575,14 +569,10 @@ export function registerCaseRoutes(
|
||||
// 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 (isLinkedConflict(err)) {
|
||||
if ((err as { conflict?: boolean }).conflict) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -900,20 +890,20 @@ export function registerCaseRoutes(
|
||||
}
|
||||
|
||||
if (customPath !== undefined) {
|
||||
// The name was checked before git ran, which can take minutes. Checked again where the entry is
|
||||
// written, so of two clones that claimed one name only the first to finish links it.
|
||||
// Re-read right before writing so a case linked while git ran is not dropped. Like
|
||||
// POST /api/cases/link the write is not serialized, so this only narrows the window.
|
||||
try {
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
|
||||
return { ...current, [name]: casePath };
|
||||
});
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name])
|
||||
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
} catch (err) {
|
||||
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
|
||||
const conflict = isLinkedConflict(err);
|
||||
const refused = err instanceof LinkedCasesWriteRefusedError;
|
||||
reply.code(conflict || refused ? 409 : 500);
|
||||
const conflict = (err as { conflict?: boolean }).conflict;
|
||||
reply.code(conflict ? 409 : 500);
|
||||
return createErrorResponse(
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED,
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
|
||||
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
|
||||
);
|
||||
}
|
||||
@@ -1723,17 +1713,28 @@ export function registerCaseRoutes(
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
|
||||
// The name check and the write are one step, so two links of one name cannot both succeed.
|
||||
// Load existing linked cases
|
||||
const linkedCases = await readLinkedCases();
|
||||
|
||||
// Check if name is already linked
|
||||
if (linkedCases[name]) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`Case "${name}" is already linked to ${linkedCases[name]}`
|
||||
);
|
||||
}
|
||||
|
||||
// Save the linked case
|
||||
linkedCases[name] = expandedPath;
|
||||
try {
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
|
||||
return { ...current, [name]: expandedPath };
|
||||
});
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) {
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
@@ -1814,19 +1815,13 @@ export function registerCaseRoutes(
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
// Removes this one entry from the registry as it is now, keeping any linked since the read above.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (!Object.hasOwn(current, name)) return undefined;
|
||||
const next = { ...current };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
await forgetCaseDefault();
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
@@ -2012,6 +2007,7 @@ export function registerCaseRoutes(
|
||||
stopped++;
|
||||
}
|
||||
if (moveFolder) await moveCaseFolder(oldPath, newPath);
|
||||
const originalLinks = isLinked ? await readLinkedCases() : undefined;
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
@@ -2031,19 +2027,16 @@ export function registerCaseRoutes(
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
}
|
||||
}
|
||||
if (isLinked) {
|
||||
// Checked again where the entry is written: the checks above ran before the sessions were
|
||||
// stopped and the folder moved. A failed write leaves the registry as it was.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`);
|
||||
if (current[name] !== oldPath) {
|
||||
throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`);
|
||||
}
|
||||
const links = { ...current };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
return links;
|
||||
});
|
||||
if (originalLinks) {
|
||||
const links = { ...originalLinks };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
|
||||
} catch (err) {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
@@ -2088,9 +2081,6 @@ export function registerCaseRoutes(
|
||||
: ApiErrorCode.OPERATION_FAILED;
|
||||
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped });
|
||||
}
|
||||
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1595,12 +1595,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||
const { depth, showHidden, q, root } = req.query as {
|
||||
depth?: string;
|
||||
showHidden?: string;
|
||||
q?: string;
|
||||
root?: string;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
const workingDir = session.workingDir;
|
||||
// `root=live` starts the walk at the linked worktree the pane is in (#765). That folder is
|
||||
// inside `workingDir` (`live-worktree.ts`), and every `path` below stays relative to
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await probePathKind(liveWorktree.path, { pastCap: true })) === 'directory'
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1676,17 +1691,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
}
|
||||
// Always recurse, even when this directory does not match.
|
||||
await searchDirectory(fullPath, currentDepth + 1);
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
@@ -1705,12 +1721,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(workingDir, 1);
|
||||
await searchDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1791,12 +1808,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
const tree = await scanDirectory(workingDir, 1);
|
||||
const tree = await scanDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
|
||||
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { findLiveWorktree } from '../live-worktree.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
@@ -2967,6 +2968,7 @@ export class WebServer extends EventEmitter {
|
||||
// `undefined` here means an authoritative read listed no single directory
|
||||
// for the session (split into several panes, or gone from the socket).
|
||||
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
|
||||
if (paneCwdChanged) void this.refreshLiveWorktree(session);
|
||||
if (session.setPaneExit(getPaneExit(muxName))) {
|
||||
this.persistSessionState(session);
|
||||
} else if (!paneCwdChanged) {
|
||||
@@ -2977,6 +2979,18 @@ export class WebServer extends EventEmitter {
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which linked worktree a session's pane moved into (#765). In the background, since
|
||||
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
|
||||
* A reading newer than the one this lookup started from owns the answer.
|
||||
*/
|
||||
private async refreshLiveWorktree(session: Session): Promise<void> {
|
||||
const cwd = session.paneCwd;
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd);
|
||||
if (session.paneCwd !== cwd) return;
|
||||
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
|
||||
@@ -428,14 +428,20 @@ describe('EditHistorySync', () => {
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
const orig = host.read;
|
||||
let first = true;
|
||||
let inRead!: () => void;
|
||||
const reachedRead = new Promise<void>((r) => (inRead = r));
|
||||
host.read = async (...a) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
inRead();
|
||||
await gate;
|
||||
}
|
||||
return orig(...a);
|
||||
};
|
||||
const slow = sync.sync(local);
|
||||
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
|
||||
// could reach the read first, take the gate and hang the loop below before `release()`.
|
||||
await reachedRead;
|
||||
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
|
||||
release();
|
||||
expect(await slow).toEqual({ state: 'ok' });
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @fileoverview Files panel following the agent into a git worktree (#765).
|
||||
*
|
||||
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
|
||||
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
|
||||
* why the follow compares with what the listing reported rather than with the previous update.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
|
||||
|
||||
function element() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPanel() {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route: the session's worktree, listed when the request asks for it.
|
||||
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.worktree;
|
||||
const data = {
|
||||
root: live ? server.worktree!.path : '/repo',
|
||||
...(server.worktree && { worktree: server.worktree }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
}
|
||||
|
||||
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
|
||||
hidden: elements.fileBrowserRoot.hidden,
|
||||
label: elements.fileBrowserRootLabel.textContent,
|
||||
name: elements.fileBrowserRootName.textContent,
|
||||
button: elements.fileBrowserRootSwitch.textContent,
|
||||
title: elements.fileBrowserRoot.title,
|
||||
});
|
||||
|
||||
describe('Files panel in a git worktree', () => {
|
||||
it('asks for the live root by default', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests[0]).toContain('root=live');
|
||||
});
|
||||
|
||||
it('shows no strip for a session in no worktree', async () => {
|
||||
const { app, elements } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('says which worktree is listed, and offers the session folder', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Worktree',
|
||||
name: 'probe',
|
||||
button: 'Session folder',
|
||||
title: WORKTREE.path,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to the session folder and back', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[1]).not.toContain('root=live');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Session folder',
|
||||
name: '',
|
||||
button: 'Back to worktree',
|
||||
title: '/repo',
|
||||
});
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[2]).toContain('root=live');
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('keeps the query in the box across the switch, and searches the new root', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._fileBrowserState.filter = 'main';
|
||||
elements.fileBrowserSearch.value = 'main';
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
|
||||
|
||||
const search = server.requests.find((url) => url.includes('q=main'))!;
|
||||
expect(search).not.toContain('root=live');
|
||||
});
|
||||
|
||||
it('follows the agent into a worktree while the panel is open', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
|
||||
server.worktree = WORKTREE;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
server.worktree = null;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1' });
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('settles: updates that agree with the listing reload nothing', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves another session and a closed panel alone', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
|
||||
elements.fileBrowserPanel.classList.toggle('visible', false);
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops the pin and the strip on a session switch', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
app._activateFileBrowserSession('sess-2');
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired up in the page', () => {
|
||||
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
|
||||
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
|
||||
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
|
||||
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to
|
||||
* overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real
|
||||
* filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()).
|
||||
*
|
||||
* The CI flake this fixes was "a name linked while the clone ran is a 409" in
|
||||
* test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the
|
||||
* registry before either wrote it, so both answered success and the later write replaced the
|
||||
* earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers
|
||||
* that both read before either writes lose an entry every time instead of once in a while.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import { registerCaseRoutes } from '../src/web/routes/case-routes.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js';
|
||||
import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const LINKED = () => linkedCasesPath();
|
||||
const linked = (): Record<string, string> => JSON.parse(readFileSync(LINKED(), 'utf8'));
|
||||
/** Temp files the writer left beside the registry (there must never be any). */
|
||||
const leftovers = () =>
|
||||
readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp'));
|
||||
/** The bytes of a registry torn by two overlapping truncate-then-write calls. */
|
||||
const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}';
|
||||
|
||||
/** How long every write to the registry (or a temp file beside it) is held before it starts. */
|
||||
const SLOW_WRITE_MS = 60;
|
||||
const realWriteFile = fs.writeFile.bind(fs);
|
||||
|
||||
let workDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isSandboxedHome()) {
|
||||
throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test');
|
||||
}
|
||||
safeRmHomeTree(LINKED());
|
||||
mkdirSync(dirname(LINKED()), { recursive: true });
|
||||
workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-')));
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
|
||||
if (typeof file === 'string' && file.startsWith(LINKED())) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
|
||||
}
|
||||
return realWriteFile(file as string, data as string, options as undefined);
|
||||
}) as typeof fs.writeFile);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (workDir) safeRmHomeTree(workDir);
|
||||
workDir = '';
|
||||
if (isSandboxedHome()) safeRmHomeTree(LINKED());
|
||||
});
|
||||
|
||||
describe('mutateLinkedCases()', () => {
|
||||
it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` })))
|
||||
);
|
||||
expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']);
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => {
|
||||
const claim = (name: string, path: string) =>
|
||||
mutateLinkedCases((current) => {
|
||||
if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true });
|
||||
return { ...current, [name]: path };
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
claim('twin', '/srv/a'),
|
||||
claim('twin', '/srv/b'),
|
||||
claim('solo', '/srv/s'),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
|
||||
expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken');
|
||||
expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' });
|
||||
});
|
||||
|
||||
it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => {
|
||||
const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(before).toEqual({});
|
||||
expect(after).toEqual({ a: '/srv/a' });
|
||||
writeFileSync(LINKED(), ' \n');
|
||||
await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' }));
|
||||
expect(linked()).toEqual({ b: '/srv/b' });
|
||||
const untouched = readFileSync(LINKED(), 'utf8');
|
||||
expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(untouched);
|
||||
});
|
||||
|
||||
it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => {
|
||||
for (const content of [TORN, '[]', 'null', '"text"']) {
|
||||
writeFileSync(LINKED(), content);
|
||||
const fn = vi.fn((current: Record<string, string>) => ({ ...current, a: '/srv/a' }));
|
||||
await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(content);
|
||||
}
|
||||
// The chain keeps going after a refusal.
|
||||
writeFileSync(LINKED(), '{}');
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(linked()).toEqual({ a: '/srv/a' });
|
||||
});
|
||||
|
||||
it('leaves the previous registry and no temp file when the write fails', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
const realRename = fs.rename.bind(fs);
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left');
|
||||
expect(linked()).toEqual({ kept: '/srv/kept' });
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => {
|
||||
const real = join(workDir, 'linked-cases.json');
|
||||
writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 });
|
||||
safeRmHomeTree(LINKED());
|
||||
symlinkSync(real, LINKED());
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(LINKED())).toBe(real);
|
||||
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' });
|
||||
expect(statSync(real).mode & 0o777).toBe(0o600);
|
||||
expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the case routes share the writer', () => {
|
||||
const folder = (name: string) => {
|
||||
const dir = join(workDir, name);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
it('two links of different names at once both survive', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') });
|
||||
});
|
||||
|
||||
it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
|
||||
const loser = a.json().success ? b : a;
|
||||
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
|
||||
expect(Object.keys(linked())).toEqual(['twin']);
|
||||
});
|
||||
|
||||
it('an unlink at the same time as a link keeps the new link', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ old: folder('old') }));
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [unlink, link] = await Promise.all([
|
||||
app.inject({ method: 'DELETE', url: '/api/cases/old' }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }),
|
||||
]);
|
||||
expect([unlink.json().success, link.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ fresh: join(workDir, 'fresh') });
|
||||
expect(existsSync(join(workDir, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a link refuses a registry that does not parse instead of replacing it', async () => {
|
||||
writeFileSync(LINKED(), TORN);
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } });
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(TORN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
|
||||
*
|
||||
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
|
||||
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
|
||||
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
|
||||
* file. Runs against a real filesystem, and one case against a worktree real git made.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, 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 { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
|
||||
|
||||
describe('isLinkedWorktreeGitFile', () => {
|
||||
it('accepts what git writes for a linked worktree, absolute or relative', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLiveWorktree', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the worktree from its own root and from below it', async () => {
|
||||
const expected = { name: 'probe', path: worktree };
|
||||
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
|
||||
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
|
||||
});
|
||||
|
||||
it('answers the same for a workingDir spelled with a trailing slash', async () => {
|
||||
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('does not treat the session folder as a worktree of itself', async () => {
|
||||
writeFileSync(join(worktree, 'marker'), '');
|
||||
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
|
||||
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
|
||||
mkdirSync(join(repo, 'src'));
|
||||
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never answers outside the session folder', async () => {
|
||||
const outside = join(base, 'repo-feature');
|
||||
mkdirSync(outside);
|
||||
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
|
||||
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
|
||||
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops at a submodule and at a repository of its own', async () => {
|
||||
const submodule = join(repo, 'lib');
|
||||
mkdirSync(submodule);
|
||||
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
|
||||
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
|
||||
|
||||
const nested = join(worktree, 'vendored');
|
||||
mkdirSync(join(nested, '.git'), { recursive: true });
|
||||
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git that is not a regular file', async () => {
|
||||
const linked = join(repo, 'linked');
|
||||
mkdirSync(linked);
|
||||
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
|
||||
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
|
||||
|
||||
// A FIFO would block open() for good: the test would time out rather than fail.
|
||||
const piped = join(repo, 'piped');
|
||||
mkdirSync(piped);
|
||||
execFileSync('mkfifo', [join(piped, '.git')]);
|
||||
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers nothing without an absolute reading', async () => {
|
||||
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '')).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises a worktree real git made', async () => {
|
||||
const real = join(base, 'real');
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
|
||||
cwd: real,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
mkdirSync(real);
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'init');
|
||||
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
|
||||
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
|
||||
name: 'made',
|
||||
path: join(real, '.claude', 'worktrees', 'made'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,11 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
|
||||
expect(session.toState().workingDir).toBe('/work/proj');
|
||||
});
|
||||
|
||||
describe('liveWorktree (#765)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const inRepo = () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
const broadcast = vi.fn();
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
broadcast;
|
||||
mux.getPaneExit = () => undefined;
|
||||
return { mux, session, web, broadcast };
|
||||
};
|
||||
|
||||
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
|
||||
const { mux, session, web, broadcast } = inRepo();
|
||||
let dir = worktree;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
|
||||
});
|
||||
|
||||
it('does nothing for a pane that has not moved', async () => {
|
||||
const { mux, session } = inRepo();
|
||||
mux.getPaneCurrentPath = () => repo;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
||||
mux.emit('paneExitsUpdated');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops an answer for a directory the pane has already left', async () => {
|
||||
const { web, session } = inRepo();
|
||||
session.setPaneCwd(worktree);
|
||||
const refresh = (
|
||||
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
|
||||
).refreshLiveWorktree.call(web, session);
|
||||
session.setPaneCwd(repo);
|
||||
await refresh;
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
@@ -41,7 +40,7 @@ beforeEach(() => {
|
||||
if (!sandboxed()) {
|
||||
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
|
||||
}
|
||||
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
|
||||
// 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-')));
|
||||
@@ -135,36 +134,20 @@ describe('POST /api/cases with a custom path', () => {
|
||||
|
||||
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
// The registry is replaced by renaming a temp file over it: fail that rename.
|
||||
const realRename = fs.rename;
|
||||
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
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);
|
||||
expect(linked()).toEqual({});
|
||||
});
|
||||
|
||||
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
|
||||
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'));
|
||||
// A registry that does not parse is never written over (that would unlink every other case).
|
||||
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
|
||||
writeFileSync(LINKED(), torn);
|
||||
mkdirSync(LINKED(), { recursive: true });
|
||||
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().errorCode).toBe('CONFLICT');
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(join(work(), 'picked'))).toBe(true);
|
||||
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
|
||||
});
|
||||
|
||||
it('a request without a path still creates under the cases directory, as before', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -35,21 +36,6 @@ let claudeCfg = '';
|
||||
const made: string[] = [];
|
||||
|
||||
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
||||
/**
|
||||
* The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a
|
||||
* failed registry write is injected at that rename. `before` runs first (another writer landing then).
|
||||
*/
|
||||
const failRegistryWrite = (before?: () => Promise<unknown>, alsoFail?: (from: string) => boolean) => {
|
||||
const realRename = fs.rename;
|
||||
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) {
|
||||
if (before) await before();
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
};
|
||||
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
||||
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
|
||||
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
|
||||
@@ -292,13 +278,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
// the registry reads fine but cannot be written: the rename must undo the folder move
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
chmodSync(LINKED(), 0o444);
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(real)).toBe(true);
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
@@ -313,9 +294,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
// Another writer (a settings save) lands after the rename wrote settings.json and before the
|
||||
// registry write fails.
|
||||
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
|
||||
const realWriteFile = fs.writeFile;
|
||||
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
|
||||
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
|
||||
return realWriteFile(file, data, options);
|
||||
});
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -365,8 +351,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
const moved = join(work, 'renamed');
|
||||
const spy = failRegistryWrite(undefined, (from) => from === moved);
|
||||
const realRename = fs.rename;
|
||||
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -382,14 +373,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.json().stoppedSessions).toBe(1);
|
||||
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
|
||||
|
||||
@@ -54,12 +54,6 @@ vi.mock('node:fs/promises', () => ({
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
// What the linked-cases writer needs to replace the registry atomically (temp file + rename).
|
||||
realpath: vi.fn(async (p: string) => p),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
chmod: vi.fn(async () => {}),
|
||||
rename: vi.fn(async () => {}),
|
||||
rm: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -710,7 +704,6 @@ describe('case-routes', () => {
|
||||
});
|
||||
// linked-cases.json doesn't exist yet
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
|
||||
*
|
||||
* The listing starts at the worktree the pane is in, and every `path` stays relative to
|
||||
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
|
||||
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const PREFIX = '.claude/worktrees/probe';
|
||||
|
||||
describe('Files panel listing of a live worktree', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
|
||||
worktree = join(repo, PREFIX);
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src'), { recursive: true });
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'probe', path: worktree },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists the worktree with paths relative to the session folder', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('answers exactly as before for a session in no worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: undefined });
|
||||
const data = await list('&root=live');
|
||||
expect(data).toEqual(await list());
|
||||
expect(data.root).toBe(repo);
|
||||
expect('worktree' in data).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
|
||||
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('serves a listed path through the unchanged preview route', async () => {
|
||||
const path = `${PREFIX}/src/worktree-only.ts`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
expect(res.json().success).toBe(true);
|
||||
expect(res.json().data.content).toBe('worktree');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user