From ace4c2f3acaac1d0d232b518e06693f45d8225e2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 16:04:49 +0200 Subject: [PATCH 01/29] feat(changes): serve the content pair and the write of a changed file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel holds a repo-relative path and nothing else; editing a changed file needs the original/working-tree pair behind it and a way to write it back, without ever handing an absolute path to the renderer. git-changes-file reads the side git diff itself compares against — the index blob for the unstaged view, HEAD for the staged one — so the panel and the editor cannot disagree about what changed. The blob is read with `git cat-file blob`, not `git show`: `git show` resolves `:/` to a commit and a directory path to a tree listing, both with exit 0, and would hand either to the editor as file content; `cat-file blob` refuses anything that is not a blob. `:` is a revision operand, not a pathspec — `--literal-pathspecs` does not reach it and `--` cannot separate it — so it gets its own guard instead of the pathspec one, including the `::` conflict-stage syntax the pathspec guard has no reason to know about. git-changes-save re-resolves the session's cwd on every call, resolves the repository root and the target on disk, requires the target to stay inside the root, applies the sensitive-path denylist, and writes the path the guard returned. Both handlers refuse a remote session: there is no file-write path to a remote host in this app. --- .ai/contexts/changes-view.md | 106 +++++++- .ai/contexts/ipc-bridge.md | 17 +- git-changes-file.js | 149 +++++++++++ git-changes-runner.js | 1 + main.js | 43 +++- preload.js | 2 + test/git-changes-file-real-git.test.js | 340 +++++++++++++++++++++++++ test/git-changes-file.test.js | 66 +++++ 8 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 git-changes-file.js create mode 100644 test/git-changes-file-real-git.test.js create mode 100644 test/git-changes-file.test.js diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 559f06fe..58434392 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -13,8 +13,9 @@ integration: `.ai/contexts/viewer-panel.md` ("Changes mode"). |---|---| | `git-changes.js` | Pure parser — no electron, no DOM, no fs. `require()`-d from `main.js` and from tests, same pattern as `remote-hosts.js` / `derive-project-path.js`. | | `git-changes-runner.js` | Runs the git commands, local or remote, behind one interface. | -| `git-changes-target.js` | cwd resolution for the two IPCs, extracted out of `main.js` for testability (same rationale as `delete-session-target.js`). | -| `public/file-panel.js` | Renderer: the `'changes'` tab type, its rows, and the fallback diff-line renderer. | +| `git-changes-target.js` | cwd resolution for the panel's IPCs, extracted out of `main.js` for testability (same rationale as `delete-session-target.js`). | +| `git-changes-file.js` | The content pair and the write target behind the editable diff: the `:` guard, the repository-containment check, the read and the write. | +| `public/file-panel.js` | Renderer: the `'changes'` tab type, its rows, the editor, and the read-only diff-line renderer. | | `public/session-activity.js` | `onSessionIdle()` — the no-polling refresh hook. | ## Parser (`git-changes.js`) @@ -314,6 +315,107 @@ Extracted out of the two IPC handlers into its own module, fully dependency-inje `filePath` on `git-changes-diff` is a git pathspec relative to that cwd, not an absolute filesystem path, so `ipc-path-validator.js`'s allowlist/denylist helpers (which assume an absolute path under a known root) don't fit — it's validated by the runner's own `isSafeGitPath` instead (see "Quoting rule" above). +## Editing a changed file (`git-changes-file.js`) + +A changed file of a **local** session is edited in place in the panel, with the +diff recomputed as the user types. Two IPCs carry that — `git-changes-file` +(the content pair) and `git-changes-save` (the write); both take the same +repo-relative path the rows already carry, and neither returns an absolute +path. The renderer never learns where the repository is: the session's cwd is +re-resolved through `resolveGitChangesTarget` on **every** call, the absolute +path is built from it, used, and discarded main-side. + +`git-changes-file` returns `{ok, original, current, binary, truncated}`. +`original` is the side `git diff` itself compares against, so the diff the +panel draws and the diff `git diff` would print cannot disagree: the index +(`:`) for the unstaged view, `HEAD:` for the staged one. A path +absent from that tree exits 128 — that is the untracked/new-file case, and it +yields `original: ''` rather than an error. The repository's own validity is +established before that call (`git rev-parse --show-toplevel` has already +succeeded), which is what makes "non-zero means the path is not in this tree" +safe to read that way. + +### `git cat-file blob`, not `git show` + +Both print the blob for a well-formed `:`. They differ on +everything else, and the difference is the whole guard: + +| Operand | `git show` | `git cat-file blob` | +|---|---|---| +| `:/` | exit 0, prints a **commit** (`:/text` is commit-message search magic) | exit 128, `Not a valid object name` | +| `HEAD:` or a directory path | exit 0, prints a **tree listing** | exit 128, `bad file` | +| `HEAD` | exit 0, prints a commit with its diff | exit 128 | +| a path not in that tree | exit 128 | exit 128 | + +Measured against git 2.53 and pinned in `test/git-changes-file-real-git.test.js`. +`git show` is content-type-polymorphic: hand it something that is not a blob +and it prints *something else* with exit 0, which would land in the editor as +"the original side of this file". `cat-file blob` is type-constrained — a +non-blob is an error, never output — so a guard bug downstream degrades into a +refusal instead of into the wrong content. + +### `:` is not a pathspec, and does not reuse the pathspec guard + +`--literal-pathspecs` does not apply to a revision operand, `--` cannot +separate it from options, and `isSafeGitPath`'s leading-`:` rejection was +written for pathspec magic (`:(exclude)`, `:/`, `:(top)`), which is a different +syntax from revision magic. So the operand carries its own pair of guards +(`git-changes-file.js`): + +- `isSafeRepoRelativePath` — non-empty, ≤ 4096 chars, no control characters + (NUL, newline, carriage return included), no `..`, not absolute (`/`, `\`, + `X:`), no leading `-`, no leading `:`. This is what `git-changes-save` uses, + because its operand is a filesystem path and nothing else. +- `isSafeRevPathOperand` — the above **plus** no `^[0-9]+:` prefix, which would + turn `:` into `::`, git's conflict-stage syntax. A file + literally named `1:f.txt` is therefore not editable from the panel: an + accepted, narrow loss against a second layer of revision syntax hiding inside + what the renderer called a file path. + +Rejecting a leading `/` is what closes `:/`, since the search magic is +reachable only through an operand that starts with a slash after the colon. + +### Containment, and which path the write runs on + +The boundary is the repository root (`git rev-parse --show-toplevel`), not the +session cwd: the paths in a row come from `git status`, which reports them +relative to the root, and a session whose cwd is a subdirectory of the repo +must still open its own repository's files. The root is computed by git from +the already-resolved cwd — it is never a renderer-supplied string. + +`resolveTargetInsideRepo` resolves both the root and `path.join(root, relPath)` +**on disk** (`resolveOnDisk`), requires the real target to be the real root or +beneath it, applies `isSensitivePath`, and requires a regular file. It returns +that single resolved path, and the read and the write run on **that** value — +the TOCTOU rule `ipc-path-validator.js` documents for +`resolveAllowedMemoryPath`: two independent resolutions of the same string are +two chances for a symlink swap in between, one resolution reused cannot +diverge from itself. A symlink inside the repository pointing outside it is +refused by exactly that check (measured, both for the read and for the write). + +`save-file-for-panel`, the neighbouring write handler, has no containment check +at all — it takes an absolute path from an OSC 8 terminal hyperlink and checks +only `isSensitivePath`. A handler whose entire input is a *relative* path from +the renderer has no such excuse, so it does not inherit that shape. + +### Caps, and why an oversized file is refused rather than truncated + +Same two limits the other panel reads use (`PANEL_FILE_MAX_BYTES`, 2 MB, and a +NUL byte anywhere means binary), applied to both sides of the pair, and the +refusal says **which** of the two it was (`reason: 'binary'` vs +`'too-large'`) so the panel can explain itself and fall back to the read-only +unified diff. Neither side is ever truncated: a truncated buffer in an editor +that can save is a data-loss device, not a preview. + +### Remote sessions are refused + +Both IPCs refuse a session whose target is remote. There is no file-write path +to a remote host anywhere in this app, and the working-tree side of the pair is +read with `fs.readFileSync` from a path that only means anything on this +machine. The renderer never calls them for a remote session either — it keeps +the read-only unified-diff renderer — so the refusal is the second line, not +the only one. + ## Refresh triggers (no polling) `refreshChanges(sessionId)` in `public/file-panel.js` runs only from three places: opening the tab, the panel's own Refresh button, and a subscriber registered with `onSessionIdle()` (`public/session-activity.js`) inside `initFilePanel()`. diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 8a989bde..f96cff8f 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -82,14 +82,21 @@ This file is the **canonical inventory** of the IPC surface. When you add a new ### Changes panel (issue #251) -Read-only git-status view in the same right-hand file panel, for local and -remote sessions alike. Full design (parser, runner, quoting, cwd resolution, -refresh triggers): `.ai/contexts/changes-view.md`. User-facing: `docs/changes-view.md`. +A git-status view in the same right-hand file panel, for local and remote +sessions alike; a local session's changed file is editable in place. Full +design (parser, runner, quoting, cwd resolution, refresh triggers, editing): +`.ai/contexts/changes-view.md`. User-facing: `docs/changes-view.md`. | IPC | Args | Returns | Notes | |---|---|---|---| -| `git-changes-status` | `(sessionId)` | `{ok, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. | +| `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. | | `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- `, or `git diff --no-index -- /dev/null ` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). | +| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `sensitive`, `not-a-file`, `binary`, `too-large`, `remote`. | +| `git-changes-save` | `(sessionId, filePath, content)` | `{ok:true} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved. Local sessions only; never creates a file. | + +`filePath` is repo-relative in all four. No absolute path crosses this +boundary in either direction: the session's cwd is re-resolved main-side on +every call, and the absolute path built from it is used and discarded there. ### Misc @@ -157,6 +164,8 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it | | `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused | | `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded | +| `git-changes-file` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root comes from `git rev-parse --show-toplevel`, both root and target are resolved on disk, the target must stay inside the root, and `isSensitivePath` applies on top | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | +| `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | | `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc | ### Non-obvious behaviors diff --git a/git-changes-file.js b/git-changes-file.js new file mode 100644 index 00000000..a59fb0a4 --- /dev/null +++ b/git-changes-file.js @@ -0,0 +1,149 @@ +// git-changes-file.js — content pair and write target for the editable Changes panel — see .ai/contexts/changes-view.md + +'use strict'; + +const realFs = require('fs'); +const path = require('path'); +const { execFile } = require('child_process'); +const { localGitEnv } = require('./git-changes-runner'); +const { resolveOnDisk, isInsideDir } = require('./resolve-path-on-disk'); +const { isSensitivePath } = require('./ipc-path-validator'); + +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_PATH_LENGTH = 4096; +const TOPLEVEL_MAX_BUFFER = 64 * 1024; + +// Guards for a repo-relative path from the renderer — see .ai/contexts/changes-view.md ("Editing a changed file") +function isSafeRepoRelativePath(p) { + if (typeof p !== 'string' || p === '' || p.length > MAX_PATH_LENGTH) return false; + if (/[\x00-\x1f\x7f]/.test(p)) return false; + if (p.includes('..')) return false; + if (p[0] === '/' || p[0] === '\\') return false; + if (/^[A-Za-z]:/.test(p)) return false; + if (p[0] === ':') return false; + if (p[0] === '-') return false; + return true; +} + +// `:` is a revision operand, not a pathspec — see .ai/contexts/changes-view.md ("Editing a changed file") +function isSafeRevPathOperand(p) { + if (!isSafeRepoRelativePath(p)) return false; + if (/^[0-9]+:/.test(p)) return false; + return true; +} + +function buildBlobRev(relPath, staged) { + return (staged ? 'HEAD:' : ':') + relPath; +} + +function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { + return new Promise((resolve) => { + execFile('git', args, { cwd, env: localGitEnv(), timeout: timeoutMs, maxBuffer, encoding: 'buffer', windowsHide: true }, + (err, stdout, stderr) => { + const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout || ''); + if (err) { + resolve({ + code: typeof err.code === 'number' ? err.code : -1, + stdout: out, + tooLarge: err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + }); + return; + } + resolve({ code: 0, stdout: out, tooLarge: false }); + }); + }); +} + +async function resolveRepoRoot(cwd, deps) { + const runGit = deps.runGit || defaultRunGit; + const result = await runGit(['rev-parse', '--show-toplevel'], { + cwd, + timeoutMs: deps.timeoutMs || DEFAULT_TIMEOUT_MS, + maxBuffer: TOPLEVEL_MAX_BUFFER, + }); + if (result.code !== 0) return null; + const root = String(result.stdout).trim(); + return root || null; +} + +// Returns the single resolved path every later read/write must use — see .ai/contexts/changes-view.md +function resolveTargetInsideRepo(repoRoot, relPath, deps) { + const fs = deps.fs || realFs; + const realRoot = resolveOnDisk(repoRoot); + if (!realRoot) return { ok: false, error: 'the repository directory no longer exists', reason: 'repo' }; + + const real = resolveOnDisk(path.join(realRoot, relPath)); + if (!real) return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; + if (!isInsideDir(real, realRoot)) return { ok: false, error: 'path resolves outside the repository', reason: 'outside' }; + if (isSensitivePath(real)) return { ok: false, error: 'access to sensitive path denied', reason: 'sensitive' }; + + let stat; + try { + stat = fs.statSync(real); + } catch { + return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; + } + if (!stat.isFile()) return { ok: false, error: 'not a regular file', reason: 'not-a-file' }; + + return { ok: true, path: real, size: stat.size, repoRoot: realRoot }; +} + +async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { + const fs = deps.fs || realFs; + if (!isSafeRevPathOperand(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + + const repoRoot = await resolveRepoRoot(cwd, deps); + if (!repoRoot) return { ok: false, error: 'not a git repository', reason: 'repo' }; + + const target = resolveTargetInsideRepo(repoRoot, relPath, deps); + if (!target.ok) return target; + + if (target.size > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; + const buf = fs.readFileSync(target.path); + if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; + if (buf.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; + + const runGit = deps.runGit || defaultRunGit; + const blob = await runGit(['cat-file', 'blob', buildBlobRev(relPath, staged)], { + cwd: target.repoRoot, + timeoutMs: deps.timeoutMs || DEFAULT_TIMEOUT_MS, + maxBuffer: maxBytes + 1, + }); + + if (blob.tooLarge) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; + + let original = ''; + if (blob.code === 0) { + if (blob.stdout.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; + if (blob.stdout.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; + original = blob.stdout.toString('utf8'); + } + + return { ok: true, original, current: buf.toString('utf8'), binary: false, truncated: false }; +} + +async function writeChangesFile({ cwd, relPath, content, maxBytes }, deps = {}) { + const fs = deps.fs || realFs; + if (typeof content !== 'string') return { ok: false, error: 'invalid content', reason: 'invalid-content' }; + if (!isSafeRepoRelativePath(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + if (Buffer.byteLength(content, 'utf8') > maxBytes) return { ok: false, error: 'content too large to save', reason: 'too-large' }; + + const repoRoot = await resolveRepoRoot(cwd, deps); + if (!repoRoot) return { ok: false, error: 'not a git repository', reason: 'repo' }; + + const target = resolveTargetInsideRepo(repoRoot, relPath, deps); + if (!target.ok) return target; + + fs.writeFileSync(target.path, content, 'utf8'); + return { ok: true, savedPath: target.path }; +} + +module.exports = { + readChangesFile, + writeChangesFile, + resolveTargetInsideRepo, + resolveRepoRoot, + isSafeRepoRelativePath, + isSafeRevPathOperand, + buildBlobRev, +}; diff --git a/git-changes-runner.js b/git-changes-runner.js index b74ae247..4d9e7c6b 100644 --- a/git-changes-runner.js +++ b/git-changes-runner.js @@ -294,6 +294,7 @@ module.exports = { createGitChangesRunner, buildRemoteGitCommand, buildGitArgs, + localGitEnv, truncateDiffContent, shQuote, isSafeCwd, diff --git a/main.js b/main.js index 81f6f531..3ce8b301 100644 --- a/main.js +++ b/main.js @@ -86,6 +86,7 @@ const { createRemoteStopAdapter } = require('./remote-stop'); const { createGitChangesRunner } = require('./git-changes-runner'); const gitChangesTarget = require('./git-changes-target'); const { resolvePanelTerminalCwd, isPanelShellSession } = require('./panel-terminal-target'); +const gitChangesFile = require('./git-changes-file'); setPtyOpLogger(log); @@ -1758,7 +1759,8 @@ ipcMain.handle('git-changes-status', async (_event, sessionId) => { const target = resolveGitChangesTarget(sessionId); if (!target.ok) return target; try { - return await gitChangesRunnerFor(target).status(); + const result = await gitChangesRunnerFor(target).status(); + return result.ok === false ? result : { ...result, kind: target.kind }; } catch (err) { return { ok: false, error: err.message }; } @@ -1776,6 +1778,45 @@ ipcMain.handle('git-changes-diff', async (_event, sessionId, filePath, staged, u } }); +// filePath is a repo-relative path, resolved and contained main-side — see .ai/contexts/changes-view.md ("Editing a changed file") +ipcMain.handle('git-changes-file', async (_event, sessionId, filePath, opts) => { + if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + const target = resolveGitChangesTarget(sessionId); + if (!target.ok) return target; + if (target.kind !== 'local') return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; + try { + return await gitChangesFile.readChangesFile({ + cwd: target.cwd, + relPath: filePath, + staged: !!(opts && opts.staged), + maxBytes: PANEL_FILE_MAX_BYTES, + }); + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content) => { + if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + const target = resolveGitChangesTarget(sessionId); + if (!target.ok) return target; + if (target.kind !== 'local') return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; + try { + const result = await gitChangesFile.writeChangesFile({ + cwd: target.cwd, + relPath: filePath, + content, + maxBytes: PANEL_FILE_MAX_BYTES, + }); + if (!result.ok) return result; + if (result.savedPath.includes('/.work-files/')) invalidateFtsSignature('work-file'); + if (result.savedPath.endsWith('.md')) invalidateFtsSignature('memory'); + return { ok: true }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + // --- IPC: toggle-star --- ipcMain.handle('toggle-star', (_event, sessionId) => { const starred = toggleStar(sessionId); diff --git a/preload.js b/preload.js index 03a9ea8e..d0e1dec2 100644 --- a/preload.js +++ b/preload.js @@ -36,6 +36,8 @@ contextBridge.exposeInMainWorld('api', { // see .ai/contexts/changes-view.md gitChangesStatus: (sessionId) => ipcRenderer.invoke('git-changes-status', sessionId), gitChangesDiff: (sessionId, filePath, staged, untracked) => ipcRenderer.invoke('git-changes-diff', sessionId, filePath, staged, untracked), + gitChangesFile: (sessionId, filePath, opts) => ipcRenderer.invoke('git-changes-file', sessionId, filePath, opts), + gitChangesSave: (sessionId, filePath, content) => ipcRenderer.invoke('git-changes-save', sessionId, filePath, content), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js new file mode 100644 index 00000000..425c1b73 --- /dev/null +++ b/test/git-changes-file-real-git.test.js @@ -0,0 +1,340 @@ +'use strict'; + +// A real, on-disk git repository — no injected exec, no fakes: the content +// pair and the write target of the editable Changes panel, measured against +// the git binary and the filesystem they will meet in production. +// See .ai/contexts/changes-view.md ("Editing a changed file"). + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const { readChangesFile, writeChangesFile } = require('../git-changes-file'); + +// git translates its diagnostics; the assertions below match its English text. +process.env.LC_ALL = 'C'; +process.env.LANGUAGE = 'C'; + +const MAX_BYTES = 1024 * 1024; + +function mkTmp() { + return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-gcf-real-'))); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// Scratch repo only: drop the caller's GIT_* env (set when this suite runs under a hook) and its hooks. +function scratchGitEnv() { + const env = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith('GIT_') || k.startsWith('HUSKY')) continue; + env[k] = v; + } + return env; +} + +function git(cwd, args) { + return execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', env: scratchGitEnv() }); +} + +// committed → indexed → working tree, so the three sides are distinguishable. +function initRepo(repoDir) { + fs.mkdirSync(repoDir, { recursive: true }); + git(repoDir, ['init', '-q']); + git(repoDir, ['config', 'user.email', 'a@a.com']); + git(repoDir, ['config', 'user.name', 'a']); + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'committed\n'); + git(repoDir, ['add', 'f.txt']); + git(repoDir, ['commit', '-q', '-m', 'first commit about SECRETWORD']); + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'indexed\n'); + git(repoDir, ['add', 'f.txt']); + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'worktree\n'); +} + +function read(repoDir, relPath, staged) { + return readChangesFile({ cwd: repoDir, relPath, staged: !!staged, maxBytes: MAX_BYTES }); +} + +// --- The content pair --------------------------------------------------- + +test('real git: the unstaged view pairs the index blob with the working tree', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const result = await read(repoDir, 'f.txt', false); + assert.equal(result.ok, true, result.error); + assert.equal(result.original, 'indexed\n', 'the unstaged diff compares against the index, so the editor must too'); + assert.equal(result.current, 'worktree\n'); + assert.equal(result.binary, false); + assert.equal(result.truncated, false); + } finally { cleanup(tmp); } +}); + +test('real git: the staged view pairs the HEAD blob with the working tree', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const result = await read(repoDir, 'f.txt', true); + assert.equal(result.ok, true, result.error); + assert.equal(result.original, 'committed\n'); + assert.equal(result.current, 'worktree\n'); + } finally { cleanup(tmp); } +}); + +test('real git: a path absent from the tree is a new file — empty original, not an error (exit 128 is the untracked case)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'new.txt'), 'brand new\n'); + + // The exit code real git returns for a path that is not in the index, pinned. + const raw = spawnSync('git', ['cat-file', 'blob', ':new.txt'], { cwd: repoDir, encoding: 'utf8', env: scratchGitEnv() }); + assert.equal(raw.status, 128); + assert.match(raw.stderr, /not in the index/); + + const result = await read(repoDir, 'new.txt', false); + assert.equal(result.ok, true, result.error); + assert.equal(result.original, '', 'an untracked file has no original side'); + assert.equal(result.current, 'brand new\n'); + } finally { cleanup(tmp); } +}); + +test('real git: a session cwd below the repository root still resolves the root-relative paths git status reports', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, 'sub')); + fs.writeFileSync(path.join(repoDir, 'sub', 's.txt'), 'sub content\n'); + + const result = await readChangesFile({ cwd: path.join(repoDir, 'sub'), relPath: 'sub/s.txt', staged: false, maxBytes: MAX_BYTES }); + assert.equal(result.ok, true, result.error); + assert.equal(result.current, 'sub content\n'); + } finally { cleanup(tmp); } +}); + +// --- Why `git cat-file blob`, not `git show` ---------------------------- + +test('real git: `git show :/` prints a whole commit while `git cat-file blob` refuses it — the reason the blob is read type-constrained', () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const shown = spawnSync('git', ['show', ':/SECRETWORD'], { cwd: repoDir, encoding: 'utf8', env: scratchGitEnv() }); + assert.equal(shown.status, 0, 'git show resolves :/ as a commit-message search'); + assert.match(shown.stdout, /^commit [0-9a-f]{40}/, 'git show would hand the panel a commit object as "file content"'); + + const catFile = spawnSync('git', ['cat-file', 'blob', ':/SECRETWORD'], { cwd: repoDir, encoding: 'utf8', env: scratchGitEnv() }); + assert.notEqual(catFile.status, 0, 'cat-file blob refuses an object that is not a blob'); + assert.equal(catFile.stdout, ''); + + // The same asymmetry for a directory: a tree listing vs a refusal. + const showTree = spawnSync('git', ['show', 'HEAD:'], { cwd: repoDir, encoding: 'utf8', env: scratchGitEnv() }); + assert.equal(showTree.status, 0); + assert.match(showTree.stdout, /^tree HEAD:/); + const catTree = spawnSync('git', ['cat-file', 'blob', 'HEAD:'], { cwd: repoDir, encoding: 'utf8', env: scratchGitEnv() }); + assert.notEqual(catTree.status, 0); + } finally { cleanup(tmp); } +}); + +// --- Caps --------------------------------------------------------------- + +test('real git: a binary working-tree file is refused as binary, and says so', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'bin.dat'), Buffer.from([0, 1, 2, 0, 3, 255])); + + const result = await read(repoDir, 'bin.dat', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'binary', 'the panel has to tell a binary file apart from an oversized one'); + } finally { cleanup(tmp); } +}); + +test('real git: a file over the cap is refused as too large, and says so', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'big.txt'), 'x'.repeat(2048)); + + const result = await readChangesFile({ cwd: repoDir, relPath: 'big.txt', staged: false, maxBytes: 1024 }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'too-large'); + } finally { cleanup(tmp); } +}); + +test('real git: a blob over the cap is refused too, even when the working-tree side fits', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'shrunk.txt'), 'x'.repeat(4096)); + git(repoDir, ['add', 'shrunk.txt']); + fs.writeFileSync(path.join(repoDir, 'shrunk.txt'), 'tiny\n'); + + const result = await readChangesFile({ cwd: repoDir, relPath: 'shrunk.txt', staged: false, maxBytes: 1024 }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'too-large'); + } finally { cleanup(tmp); } +}); + +// --- Containment -------------------------------------------------------- + +const OUTSIDE_SECRET = 'outside secret\n'; + +function withOutsideFile(tmp) { + const secret = path.join(tmp, 'outside-secret.txt'); + fs.writeFileSync(secret, OUTSIDE_SECRET); + return secret; +} + +test('real git: a traversal out of the repository is refused by the read (mutation target: the containment check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + withOutsideFile(tmp); + + for (const relPath of ['../outside-secret.txt', '../../etc/passwd', '/etc/passwd', 'f\n.txt', '']) { + const result = await read(repoDir, relPath, false); + assert.equal(result.ok, false, `must refuse ${JSON.stringify(relPath)}`); + assert.ok(!('current' in result), 'no content may come back from a refused path'); + } + } finally { cleanup(tmp); } +}); + +test('real git: a symlink inside the repository pointing outside it is refused by both the read and the save (mutation target: resolving on disk)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const secret = withOutsideFile(tmp); + fs.symlinkSync(secret, path.join(repoDir, 'link.txt')); + + const readResult = await read(repoDir, 'link.txt', false); + assert.equal(readResult.ok, false, 'a symlinked escape must not read the file it points at'); + assert.equal(readResult.reason, 'outside'); + + const writeResult = await writeChangesFile({ cwd: repoDir, relPath: 'link.txt', content: 'overwritten\n', maxBytes: MAX_BYTES }); + assert.equal(writeResult.ok, false); + assert.equal(writeResult.reason, 'outside'); + assert.equal(fs.readFileSync(secret, 'utf8'), OUTSIDE_SECRET, 'the file outside the repository is untouched'); + } finally { cleanup(tmp); } +}); + +test('real git: a sensitive file inside the repository is refused even though it is contained', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, '.env'), 'TOKEN=secret\n'); + + const result = await read(repoDir, '.env', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'sensitive'); + } finally { cleanup(tmp); } +}); + +test('real git: a directory is refused rather than read', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, 'dir')); + + const result = await read(repoDir, 'dir', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'not-a-file'); + } finally { cleanup(tmp); } +}); + +test('real git: a directory that is not a repository is refused', async () => { + const tmp = mkTmp(); + try { + const plainDir = path.join(tmp, 'plain'); + fs.mkdirSync(plainDir); + fs.writeFileSync(path.join(plainDir, 'f.txt'), 'x\n'); + + const result = await read(plainDir, 'f.txt', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'repo'); + } finally { cleanup(tmp); } +}); + +// --- The save ----------------------------------------------------------- + +test('real git: a save writes the working-tree file and the next read sees it', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const result = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'edited\n', maxBytes: MAX_BYTES }); + assert.equal(result.ok, true, result.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'edited\n'); + + const after = await read(repoDir, 'f.txt', false); + assert.equal(after.current, 'edited\n'); + assert.equal(after.original, 'indexed\n', 'saving the working tree must not move the index'); + } finally { cleanup(tmp); } +}); + +test('real git: a save never creates a file that does not exist', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const result = await writeChangesFile({ cwd: repoDir, relPath: 'nope.txt', content: 'x\n', maxBytes: MAX_BYTES }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing'); + assert.equal(fs.existsSync(path.join(repoDir, 'nope.txt')), false); + } finally { cleanup(tmp); } +}); + +test('real git: a save refuses every adversarial path shape and writes nothing (mutation target: the path guard)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const secret = withOutsideFile(tmp); + + for (const relPath of ['../outside-secret.txt', '../../etc/passwd', secret, 'f\n.txt', '', ':(exclude)f.txt', '-rf']) { + const result = await writeChangesFile({ cwd: repoDir, relPath, content: 'pwned\n', maxBytes: MAX_BYTES }); + assert.equal(result.ok, false, `must refuse ${JSON.stringify(relPath)}`); + } + assert.equal(fs.readFileSync(secret, 'utf8'), OUTSIDE_SECRET); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'worktree\n'); + } finally { cleanup(tmp); } +}); + +test('real git: a save refuses content over the cap and non-string content', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const tooBig = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'x'.repeat(2048), maxBytes: 1024 }); + assert.equal(tooBig.ok, false); + assert.equal(tooBig.reason, 'too-large'); + + const notAString = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: null, maxBytes: MAX_BYTES }); + assert.equal(notAString.ok, false); + assert.equal(notAString.reason, 'invalid-content'); + + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'worktree\n'); + } finally { cleanup(tmp); } +}); diff --git a/test/git-changes-file.test.js b/test/git-changes-file.test.js new file mode 100644 index 00000000..7c6256cc --- /dev/null +++ b/test/git-changes-file.test.js @@ -0,0 +1,66 @@ +'use strict'; + +// Guards for the editable Changes panel's two IPCs (git-changes-file / +// git-changes-save). The `:` operand is a revision, not a +// pathspec, so it carries its own guard — see .ai/contexts/changes-view.md +// ("Editing a changed file"). + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + isSafeRepoRelativePath, + isSafeRevPathOperand, + buildBlobRev, +} = require('../git-changes-file'); + +const ADVERSARIAL = [ + ['', 'the empty string'], + ['../../etc/passwd', 'a traversal'], + ['a/../../b', 'a traversal in the middle'], + ['/etc/passwd', 'an absolute POSIX path'], + ['\\\\server\\share\\x', 'a UNC path'], + ['C:\\Windows\\win.ini', 'a Windows absolute path'], + ['src/a\nb.js', 'an embedded newline'], + ['src/a\rb.js', 'an embedded carriage return'], + ['src/a\u0000b.js', 'an embedded NUL'], + ['-rf', 'a leading dash (an option to git)'], + [':(exclude)src', 'pathspec magic'], + [':/etc/passwd', 'a leading colon'], +]; + +for (const [value, label] of ADVERSARIAL) { + test(`isSafeRepoRelativePath rejects ${label}`, () => { + assert.equal(isSafeRepoRelativePath(value), false, JSON.stringify(value)); + }); + test(`isSafeRevPathOperand rejects ${label}`, () => { + assert.equal(isSafeRevPathOperand(value), false, JSON.stringify(value)); + }); +} + +test('isSafeRepoRelativePath rejects a non-string and an over-long path', () => { + assert.equal(isSafeRepoRelativePath(null), false); + assert.equal(isSafeRepoRelativePath(42), false); + assert.equal(isSafeRepoRelativePath(undefined), false); + assert.equal(isSafeRepoRelativePath('a'.repeat(4097)), false); +}); + +test('isSafeRepoRelativePath accepts the ordinary paths git status reports', () => { + for (const p of ['a.txt', 'src/a.js', 'dir with spaces/b.md', 'café.txt', 'a$(b)`c`.txt', 'x.1']) { + assert.equal(isSafeRepoRelativePath(p), true, p); + assert.equal(isSafeRevPathOperand(p), true, p); + } +}); + +// `git show :1:f.txt` reads stage 1 of a conflicted path — a second layer of +// revision syntax hiding inside what the renderer called a file path. +test('isSafeRevPathOperand rejects the `::` stage syntax that isSafeRepoRelativePath alone allows', () => { + assert.equal(isSafeRepoRelativePath('1:f.txt'), true, 'a file literally named "1:f.txt" is a legal filesystem path'); + assert.equal(isSafeRevPathOperand('1:f.txt'), false); + assert.equal(isSafeRevPathOperand('23:f.txt'), false); +}); + +test('buildBlobRev names the index for the unstaged view and HEAD for the staged one', () => { + assert.equal(buildBlobRev('src/a.js', false), ':src/a.js'); + assert.equal(buildBlobRev('src/a.js', true), 'HEAD:src/a.js'); +}); From 3473a26a70fa6b9e207739b6da13158dd7ed078f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 16:20:54 +0200 Subject: [PATCH 02/29] feat(changes): edit a changed file in place, with the diff recomputed as you type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A changed file of a local session opens in a CodeMirror merge view over the content pair instead of an inert block of diff text: the original on the left, the working tree on the right, editable, with the diff recomputed on every keystroke by construction. One button cycles side-by-side, inline and plain; the choice persists under its own localStorage key. Ctrl/Cmd+S and a Save button write through git-changes-save and refresh the status so the row's counts follow. The render path stops being a teardown. The diff view's chrome is built once and the editor instance lives on the tab, keyed by path, staged flag and mode. The tab re-renders on every busy→idle edge, so rebuilding it would have destroyed the editor under the user's cursor once per turn the session finishes. An idle refresh arriving on a dirty buffer refreshes the file list only: the buffer is left alone and the panel says the view may be out of date. A remote session, a binary file and a file over the panel's size cap keep the read-only unified diff, with a line naming which of those it is. The merge view CSS now names both hosts in one rule list rather than being duplicated for the new one. --- .ai/contexts/changes-view.md | 32 ++- .ai/contexts/viewer-panel.md | 29 +- docs/changes-view.md | 17 +- git-changes-file.js | 10 + main.js | 6 +- public/file-panel.js | 319 +++++++++++++++++++-- public/style.css | 45 ++- test/dom-file-panel-changes.test.js | 372 ++++++++++++++++++++++++- test/git-changes-file-real-git.test.js | 27 ++ test/git-changes-file.test.js | 14 + 10 files changed, 822 insertions(+), 49 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 58434392..6cc628c5 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -1,8 +1,9 @@ # Context: changes-view -**Purpose**: A read-only, git-status-sourced view of a session's working -tree, in the same right-hand file panel IDE Emulation already uses — for -local and remote sessions alike. Issue #251. User-facing behavior: +**Purpose**: A git-status-sourced view of a session's working tree, in the +same right-hand file panel IDE Emulation already uses — for local and remote +sessions alike, and editable in place on a local one. Issue #251. +User-facing behavior: `docs/changes-view.md`. IPC names and the path-guard table entry: `.ai/contexts/ipc-bridge.md` ("Changes panel"). The panel's tab-type integration: `.ai/contexts/viewer-panel.md` ("Changes mode"). @@ -424,9 +425,30 @@ the only one. `file-panel.js` references `onSessionIdle` even though `session-activity.js` loads *after* it in `index.html` — safe because the reference lives inside `initFilePanel()`'s body, which only runs once `app.js` (the last script) calls it, by which point every script has already evaluated. Same reasoning `.ai/contexts/session-state.md` documents for `session-activity-dom.js`'s own out-of-order cross-file references. -## Renderer: why not `ViewerPanel` for the diff +## Renderer: two ways to show a file -`public/file-panel.js`'s Changes mode is a third tab type (`'changes'`), alongside the pre-existing `'file'` and `'diff'` (MCP) types, on the same per-session `filePanelState` — opening one replaces whatever the other was showing. It does not route the diff through `ViewerPanel`'s CodeMirror editor or the MCP diff tab's merge-view: both expect an old/new content pair, and a `git diff` result is a unified-diff text blob. The bundled CodeMirror also has no diff/patch language mode to color it with. The fallback is deliberately plain: one `
` per line, classed by its `+`/`-`/`@@` prefix (`classifyDiffLine()`), set via `textContent` (no HTML injection risk from diff content, which can contain arbitrary user code). +`public/file-panel.js`'s Changes mode is a third tab type (`'changes'`), alongside the pre-existing `'file'` and `'diff'` (MCP) types, on the same per-session `filePanelState`. Opening one replaces whatever the other was showing. It does not route anything through `ViewerPanel`, which owns one file and one path; a Changes tab owns a list, a selection, and a session. + +A **local** session's selected file is a live editor over the content pair from `git-changes-file` — `createMergeViewer` (side-by-side, original read-only on the left, working tree editable on the right), `createUnifiedMergeViewer` (inline) or `createEditableViewer` (plain, no diff decoration), cycled by one button and persisted under `localStorage.changesDiffMode`. CodeMirror recomputes the diff on every keystroke by construction, so "live update" is a property of using the merge view at all, not a feature built on top of it. + +A **remote** session, and any file the main process refuses to open for editing (binary, over the cap, outside the repository), fall back to the unified-diff text from `git-changes-diff`: one `
` per line, classed by its `+`/`-`/`@@` prefix (`classifyDiffLine()`), set via `textContent` (no HTML injection risk from diff content, which can contain arbitrary user code). The bundled CodeMirror has no diff/patch language mode to colour that blob with, which is why the fallback is deliberately plain. The panel says which of the two it is in, in its notice line, and the refusal's `reason` is what that line reports. + +### The render path is not a teardown + +The Changes tab re-renders on every busy→idle edge (see "Refresh triggers"), so a render that rebuilt its own DOM would destroy the editor under the user's cursor and discard unsaved edits once per turn the session finishes. Two rules prevent that: + +- The diff view's chrome — title, Back, mode button, Save, notice line, editor host — is **built once**, in `initFilePanel()`. A render updates text and visibility; it never clears `#changes-diff-view`. +- The editor instance lives on the tab (`tab.editorView`, keyed by `tab.editorKey` = path + staged, and `tab.editorMode`) and is **reused** whenever those still match. It is destroyed on Back, on closing the tab, on a mode change, and when a clean buffer is reloaded — nowhere else. `destroyCurrentTab()` carries a `'changes'` branch for the same reason the `'diff'` branch exists. + +Both are pinned by tests that go red if a render clears the host (a `MutationObserver` on the host records zero child mutations across an idle refresh) or rebuilds the instance. + +### A dirty buffer is never overwritten + +An idle refresh reloads the file list unconditionally — the counts must follow what the session did. The open editor is a different matter: when its content differs from the last content known on disk (`tab.savedContent`), the refresh does **not** re-read the file and does not touch the buffer. It marks the tab stale, and the notice line says the view may be out of date while the unsaved edits are kept. A clean buffer is re-read, and replaced only when the content actually differs. + +Saving is `gitChangesSave(sessionId, path, content)` from the Save button or from the `cm-save` event the bundle dispatches for `Cmd/Ctrl+S`; on success it refreshes the status so the row's counts follow the write. The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. + +An untracked file's added-line count comes from the pair rather than a second git call: its original side is empty, so its additions are its own lines (`countAddedLines`). The read-only fallback still takes the count from the diff (`countNewFileDiffAdditions`). ## What's untested for remote diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 065d71a3..c15a5229 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -78,9 +78,36 @@ The toolbar factory builds all configured buttons up front; `open()` toggles vis `public/file-panel.js`'s side panel gained a third tab type, `'changes'`, alongside the pre-existing `'file'` and `'diff'` (MCP) types on the same per-session `filePanelState`. Full design (why it skips `ViewerPanel`, the -entry point, the no-polling refresh trigger): `.ai/contexts/changes-view.md`. +entry point, the no-polling refresh trigger, editing): `.ai/contexts/changes-view.md`. User-facing behavior: `docs/changes-view.md`. +A local session's selected file is edited in one of the same CodeMirror views +this component builds its own editors from — `createMergeViewer` (default), +`createUnifiedMergeViewer` or `createEditableViewer`, picked by a three-way +mode button and persisted under `localStorage.changesDiffMode` (the MCP diff +tab's `filePanelDiffMode` is a separate key with a separate meaning). Three +things about that editor are not `ViewerPanel`'s: + +- **The tab owns the instance, not the panel.** It lives on `tab.editorView` + the way the MCP `'diff'` tab's does, keyed by path + staged + mode, and a + re-render reuses it instead of rebuilding — the Changes tab re-renders on + every busy→idle edge, which would otherwise land on the user's cursor. + `destroyCurrentTab()` and `closeChangesDiff()` are what end its life. +- **Reading the buffer back is asymmetric.** A side-by-side `MergeView` is read + from `view.b.state.doc`, the inline and plain views from `view.state.doc` — + the same asymmetry `handleDiffAction` already navigates for the MCP tab. +- **The save is an IPC by session, not by path**: `gitChangesSave(sessionId, + repoRelativePath, content)`, not `saveFileForPanel`. The renderer never holds + an absolute path for a Changes row. + +`Cmd/Ctrl+S` arrives as the same `cm-save` DOM event the bundle dispatches, and +the listener sits on `#changes-diff-view`, which is where `ViewerPanel` puts +its own (on its container). + +The merge-view CSS in `public/style.css` is written for two hosts in one rule +list — `#file-panel-body` (MCP tab) and `#changes-diff-host` (Changes tab). +A new host means a new selector in those groups, not a copied block. + ## Gotchas - **CodeMirror state holds DOM references** — calling `destroy()` then immediately `open()` on the SAME container works because `_createEditor` rebuilds it, but if you reorder this, the editor can dangle. diff --git a/docs/changes-view.md b/docs/changes-view.md index 335547ec..f5a17e26 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -1,6 +1,6 @@ # Changes View -**Changes** is a read-only, git-status-sourced view of a session's working tree, shown in the same right-hand side panel as [IDE Emulation](ide-emulation.md)'s file/diff tabs. It exists because IDE-mode sessions never get the CLI's own `/diff` pane — Switchboard impersonates the IDE, and the IDE protocol never pushes "these files changed", only per-file diffs at permission time. A remote session shows `/diff` inside its terminal, but that view scrolls away with the session and isn't clickable from Switchboard. Changes gives both kinds the same panel. +**Changes** is a git-status-sourced view of a session's working tree — and, for a local session, an editor for the files in it — shown in the same right-hand side panel as [IDE Emulation](ide-emulation.md)'s file/diff tabs. It exists because IDE-mode sessions never get the CLI's own `/diff` pane — Switchboard impersonates the IDE, and the IDE protocol never pushes "these files changed", only per-file diffs at permission time. A remote session shows `/diff` inside its terminal, but that view scrolls away with the session and isn't clickable from Switchboard. Changes gives both kinds the same panel. ## Opening it @@ -10,7 +10,7 @@ Click the **Changes** button in the terminal header, next to the stop button. Cl - A header line: `N files changed +A −B`, plus the current branch and how far it is ahead/behind its upstream. - One row per changed file: a state letter (`M` modified, `A` added, `D` deleted, `R`/`C` renamed/copied, `?` untracked), its path, and its own `+added −deleted` line counts. -- Clicking a row opens a read-only diff for that file, including an untracked one — a brand-new file shows up as an all-additions diff. A binary file shows a one-line note instead of its bytes. +- Clicking a row opens that file's diff, including an untracked one — a brand-new file shows up as an all-additions diff. A binary file shows a one-line note instead of its bytes. - A brand-new directory is listed file by file, not as a single folder row. - A **Refresh** button for a manual pull. @@ -37,6 +37,16 @@ refresh (and one ssh round-trip each, for a remote session), which a repo with a large untracked tree would feel. Refreshing resets them, since the files may have changed since. +## Editing a file + +On a local session, the open file is a live editor, not a picture of a diff. Type on the right-hand side and the diff recomputes as you go. + +- **Save** with the Save button or `Ctrl/Cmd+S`. The file list refreshes on save, so the row's counts follow what you wrote. +- The button next to Back cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. +- The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. +- If the session writes to files while you have unsaved edits, the list refreshes but your buffer is left alone, with a note that the view may be out of date. Nothing you typed is thrown away without you. +- A remote session, a binary file, and a file over 2 MB stay read-only, and the panel says which of those it is. + ## A shell under the list **Shell** in the terminal header opens a shell in the same panel, below the @@ -48,7 +58,8 @@ sets how much room each gets. Local sessions only — see ## What it doesn't do -- No staging, committing, or reverting from the UI — this is a viewer, not a git client. +- No staging, committing, or reverting from the UI — you can type in it, but it is not a git client. +- No creating, deleting or renaming files, and no editing on a remote session. - It doesn't replace the CLI's `/diff` pane in a non-IDE session; the two coexist. - IDE mode itself is not available for remote sessions (that's a separate, larger feature — an `ssh -R` tunnel plus a lock file on the host); Changes does not depend on it and works today for both local and remote sessions. diff --git a/git-changes-file.js b/git-changes-file.js index a59fb0a4..e86c18f5 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -88,6 +88,15 @@ function resolveTargetInsideRepo(repoRoot, relPath, deps) { return { ok: true, path: real, size: stat.size, repoRoot: realRoot }; } +// There is no file-write path to a remote host anywhere in this app — see .ai/contexts/changes-view.md +function requireLocalTarget(target) { + if (!target || target.ok !== true) return target; + if (target.kind !== 'local') { + return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; + } + return target; +} + async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { const fs = deps.fs || realFs; if (!isSafeRevPathOperand(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; @@ -141,6 +150,7 @@ async function writeChangesFile({ cwd, relPath, content, maxBytes }, deps = {}) module.exports = { readChangesFile, writeChangesFile, + requireLocalTarget, resolveTargetInsideRepo, resolveRepoRoot, isSafeRepoRelativePath, diff --git a/main.js b/main.js index 3ce8b301..11aba74d 100644 --- a/main.js +++ b/main.js @@ -1781,9 +1781,8 @@ ipcMain.handle('git-changes-diff', async (_event, sessionId, filePath, staged, u // filePath is a repo-relative path, resolved and contained main-side — see .ai/contexts/changes-view.md ("Editing a changed file") ipcMain.handle('git-changes-file', async (_event, sessionId, filePath, opts) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; - const target = resolveGitChangesTarget(sessionId); + const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); if (!target.ok) return target; - if (target.kind !== 'local') return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; try { return await gitChangesFile.readChangesFile({ cwd: target.cwd, @@ -1798,9 +1797,8 @@ ipcMain.handle('git-changes-file', async (_event, sessionId, filePath, opts) => ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; - const target = resolveGitChangesTarget(sessionId); + const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); if (!target.ok) return target; - if (target.kind !== 'local') return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; try { const result = await gitChangesFile.writeChangesFile({ cwd: target.cwd, diff --git a/public/file-panel.js b/public/file-panel.js index d0aa15f0..d8be08ae 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -39,6 +39,11 @@ let changesSummaryEl = null; let changesListEl = null; let changesDiffEl = null; let changesToggleBtn = null; +let changesDiffTitleEl = null; +let changesDiffModeBtn = null; +let changesDiffSaveBtn = null; +let changesDiffNoticeEl = null; +let changesDiffHostEl = null; // Row ceiling for the Changes list — see .ai/contexts/changes-view.md ("Untracked files") const MAX_CHANGES_ROWS = 500; @@ -50,6 +55,13 @@ const MIN_PANEL_WIDTH = 280; const DIFF_MODE_KEY = 'filePanelDiffMode'; let diffMode = localStorage.getItem(DIFF_MODE_KEY) || 'side-by-side'; +const CHANGES_DIFF_MODE_KEY = 'changesDiffMode'; +const CHANGES_DIFF_MODES = ['side-by-side', 'inline', 'plain']; +const CHANGES_DIFF_MODE_LABELS = { 'side-by-side': 'Side-by-side', inline: 'Inline', plain: 'Plain' }; +let changesDiffMode = CHANGES_DIFF_MODES.includes(localStorage.getItem(CHANGES_DIFF_MODE_KEY)) + ? localStorage.getItem(CHANGES_DIFF_MODE_KEY) + : 'side-by-side'; + // ── Initialization ────────────────────────────────────────────────── function initFilePanel() { @@ -141,7 +153,7 @@ function initFilePanel() { diffActionsEl.style.display = 'none'; diffContainer.appendChild(diffActionsEl); - // ── Changes mode (issue #251, git-status-sourced, read-only) ── + // ── Changes mode (issue #251, git-status-sourced) ── changesContainerEl = document.createElement('div'); changesContainerEl.id = 'file-panel-changes'; changesContainerEl.style.display = 'none'; @@ -196,6 +208,7 @@ function initFilePanel() { changesDiffEl.style.display = 'none'; changesContainerEl.appendChild(changesDiffEl); + buildChangesDiffChrome(); // Shell region below every tab type — see .ai/contexts/panel-terminal.md if (typeof initPanelTerminal === 'function') initPanelTerminal(filePanelContentEl); @@ -233,6 +246,9 @@ function handleClose() { tab.editorView.destroy(); tab.editorView = null; } + if (tab.type === 'changes') { + destroyChangesEditor(tab); + } if (tab.type === 'file') { fpViewerPanel.destroy(); } @@ -387,6 +403,9 @@ function destroyCurrentTab(state) { delete diffBodyEl._cmGotoLine; } } + if (tab.type === 'changes') { + destroyChangesEditor(tab); + } if (tab.type === 'file') { fpViewerPanel.destroy(); } @@ -624,11 +643,23 @@ function openChangesTab(sessionId) { loading: true, error: null, data: null, + remote: false, selectedFile: null, diffLoading: false, diffError: null, diffContent: null, diffTruncated: false, + editable: false, + original: null, + current: null, + savedContent: null, + editorView: null, + editorKey: null, + editorMode: null, + editorPending: null, + fallbackReason: null, + saveError: null, + stale: false, }; state.panelVisible = true; @@ -660,8 +691,36 @@ async function refreshChanges(sessionId) { } else { tab.error = null; tab.data = result; + tab.remote = result.kind === 'remote'; } if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return syncOpenChangesFile(sessionId, tab); +} + +// A refresh may never replace what the user is typing into — see .ai/contexts/changes-view.md +async function syncOpenChangesFile(sessionId, tab) { + if (!tab.selectedFile || !tab.editable || !tab.editorView) return; + + if (isChangesBufferDirty(tab)) { + tab.stale = true; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + + const file = tab.selectedFile; + const result = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); + + const stillState = filePanelState.get(sessionId); + if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; + if (isChangesBufferDirty(tab)) return; + if (!result || result.ok === false) return; + if (result.current === tab.current && result.original === tab.original) return; + + tab.original = result.original; + tab.current = result.current; + tab.savedContent = result.current; + destroyChangesEditor(tab); + if (currentPanelSessionId === sessionId) renderPanel(sessionId); } async function openChangesDiff(sessionId, file) { @@ -674,10 +733,37 @@ async function openChangesDiff(sessionId, file) { tab.diffContent = null; tab.diffTruncated = false; const dataAtRequest = tab.data; + tab.editable = false; + tab.original = null; + tab.current = null; + tab.savedContent = null; + tab.fallbackReason = null; + tab.saveError = null; + tab.stale = false; + destroyChangesEditor(tab); tab.diffLoading = true; if (currentPanelSessionId === sessionId) renderPanel(sessionId); + if (!tab.remote) { + const pair = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); + + const pairState = filePanelState.get(sessionId); + if (!pairState || pairState.currentTab !== tab || tab.selectedFile !== file) return; + + if (pair && pair.ok) { + tab.diffLoading = false; + tab.editable = true; + tab.original = pair.original; + tab.current = pair.current; + tab.savedContent = pair.current; + if (file.untracked) applyUntrackedCounts(tab, file.path, countAddedLines(pair.current), 0); + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + tab.fallbackReason = describeFallback(pair); + } + const result = await window.api.gitChangesDiff(sessionId, file.path, file.staged, file.untracked); const stillState = filePanelState.get(sessionId); @@ -694,6 +780,20 @@ async function openChangesDiff(sessionId, file) { if (currentPanelSessionId === sessionId) renderPanel(sessionId); } +function describeFallback(pair) { + if (!pair) return 'this file could not be opened for editing'; + if (pair.reason === 'binary') return 'binary file'; + if (pair.reason === 'too-large') return 'file too large to edit'; + return pair.error || 'this file could not be opened for editing'; +} + +function countAddedLines(content) { + if (!content) return 0; + const lines = content.split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + return lines.length; +} + // Untracked counts arrive with the diff, not with status — see .ai/contexts/changes-view.md function applyUntrackedCounts(tab, expectedData, filePath, added, deleted) { if (typeof added !== 'number') return; @@ -716,9 +816,18 @@ function applyUntrackedCounts(tab, expectedData, filePath, added, deleted) { function closeChangesDiff(sessionId) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; - state.currentTab.selectedFile = null; - state.currentTab.diffContent = null; - state.currentTab.diffError = null; + const tab = state.currentTab; + destroyChangesEditor(tab); + tab.selectedFile = null; + tab.diffContent = null; + tab.diffError = null; + tab.editable = false; + tab.original = null; + tab.current = null; + tab.savedContent = null; + tab.fallbackReason = null; + tab.saveError = null; + tab.stale = false; if (currentPanelSessionId === sessionId) renderPanel(sessionId); } @@ -826,31 +935,79 @@ function buildChangesFileRow(sessionId, file) { return row; } -function renderChangesDiff(sessionId, tab) { - changesDiffEl.innerHTML = ''; - +// Built once: a render must never tear the open editor down — see .ai/contexts/changes-view.md +function buildChangesDiffChrome() { const header = document.createElement('div'); header.className = 'viewer-toolbar'; const info = document.createElement('div'); info.className = 'viewer-toolbar-info'; - const titleEl = document.createElement('span'); - titleEl.className = 'viewer-toolbar-title'; - titleEl.textContent = tab.selectedFile.path; - info.appendChild(titleEl); + changesDiffTitleEl = document.createElement('span'); + changesDiffTitleEl.className = 'viewer-toolbar-title'; + changesDiffTitleEl.id = 'changes-diff-path'; + info.appendChild(changesDiffTitleEl); header.appendChild(info); const controls = document.createElement('div'); controls.className = 'viewer-toolbar-controls'; + const backBtn = document.createElement('button'); backBtn.className = 'fp-toolbar-btn'; backBtn.textContent = 'Back'; - backBtn.addEventListener('click', () => closeChangesDiff(sessionId)); + backBtn.addEventListener('click', () => { + if (currentPanelSessionId) closeChangesDiff(currentPanelSessionId); + }); controls.appendChild(backBtn); - header.appendChild(controls); + changesDiffModeBtn = document.createElement('button'); + changesDiffModeBtn.className = 'fp-toolbar-btn'; + changesDiffModeBtn.id = 'changes-diff-mode-btn'; + changesDiffModeBtn.addEventListener('click', handleChangesDiffModeToggle); + controls.appendChild(changesDiffModeBtn); + + changesDiffSaveBtn = document.createElement('button'); + changesDiffSaveBtn.className = 'fp-toolbar-btn fp-save-btn'; + changesDiffSaveBtn.id = 'changes-diff-save-btn'; + changesDiffSaveBtn.textContent = 'Save'; + changesDiffSaveBtn.title = 'Save this file (Ctrl/Cmd+S)'; + changesDiffSaveBtn.addEventListener('click', () => { + if (currentPanelSessionId) handleChangesSave(currentPanelSessionId); + }); + controls.appendChild(changesDiffSaveBtn); + + header.appendChild(controls); changesDiffEl.appendChild(header); + changesDiffNoticeEl = document.createElement('div'); + changesDiffNoticeEl.id = 'changes-diff-notice'; + changesDiffNoticeEl.style.display = 'none'; + changesDiffEl.appendChild(changesDiffNoticeEl); + + changesDiffHostEl = document.createElement('div'); + changesDiffHostEl.id = 'changes-diff-host'; + changesDiffEl.appendChild(changesDiffHostEl); + + changesDiffEl.addEventListener('cm-save', () => { + if (currentPanelSessionId) handleChangesSave(currentPanelSessionId); + }); +} + +function renderChangesDiff(sessionId, tab) { + changesDiffTitleEl.textContent = tab.selectedFile.path; + + changesDiffModeBtn.style.display = tab.editable ? '' : 'none'; + changesDiffModeBtn.textContent = CHANGES_DIFF_MODE_LABELS[changesDiffMode]; + changesDiffModeBtn.title = 'Diff view mode — click to cycle'; + changesDiffSaveBtn.style.display = tab.editable ? '' : 'none'; + + renderChangesNotice(tab); + + if (tab.editable) { + ensureChangesEditor(sessionId, tab); + return; + } + + destroyChangesEditor(tab); const body = document.createElement('pre'); body.className = 'changes-diff-body'; @@ -864,14 +1021,138 @@ function renderChangesDiff(sessionId, tab) { } else { renderDiffLines(body, tab.diffContent); } - changesDiffEl.appendChild(body); - if (tab.diffTruncated) { - const note = document.createElement('div'); - note.className = 'changes-diff-truncated'; - note.textContent = 'Diff truncated at 512 KB.'; - changesDiffEl.appendChild(note); + changesDiffHostEl.innerHTML = ''; + changesDiffHostEl.appendChild(body); +} + +function renderChangesNotice(tab) { + const notes = []; + if (tab.remote) notes.push('Remote session — read-only.'); + if (tab.fallbackReason) notes.push(`${tab.fallbackReason} — showing the diff read-only.`); + if (tab.diffTruncated) notes.push('Diff truncated at 512 KB.'); + if (tab.stale) notes.push('This session changed files while you were editing — your unsaved edits are kept, the diff may be out of date.'); + if (tab.saveError) notes.push(`Save failed: ${tab.saveError}`); + + changesDiffNoticeEl.textContent = notes.join(' '); + changesDiffNoticeEl.style.display = notes.length ? '' : 'none'; + changesDiffNoticeEl.classList.toggle('changes-error', !!tab.saveError); + changesDiffNoticeEl.classList.toggle('changes-diff-truncated', !tab.saveError); +} + +// see .ai/contexts/changes-view.md ("The render path is not a teardown") +function ensureChangesEditor(sessionId, tab) { + const key = changesEditorKey(tab); + if (tab.editorView && tab.editorKey === key && tab.editorMode === changesDiffMode) { + if (tab.editorView.dom.parentNode !== changesDiffHostEl) changesDiffHostEl.appendChild(tab.editorView.dom); + return; } + if (tab.editorPending === key + '' + changesDiffMode) return; + + destroyChangesEditor(tab); + changesDiffHostEl.innerHTML = ''; + + const token = key + '' + changesDiffMode; + const mode = changesDiffMode; + tab.editorPending = token; + + window.loadCodeMirrorBundle().then(() => { + const state = filePanelState.get(sessionId); + if (!state || state.currentTab !== tab || tab.editorPending !== token) return; + tab.editorPending = null; + if (!tab.selectedFile || !tab.editable) return; + + const filename = tab.selectedFile.path; + if (mode === 'plain') { + tab.editorView = window.createEditableViewer(changesDiffHostEl, tab.current, filename); + } else if (mode === 'inline') { + tab.editorView = window.createUnifiedMergeViewer(changesDiffHostEl, tab.original, tab.current, filename); + } else { + tab.editorView = window.createMergeViewer(changesDiffHostEl, tab.original, tab.current, filename); + } + tab.editorKey = key; + tab.editorMode = mode; + }).catch((err) => { + tab.editorPending = null; + console.error('[file-panel] Failed to load codemirror-bundle:', err); + }); +} + +function changesEditorKey(tab) { + const file = tab.selectedFile; + return file ? file.path + '' + (file.staged ? '1' : '0') : ''; +} + +function destroyChangesEditor(tab) { + tab.editorPending = null; + if (!tab.editorView) return; + try { tab.editorView.destroy(); } catch {} + tab.editorView = null; + tab.editorKey = null; + tab.editorMode = null; + if (changesDiffHostEl) { + delete changesDiffHostEl._cmSearchBar; + delete changesDiffHostEl._cmGotoLine; + } +} + +// see .ai/contexts/changes-view.md ("A dirty buffer is never overwritten") +function readChangesEditorContent(tab) { + const view = tab.editorView; + if (!view) return null; + if (tab.editorMode === 'side-by-side') { + return view.b ? view.b.state.doc.toString() : null; + } + return view.state ? view.state.doc.toString() : null; +} + +function isChangesBufferDirty(tab) { + const content = readChangesEditorContent(tab); + return content != null && content !== tab.savedContent; +} + +function handleChangesDiffModeToggle() { + const next = (CHANGES_DIFF_MODES.indexOf(changesDiffMode) + 1) % CHANGES_DIFF_MODES.length; + changesDiffMode = CHANGES_DIFF_MODES[next]; + localStorage.setItem(CHANGES_DIFF_MODE_KEY, changesDiffMode); + + if (!currentPanelSessionId) return; + const state = filePanelState.get(currentPanelSessionId); + const tab = state && state.currentTab; + if (!tab || tab.type !== 'changes') return; + + const pending = readChangesEditorContent(tab); + if (pending != null) tab.current = pending; + destroyChangesEditor(tab); + renderPanel(currentPanelSessionId); +} + +async function handleChangesSave(sessionId) { + const state = filePanelState.get(sessionId); + const tab = state && state.currentTab; + if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; + + const content = readChangesEditorContent(tab); + if (content == null) return; + + const file = tab.selectedFile; + const result = await window.api.gitChangesSave(sessionId, file.path, content); + + const stillState = filePanelState.get(sessionId); + if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; + + if (!result || result.ok === false) { + tab.saveError = (result && result.error) || 'failed to save'; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + + tab.saveError = null; + tab.stale = false; + tab.current = content; + tab.savedContent = content; + if (typeof window.flashButtonText === 'function') window.flashButtonText(changesDiffSaveBtn, 'Saved!'); + return refreshChanges(sessionId); } // see .ai/contexts/changes-view.md ("why not ViewerPanel for the diff") diff --git a/public/style.css b/public/style.css index 84fce41a..866f5c04 100644 --- a/public/style.css +++ b/public/style.css @@ -4411,18 +4411,23 @@ body { display: flex; flex-direction: column; } background: rgba(120,130,255,0.10); } -#file-panel-body { +/* Editor hosts: the MCP diff tab and the Changes panel's editable diff share + every rule below — a merge view looks the same wherever it is mounted. */ +#file-panel-body, +#changes-diff-host { flex: 1; min-height: 0; position: relative; overflow: hidden; } -#file-panel-body .cm-editor { +#file-panel-body .cm-editor, +#changes-diff-host .cm-editor { height: 100%; } -#file-panel-body .cm-merge-view { +#file-panel-body .cm-merge-view, +#changes-diff-host .cm-merge-view { height: 100%; overflow: auto; font-size: 12.5px; @@ -4430,18 +4435,24 @@ body { display: flex; flex-direction: column; } /* Diff line backgrounds */ #file-panel-body .cm-merge-a .cm-changedLine, -#file-panel-body .cm-deletedChunk { +#file-panel-body .cm-deletedChunk, +#changes-diff-host .cm-merge-a .cm-changedLine, +#changes-diff-host .cm-deletedChunk { background-color: rgba(248, 81, 73, 0.12) !important; } -#file-panel-body .cm-merge-b .cm-changedLine { +#file-panel-body .cm-merge-b .cm-changedLine, +#changes-diff-host .cm-merge-b .cm-changedLine { background-color: rgba(63, 185, 80, 0.12) !important; } /* Inner word-level change highlighting (replace underlines with backgrounds) */ #file-panel-body .cm-merge-a .cm-changedText, -#file-panel-body .cm-deletedChunk .cm-deletedText { +#file-panel-body .cm-deletedChunk .cm-deletedText, +#changes-diff-host .cm-merge-a .cm-changedText, +#changes-diff-host .cm-deletedChunk .cm-deletedText { background: rgba(248, 81, 73, 0.35) !important; } -#file-panel-body .cm-merge-b .cm-changedText { +#file-panel-body .cm-merge-b .cm-changedText, +#changes-diff-host .cm-merge-b .cm-changedText { background: rgba(63, 185, 80, 0.35) !important; } @@ -4655,6 +4666,26 @@ body { display: flex; flex-direction: column; } border-top: 1px solid rgba(255,255,255,0.06); } +#changes-diff-notice { + padding: 6px 12px; + font-size: 11px; + color: #9090a8; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +#changes-diff-notice.changes-error { + color: #e05070; +} + +#changes-diff-host { + display: flex; + flex-direction: column; +} + +#changes-diff-host .changes-diff-body { + min-height: 0; +} + /* --- Diagnostics: activity trace --- */ #activity-trace-viewer { position: absolute; diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index b98d18b7..c2523e19 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -40,6 +40,7 @@ function evalInWindow(dom, file) { function makeStatusResult(overrides = {}) { return { ok: true, + kind: 'local', branch: { head: 'main', upstream: 'origin/main', ahead: 1, behind: 0 }, files: [ { path: 'src/a.js', origPath: null, staged: true, unstaged: false, untracked: false, renamed: false, state: 'M', added: 3, deleted: 1 }, @@ -50,11 +51,36 @@ function makeStatusResult(overrides = {}) { }; } -function setupFilePanelDom({ statusImpl, diffImpl } = {}) { +const DEFAULT_PAIR = { ok: true, original: 'old\n', current: 'new\n', binary: false, truncated: false }; + +// A stand-in for a CodeMirror view: it owns a DOM node, reports a document +// the test can rewrite (typing), and records its own destruction — enough for +// the reuse, dirty-buffer and save paths, none of the bundle. +function makeEditorStub(window, mode, doc, created) { + const dom = window.document.createElement('div'); + dom.className = 'fake-editor fake-editor-' + mode; + const box = { text: doc, destroyed: false, mode }; + const docSide = { state: { doc: { toString: () => box.text } } }; + const view = { + dom, + box, + destroy() { + box.destroyed = true; + if (dom.parentNode) dom.parentNode.removeChild(dom); + }, + }; + if (mode === 'side-by-side') view.b = docSide; + else view.state = docSide.state; + created.push(view); + return view; +} + +function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl } = {}) { const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; - const calls = { status: [], diff: [] }; + const calls = { status: [], diff: [], file: [], save: [] }; + const editors = []; window.api = { onMcpOpenDiff: () => {}, @@ -69,6 +95,36 @@ function setupFilePanelDom({ statusImpl, diffImpl } = {}) { calls.diff.push({ sessionId, filePath, staged, untracked }); return Promise.resolve((diffImpl || (() => ({ ok: true, content: '@@ -1 +1 @@\n-old\n+new\n context\n', truncated: false })))(sessionId, filePath, staged, untracked)); }, + gitChangesFile: (sessionId, filePath, opts) => { + calls.file.push({ sessionId, filePath, staged: !!(opts && opts.staged) }); + return Promise.resolve((fileImpl || (() => DEFAULT_PAIR))(sessionId, filePath, opts)); + }, + gitChangesSave: (sessionId, filePath, content) => { + calls.save.push({ sessionId, filePath, content }); + return Promise.resolve((saveImpl || (() => ({ ok: true })))(sessionId, filePath, content)); + }, + }; + + // file-panel.js defers every editor to the lazy bundle loader; the suite + // stands in for both the loader and the factories it would provide. + window.loadCodeMirrorBundle = () => Promise.resolve(); + window.createMergeViewer = (parent, original, modified, filename) => { + const view = makeEditorStub(window, 'side-by-side', modified, editors); + view.opened = { original, modified, filename }; + parent.appendChild(view.dom); + return view; + }; + window.createUnifiedMergeViewer = (parent, original, modified, filename) => { + const view = makeEditorStub(window, 'inline', modified, editors); + view.opened = { original, modified, filename }; + parent.appendChild(view.dom); + return view; + }; + window.createEditableViewer = (parent, content, filename) => { + const view = makeEditorStub(window, 'plain', content, editors); + view.opened = { original: null, modified: content, filename }; + parent.appendChild(view.dom); + return view; }; Object.defineProperty(window, 'ViewerPanel', { @@ -92,14 +148,33 @@ function setupFilePanelDom({ statusImpl, diffImpl } = {}) { window, document: window.document, calls, + editors, setActivity: read('setActivity'), destroy: () => window.close(), }; } function flush() { - // Two microtask turns: one for the IPC promise, one for whatever chains off it. - return Promise.resolve().then(() => Promise.resolve()); + // Four microtask turns: the status IPC, the content-pair IPC chained off it, + // the bundle loader, and whatever chains off that. + return Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()).then(() => Promise.resolve()); +} + +function clickRow(ctx, filePath) { + ctx.document.querySelector(`.changes-file-row[data-path="${filePath}"]`) + .dispatchEvent(new ctx.window.Event('click', { bubbles: true })); +} + +function backBtn(ctx) { + return Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find((b) => b.textContent === 'Back'); +} + +async function openFile(ctx, sessionId, filePath) { + ctx.window.switchPanel(sessionId); + await ctx.window.openChangesTab(sessionId); + await flush(); + clickRow(ctx, filePath); + await flush(); } // --- Rendering --------------------------------------------------------- @@ -141,8 +216,10 @@ test('an error from gitChangesStatus renders as an error message, not a crash', } finally { ctx.destroy(); } }); -test('clicking a file row opens a read-only diff colored by line prefix', async () => { - const ctx = setupFilePanelDom(); +const REMOTE_STATUS = () => makeStatusResult({ kind: 'remote' }); + +test('clicking a file row on a remote session opens a read-only diff colored by line prefix', async () => { + const ctx = setupFilePanelDom({ statusImpl: REMOTE_STATUS }); try { ctx.window.switchPanel('s1'); await ctx.window.openChangesTab('s1'); @@ -152,6 +229,7 @@ test('clicking a file row opens a read-only diff colored by line prefix', async row.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); await flush(); + assert.equal(ctx.calls.file.length, 0, 'a remote session never asks for an editable content pair'); assert.equal(ctx.calls.diff.length, 1); assert.deepEqual(ctx.calls.diff[0], { sessionId: 's1', filePath: 'src/a.js', staged: true, untracked: false }); @@ -179,7 +257,7 @@ const UNTRACKED_DIFF_RESULT = { }; test('clicking an untracked file fetches its diff like any other row, flagged untracked (mutation target: the old short-circuit)', async () => { - const ctx = setupFilePanelDom({ diffImpl: () => UNTRACKED_DIFF_RESULT }); + const ctx = setupFilePanelDom({ statusImpl: REMOTE_STATUS, diffImpl: () => UNTRACKED_DIFF_RESULT }); try { ctx.window.switchPanel('s1'); await ctx.window.openChangesTab('s1'); @@ -202,7 +280,7 @@ test('clicking an untracked file fetches its diff like any other row, flagged un }); test('an untracked file\'s counts and the header totals pick up the additions its diff reported', async () => { - const ctx = setupFilePanelDom({ diffImpl: () => UNTRACKED_DIFF_RESULT }); + const ctx = setupFilePanelDom({ statusImpl: REMOTE_STATUS, diffImpl: () => UNTRACKED_DIFF_RESULT }); try { ctx.window.switchPanel('s1'); await ctx.window.openChangesTab('s1'); @@ -230,7 +308,10 @@ test('an untracked file\'s counts and the header totals pick up the additions it test('an untracked binary file keeps null counts — the row stays countless and the totals do not move', async () => { const binary = { ok: true, content: 'diff --git a/new.txt b/new.txt\nBinary files /dev/null and b/new.txt differ\n', truncated: false, added: null, deleted: null }; - const ctx = setupFilePanelDom({ diffImpl: () => binary }); + const ctx = setupFilePanelDom({ + fileImpl: () => ({ ok: false, error: 'binary file', reason: 'binary' }), + diffImpl: () => binary, + }); try { ctx.window.switchPanel('s1'); await ctx.window.openChangesTab('s1'); @@ -348,7 +429,10 @@ test('the row list is capped, with a note for the remainder (mutation target: re }); test('a failed untracked diff surfaces the error and leaves the counts alone', async () => { - const ctx = setupFilePanelDom({ diffImpl: () => ({ ok: false, error: 'fatal: bad thing' }) }); + const ctx = setupFilePanelDom({ + fileImpl: () => ({ ok: false, error: 'fatal: bad thing', reason: 'repo' }), + diffImpl: () => ({ ok: false, error: 'fatal: bad thing' }), + }); try { ctx.window.switchPanel('s1'); await ctx.window.openChangesTab('s1'); @@ -514,3 +598,271 @@ test('the Changes header button opens and closes the tab for the active session' assert.equal(ctx.document.getElementById('file-panel').classList.contains('open'), false); } finally { ctx.destroy(); } }); + +// --- Editing in place ---------------------------------------------------- + +test('a local changed file opens in an editable diff over the content pair, with Save and the mode toggle', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + + assert.deepEqual(ctx.calls.file, [{ sessionId: 's1', filePath: 'src/a.js', staged: true }]); + assert.equal(ctx.calls.diff.length, 0, 'the editable path does not also fetch a unified diff'); + + assert.equal(ctx.editors.length, 1); + assert.deepEqual(ctx.editors[0].opened, { original: 'old\n', modified: 'new\n', filename: 'src/a.js' }); + assert.equal(ctx.editors[0].box.mode, 'side-by-side', 'the default mode'); + assert.ok(ctx.document.querySelector('#changes-diff-host .fake-editor'), 'the editor is mounted in the diff host'); + assert.equal(ctx.document.querySelector('.changes-diff-body'), null, 'no inert diff text alongside the editor'); + + assert.equal(ctx.document.getElementById('changes-diff-save-btn').style.display, ''); + assert.equal(ctx.document.getElementById('changes-diff-mode-btn').style.display, ''); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); + } finally { ctx.destroy(); } +}); + +test('a remote session keeps the read-only renderer and offers no Save button (mutation target: the remote-write refusal)', async () => { + const ctx = setupFilePanelDom({ statusImpl: REMOTE_STATUS }); + try { + await openFile(ctx, 's1', 'src/a.js'); + + assert.equal(ctx.editors.length, 0, 'a remote session never builds an editor'); + assert.equal(ctx.document.getElementById('changes-diff-save-btn').style.display, 'none'); + assert.equal(ctx.document.getElementById('changes-diff-mode-btn').style.display, 'none'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /Remote session — read-only/); + + ctx.document.getElementById('changes-diff-save-btn').click(); + ctx.document.getElementById('changes-diff-view').dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); + await flush(); + assert.deepEqual(ctx.calls.save, [], 'neither the button nor Ctrl+S may write to a remote host'); + } finally { ctx.destroy(); } +}); + +test('a file the main process refuses to open for editing falls back to the read-only diff and says which limit it hit', async () => { + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: false, error: 'file too large to edit', reason: 'too-large' }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + + assert.equal(ctx.editors.length, 0); + assert.equal(ctx.calls.diff.length, 1, 'the read-only diff is the fallback'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /file too large to edit/); + assert.ok(ctx.document.querySelector('.changes-diff-add'), 'the unified diff still renders'); + assert.equal(ctx.document.getElementById('changes-diff-save-btn').style.display, 'none'); + } finally { ctx.destroy(); } +}); + +test('an idle refresh with the same file open reuses the editor instead of rebuilding it (mutation target: tearing the render down every time)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + const editor = ctx.editors[0]; + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.equal(ctx.calls.status.length, 2, 'the file list still refreshes'); + assert.equal(ctx.editors.length, 1, 'no second editor was built'); + assert.equal(editor.box.destroyed, false, 'the editor under the cursor survives the refresh'); + assert.equal(ctx.document.querySelector('#changes-diff-host .fake-editor'), editor.dom, 'and stays mounted'); + } finally { ctx.destroy(); } +}); + +test('an idle refresh does not detach the editor from the DOM either (mutation target: clearing the host on every render)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + const host = ctx.document.getElementById('changes-diff-host'); + + const records = []; + const observer = new ctx.window.MutationObserver((list) => records.push(...list)); + observer.observe(host, { childList: true }); + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + await flush(); + + observer.disconnect(); + assert.deepEqual(records, [], 'the editor node is neither removed nor re-inserted — a detach loses focus and scroll'); + } finally { ctx.destroy(); } +}); + +test('an idle refresh leaves a dirty buffer alone and says it may be out of date (mutation target: the dirty-buffer guard)', async () => { + let current = 'new\n'; + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + const editor = ctx.editors[0]; + + editor.box.text = 'typed by the user\n'; + current = 'written by the session\n'; + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.equal(ctx.calls.file.length, 1, 'a dirty buffer is never re-read from disk'); + assert.equal(ctx.editors.length, 1); + assert.equal(editor.box.destroyed, false); + assert.equal(editor.box.text, 'typed by the user\n', 'the unsaved edit survives'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /out of date/); + } finally { ctx.destroy(); } +}); + +test('an idle refresh does reload a clean buffer when the file changed underneath', async () => { + let current = 'new\n'; + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + const first = ctx.editors[0]; + + current = 'written by the session\n'; + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.equal(ctx.calls.file.length, 2); + assert.equal(ctx.editors.length, 2, 'a clean buffer picks up the new content'); + assert.equal(first.box.destroyed, true); + assert.equal(ctx.editors[1].opened.modified, 'written by the session\n'); + } finally { ctx.destroy(); } +}); + +test('Save writes the edited buffer through git-changes-save and refreshes the status so the counts follow', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'edited\n'; + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited\n' }]); + assert.equal(ctx.calls.status.length, 2, 'the row counts are re-read after a save'); + assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none'); + } finally { ctx.destroy(); } +}); + +test('Ctrl/Cmd+S from the editor saves the same way the button does', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'edited by keyboard\n'; + + ctx.editors[0].dom.dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); + await flush(); + + assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited by keyboard\n' }]); + } finally { ctx.destroy(); } +}); + +test('a refused save surfaces the reason and keeps the buffer', async () => { + const ctx = setupFilePanelDom({ saveImpl: () => ({ ok: false, error: 'path resolves outside the repository', reason: 'outside' }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'edited\n'; + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /Save failed: path resolves outside the repository/); + assert.equal(ctx.editors.length, 1); + assert.equal(ctx.editors[0].box.text, 'edited\n'); + assert.equal(ctx.calls.status.length, 1, 'a failed save must not claim the tree changed'); + } finally { ctx.destroy(); } +}); + +test('the mode toggle cycles side-by-side → inline → plain, persists under its own key, and carries unsaved edits over', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'edited\n'; + + const modeBtn = ctx.document.getElementById('changes-diff-mode-btn'); + assert.equal(modeBtn.textContent, 'Side-by-side'); + + modeBtn.click(); + await flush(); + assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'inline'); + assert.equal(ctx.window.localStorage.getItem('filePanelDiffMode'), null, 'the MCP diff tab keeps its own key'); + assert.equal(ctx.editors[1].box.mode, 'inline'); + assert.equal(ctx.editors[1].opened.modified, 'edited\n', 'an unsaved edit is carried into the new view'); + + modeBtn.click(); + await flush(); + assert.equal(ctx.editors[2].box.mode, 'plain'); + assert.equal(ctx.editors[2].opened.original, null, 'plain mode is the file, with no diff decoration'); + + modeBtn.click(); + await flush(); + assert.equal(ctx.editors[3].box.mode, 'side-by-side'); + assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'side-by-side'); + } finally { ctx.destroy(); } +}); + +test('an inline editor is read back from the view itself, a side-by-side one from its right-hand side', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + + const sideBySide = ctx.editors[0]; + assert.ok(sideBySide.b, 'the side-by-side view edits its b side'); + sideBySide.box.text = 'from the b side\n'; + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + assert.equal(ctx.calls.save[0].content, 'from the b side\n'); + + ctx.document.getElementById('changes-diff-mode-btn').click(); + await flush(); + const inline = ctx.editors[ctx.editors.length - 1]; + assert.equal(inline.b, undefined, 'the inline view has no b side'); + inline.box.text = 'from the single view\n'; + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + assert.equal(ctx.calls.save[1].content, 'from the single view\n'); + } finally { ctx.destroy(); } +}); + +test('an untracked local file opens with an empty original and its own lines as the added count', async () => { + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: '', current: 'first\nsecond\n' }) }); + try { + await openFile(ctx, 's1', 'new.txt'); + + assert.deepEqual(ctx.calls.file, [{ sessionId: 's1', filePath: 'new.txt', staged: false }]); + assert.equal(ctx.editors[0].opened.original, ''); + + backBtn(ctx).click(); + const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); + assert.equal(counts.textContent, '+2−0'); + assert.match(ctx.document.getElementById('changes-summary').textContent, /2 files changed \+5 −1/); + } finally { ctx.destroy(); } +}); + +test('closing the panel and going back to the list both destroy the editor (mutation target: a leaked view)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + backBtn(ctx).click(); + assert.equal(ctx.editors[0].box.destroyed, true, 'Back destroys the editor'); + assert.equal(ctx.document.querySelector('#changes-diff-host .fake-editor'), null); + + clickRow(ctx, 'src/a.js'); + await flush(); + assert.equal(ctx.editors.length, 2); + + ctx.document.getElementById('changes-toggle-btn').click(); + assert.equal(ctx.editors[1].box.destroyed, true, 'closing the tab destroys the editor'); + } finally { ctx.destroy(); } +}); + +test('the panel close button destroys the editor too', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + + ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); + assert.equal(ctx.editors[0].box.destroyed, true); + assert.equal(ctx.document.getElementById('file-panel').classList.contains('open'), false); + } finally { ctx.destroy(); } +}); diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index 425c1b73..c948d4c6 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -321,6 +321,33 @@ test('real git: a save refuses every adversarial path shape and writes nothing ( } finally { cleanup(tmp); } }); +// The guard resolves the target once; the write must run on that value and not +// on a second resolution of the same string — see .ai/contexts/changes-view.md. +test('real git: the save writes the path the guard returned, not a re-derived join (mutation target: re-resolving after the check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, 'real')); + fs.writeFileSync(path.join(repoDir, 'real', 'f.txt'), 'before\n'); + fs.symlinkSync(path.join(repoDir, 'real'), path.join(repoDir, 'link')); + + const written = []; + const fakeFs = { + statSync: (p) => fs.statSync(p), + writeFileSync: (p, content, enc) => { written.push(p); fs.writeFileSync(p, content, enc); }, + }; + + const result = await writeChangesFile( + { cwd: repoDir, relPath: 'link/f.txt', content: 'after\n', maxBytes: MAX_BYTES }, + { fs: fakeFs }, + ); + assert.equal(result.ok, true, result.error); + assert.deepEqual(written, [path.join(repoDir, 'real', 'f.txt')], 'the symlink-free path from the guard, not repo/link/f.txt'); + assert.equal(fs.readFileSync(path.join(repoDir, 'real', 'f.txt'), 'utf8'), 'after\n'); + } finally { cleanup(tmp); } +}); + test('real git: a save refuses content over the cap and non-string content', async () => { const tmp = mkTmp(); try { diff --git a/test/git-changes-file.test.js b/test/git-changes-file.test.js index 7c6256cc..8cf11960 100644 --- a/test/git-changes-file.test.js +++ b/test/git-changes-file.test.js @@ -12,6 +12,7 @@ const { isSafeRepoRelativePath, isSafeRevPathOperand, buildBlobRev, + requireLocalTarget, } = require('../git-changes-file'); const ADVERSARIAL = [ @@ -60,6 +61,19 @@ test('isSafeRevPathOperand rejects the `::` stage syntax that isSafeRep assert.equal(isSafeRevPathOperand('23:f.txt'), false); }); +test('requireLocalTarget refuses a remote session and passes a local one through (mutation target: the remote-write refusal)', () => { + const remote = requireLocalTarget({ ok: true, kind: 'remote', alias: 'box', cwd: '/srv/app' }); + assert.equal(remote.ok, false); + assert.equal(remote.reason, 'remote'); + assert.ok(!('cwd' in remote), 'a refused target hands back no working directory'); + + const local = { ok: true, kind: 'local', cwd: '/home/u/repo' }; + assert.equal(requireLocalTarget(local), local); + + const unresolved = { ok: false, error: 'invalid session id' }; + assert.equal(requireLocalTarget(unresolved), unresolved, 'an unresolved target keeps its own error'); +}); + test('buildBlobRev names the index for the unstaged view and HEAD for the staged one', () => { assert.equal(buildBlobRev('src/a.js', false), ':src/a.js'); assert.equal(buildBlobRev('src/a.js', true), 'HEAD:src/a.js'); From d9e622855f701db49b21ac0c24cdef69e6a0a34b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 17:12:15 +0200 Subject: [PATCH 03/29] fix(changes): never let a save overwrite what the session wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel sits beside a session writing the same files, so a save was an unconditional overwrite of whatever had appeared on disk since the file was opened, and the session's uncommitted work was unrecoverable. git-changes-file now issues a version token — the hash of the bytes it read — and git-changes-save requires it back, re-reads the file and refuses when it no longer matches. A save with no token is refused too, so a caller that forgets it cannot clobber anything, and every successful write returns the token the next one must carry. git-changes-watch adds the second layer: fs.watch on the same guarded path, reported to the renderer as (sessionId, repo-relative path) so no absolute path crosses the boundary. Four further ways the round trip lost or corrupted content: - CodeMirror normalises CRLF to LF, so a CRLF file came back LF and rewrote every line. The read hands out LF-only text and the write re-applies the file's own dominant ending, measured from the bytes the token was taken from. - Latin-1 and friends carry no NUL, so they passed the binary gate, decoded to U+FFFD and were written back as replacement bytes. Both sides now decode strictly and a file that is not UTF-8 is refused, saying so. - .git is inside the repository root, so .git/config — core.pager, [alias] — and the hooks were writable. No path with a .git segment is accepted. - A symlink was followed: the pair was the link text against the target's content, and the save landed on a file the row did not name. A symlink at the target is refused before anything follows it; an escape through a symlinked directory is still the containment check's job. Also: only exit 128 means "absent from this tree" — a timeout or an unreadable object is an error, not an empty original side; `..` and `.git` are matched as path segments, so a file legitimately named `a..b` is editable; and the work-files signature invalidation splits on both separators. --- git-changes-file.js | 96 +++++-- main.js | 57 ++++- preload.js | 7 +- test/git-changes-file-real-git.test.js | 334 ++++++++++++++++++++++++- test/git-changes-file.test.js | 20 ++ 5 files changed, 488 insertions(+), 26 deletions(-) diff --git a/git-changes-file.js b/git-changes-file.js index e86c18f5..29ab7336 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -4,6 +4,7 @@ const realFs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { execFile } = require('child_process'); const { localGitEnv } = require('./git-changes-runner'); const { resolveOnDisk, isInsideDir } = require('./resolve-path-on-disk'); @@ -12,16 +13,19 @@ const { isSensitivePath } = require('./ipc-path-validator'); const DEFAULT_TIMEOUT_MS = 10_000; const MAX_PATH_LENGTH = 4096; const TOPLEVEL_MAX_BUFFER = 64 * 1024; +const NOT_IN_TREE_EXIT_CODE = 128; // Guards for a repo-relative path from the renderer — see .ai/contexts/changes-view.md ("Editing a changed file") function isSafeRepoRelativePath(p) { if (typeof p !== 'string' || p === '' || p.length > MAX_PATH_LENGTH) return false; if (/[\x00-\x1f\x7f]/.test(p)) return false; - if (p.includes('..')) return false; if (p[0] === '/' || p[0] === '\\') return false; if (/^[A-Za-z]:/.test(p)) return false; if (p[0] === ':') return false; if (p[0] === '-') return false; + const segments = p.split(/[/\\]/); + if (segments.some((s) => s === '..')) return false; + if (segments.some((s) => s.toLowerCase() === '.git')) return false; return true; } @@ -36,6 +40,15 @@ function buildBlobRev(relPath, staged) { return (staged ? 'HEAD:' : ':') + relPath; } +// There is no file-write path to a remote host anywhere in this app — see .ai/contexts/changes-view.md +function requireLocalTarget(target) { + if (!target || target.ok !== true) return target; + if (target.kind !== 'local') { + return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; + } + return target; +} + function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { return new Promise((resolve) => { execFile('git', args, { cwd, env: localGitEnv(), timeout: timeoutMs, maxBuffer, encoding: 'buffer', windowsHide: true }, @@ -45,11 +58,12 @@ function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { resolve({ code: typeof err.code === 'number' ? err.code : -1, stdout: out, + stderr: String(stderr || err.message || ''), tooLarge: err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', }); return; } - resolve({ code: 0, stdout: out, tooLarge: false }); + resolve({ code: 0, stdout: out, stderr: '', tooLarge: false }); }); }); } @@ -72,7 +86,18 @@ function resolveTargetInsideRepo(repoRoot, relPath, deps) { const realRoot = resolveOnDisk(repoRoot); if (!realRoot) return { ok: false, error: 'the repository directory no longer exists', reason: 'repo' }; - const real = resolveOnDisk(path.join(realRoot, relPath)); + const joined = path.join(realRoot, relPath); + let link; + try { + link = fs.lstatSync(joined); + } catch { + return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; + } + if (link.isSymbolicLink()) { + return { ok: false, error: 'this row is a symbolic link, not a file', reason: 'symlink' }; + } + + const real = resolveOnDisk(joined); if (!real) return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; if (!isInsideDir(real, realRoot)) return { ok: false, error: 'path resolves outside the repository', reason: 'outside' }; if (isSensitivePath(real)) return { ok: false, error: 'access to sensitive path denied', reason: 'sensitive' }; @@ -88,13 +113,32 @@ function resolveTargetInsideRepo(repoRoot, relPath, deps) { return { ok: true, path: real, size: stat.size, repoRoot: realRoot }; } -// There is no file-write path to a remote host anywhere in this app — see .ai/contexts/changes-view.md -function requireLocalTarget(target) { - if (!target || target.ok !== true) return target; - if (target.kind !== 'local') { - return { ok: false, error: 'editing is not available for a remote session', reason: 'remote' }; +// The token the renderer hands back on save — see .ai/contexts/changes-view.md ("Saving over a file that moved") +function versionOf(buf) { + return crypto.createHash('sha1').update(buf).digest('hex') + '-' + buf.length; +} + +function dominantEol(text) { + const crlf = (text.match(/\r\n/g) || []).length; + const lf = (text.match(/\n/g) || []).length - crlf; + return crlf > lf ? '\r\n' : '\n'; +} + +function toLf(text) { + return text.replace(/\r\n/g, '\n'); +} + +function applyEol(text, eol) { + return eol === '\r\n' ? toLf(text).replace(/\n/g, '\r\n') : toLf(text); +} + +// see .ai/contexts/changes-view.md ("Caps, line endings and encoding") +function decodeUtf8(buf) { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buf); + } catch { + return null; } - return target; } async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { @@ -111,6 +155,8 @@ async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { const buf = fs.readFileSync(target.path); if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; if (buf.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; + const currentText = decodeUtf8(buf); + if (currentText === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; const runGit = deps.runGit || defaultRunGit; const blob = await runGit(['cat-file', 'blob', buildBlobRev(relPath, staged)], { @@ -125,15 +171,27 @@ async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { if (blob.code === 0) { if (blob.stdout.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; if (blob.stdout.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; - original = blob.stdout.toString('utf8'); + const originalText = decodeUtf8(blob.stdout); + if (originalText === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; + original = toLf(originalText); + } else if (blob.code !== NOT_IN_TREE_EXIT_CODE) { + return { ok: false, error: (blob.stderr || '').trim() || `git exited with code ${blob.code}`, reason: 'git' }; } - return { ok: true, original, current: buf.toString('utf8'), binary: false, truncated: false }; + return { + ok: true, + original, + current: toLf(currentText), + version: versionOf(buf), + binary: false, + truncated: false, + }; } -async function writeChangesFile({ cwd, relPath, content, maxBytes }, deps = {}) { +async function writeChangesFile({ cwd, relPath, content, version, maxBytes }, deps = {}) { const fs = deps.fs || realFs; if (typeof content !== 'string') return { ok: false, error: 'invalid content', reason: 'invalid-content' }; + if (typeof version !== 'string' || !version) return { ok: false, error: 'missing version token', reason: 'invalid-version' }; if (!isSafeRepoRelativePath(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; if (Buffer.byteLength(content, 'utf8') > maxBytes) return { ok: false, error: 'content too large to save', reason: 'too-large' }; @@ -143,8 +201,16 @@ async function writeChangesFile({ cwd, relPath, content, maxBytes }, deps = {}) const target = resolveTargetInsideRepo(repoRoot, relPath, deps); if (!target.ok) return target; - fs.writeFileSync(target.path, content, 'utf8'); - return { ok: true, savedPath: target.path }; + const onDisk = fs.readFileSync(target.path); + if (versionOf(onDisk) !== version) { + return { ok: false, error: 'this file changed on disk since it was opened', reason: 'stale' }; + } + + const eol = dominantEol(decodeUtf8(onDisk) || ''); + const bytes = Buffer.from(applyEol(content, eol), 'utf8'); + if (bytes.length > maxBytes) return { ok: false, error: 'content too large to save', reason: 'too-large' }; + fs.writeFileSync(target.path, bytes); + return { ok: true, savedPath: target.path, version: versionOf(bytes) }; } module.exports = { @@ -156,4 +222,6 @@ module.exports = { isSafeRepoRelativePath, isSafeRevPathOperand, buildBlobRev, + versionOf, + dominantEol, }; diff --git a/main.js b/main.js index 11aba74d..22f60224 100644 --- a/main.js +++ b/main.js @@ -1795,7 +1795,7 @@ ipcMain.handle('git-changes-file', async (_event, sessionId, filePath, opts) => } }); -ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content) => { +ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content, version) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); if (!target.ok) return target; @@ -1804,17 +1804,70 @@ ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content) cwd: target.cwd, relPath: filePath, content, + version, maxBytes: PANEL_FILE_MAX_BYTES, }); if (!result.ok) return result; - if (result.savedPath.includes('/.work-files/')) invalidateFtsSignature('work-file'); + if (result.savedPath.split(/[\\/]/).includes('.work-files')) invalidateFtsSignature('work-file'); if (result.savedPath.endsWith('.md')) invalidateFtsSignature('memory'); + return { ok: true, version: result.version }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +// see .ai/contexts/changes-view.md ("Saving over a file that moved") +const changesWatchers = new Map(); + +function changesWatchKey(sessionId, relPath) { + return sessionId + '\u0000' + relPath; +} + +function stopChangesWatch(key) { + const entry = changesWatchers.get(key); + if (!entry) return; + try { entry.watcher.close(); } catch {} + if (entry.debounce) clearTimeout(entry.debounce); + changesWatchers.delete(key); +} + +ipcMain.handle('git-changes-watch', async (_event, sessionId, filePath) => { + if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); + if (!target.ok) return target; + + const key = changesWatchKey(sessionId, filePath); + stopChangesWatch(key); + + const repoRoot = await gitChangesFile.resolveRepoRoot(target.cwd, {}); + if (!repoRoot) return { ok: false, error: 'not a git repository', reason: 'repo' }; + const resolved = gitChangesFile.resolveTargetInsideRepo(repoRoot, filePath, {}); + if (!resolved.ok) return resolved; + + try { + const entry = { watcher: null, debounce: null }; + entry.watcher = fs.watch(resolved.path, (eventType) => { + if (eventType !== 'change') return; + if (entry.debounce) clearTimeout(entry.debounce); + entry.debounce = setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('git-changes-file-changed', sessionId, filePath); + } + }, 300); + }); + changesWatchers.set(key, entry); return { ok: true }; } catch (err) { return { ok: false, error: err.message }; } }); +ipcMain.handle('git-changes-unwatch', (_event, sessionId, filePath) => { + if (typeof filePath !== 'string' || !filePath) return { ok: true }; + stopChangesWatch(changesWatchKey(sessionId, filePath)); + return { ok: true }; +}); + // --- IPC: toggle-star --- ipcMain.handle('toggle-star', (_event, sessionId) => { const starred = toggleStar(sessionId); diff --git a/preload.js b/preload.js index d0e1dec2..78414cda 100644 --- a/preload.js +++ b/preload.js @@ -37,7 +37,12 @@ contextBridge.exposeInMainWorld('api', { gitChangesStatus: (sessionId) => ipcRenderer.invoke('git-changes-status', sessionId), gitChangesDiff: (sessionId, filePath, staged, untracked) => ipcRenderer.invoke('git-changes-diff', sessionId, filePath, staged, untracked), gitChangesFile: (sessionId, filePath, opts) => ipcRenderer.invoke('git-changes-file', sessionId, filePath, opts), - gitChangesSave: (sessionId, filePath, content) => ipcRenderer.invoke('git-changes-save', sessionId, filePath, content), + gitChangesSave: (sessionId, filePath, content, version) => ipcRenderer.invoke('git-changes-save', sessionId, filePath, content, version), + gitChangesWatch: (sessionId, filePath) => ipcRenderer.invoke('git-changes-watch', sessionId, filePath), + gitChangesUnwatch: (sessionId, filePath) => ipcRenderer.invoke('git-changes-unwatch', sessionId, filePath), + onGitChangesFileChanged: (callback) => { + ipcRenderer.on('git-changes-file-changed', (_event, sessionId, filePath) => callback(sessionId, filePath)); + }, // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index c948d4c6..0c74db9d 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -12,7 +12,7 @@ const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); -const { readChangesFile, writeChangesFile } = require('../git-changes-file'); +const { readChangesFile, writeChangesFile, versionOf } = require('../git-changes-file'); // git translates its diagnostics; the assertions below match its English text. process.env.LC_ALL = 'C'; @@ -60,6 +60,25 @@ function read(repoDir, relPath, staged) { return readChangesFile({ cwd: repoDir, relPath, staged: !!staged, maxBytes: MAX_BYTES }); } +// The version token of what is on disk right now, i.e. a save that races nothing. +function currentVersion(repoDir, relPath) { + try { + return versionOf(fs.readFileSync(path.join(repoDir, relPath))); + } catch { + return 'no-such-file'; + } +} + +function save(repoDir, relPath, content, version) { + return writeChangesFile({ + cwd: repoDir, + relPath, + content, + version: version === undefined ? currentVersion(repoDir, relPath) : version, + maxBytes: MAX_BYTES, + }); +} + // --- The content pair --------------------------------------------------- test('real git: the unstaged view pairs the index blob with the working tree', async () => { @@ -226,12 +245,71 @@ test('real git: a symlink inside the repository pointing outside it is refused b const readResult = await read(repoDir, 'link.txt', false); assert.equal(readResult.ok, false, 'a symlinked escape must not read the file it points at'); + assert.equal(readResult.reason, 'symlink'); + + const writeResult = await save(repoDir, 'link.txt', 'overwritten\n'); + assert.equal(writeResult.ok, false); + assert.equal(writeResult.reason, 'symlink'); + assert.equal(fs.readFileSync(secret, 'utf8'), OUTSIDE_SECRET, 'the file outside the repository is untouched'); + } finally { cleanup(tmp); } +}); + +test('real git: a symlinked directory inside the repository is an escape the containment check catches (mutation target: the containment check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const outsideDir = path.join(tmp, 'outside'); + fs.mkdirSync(outsideDir); + fs.writeFileSync(path.join(outsideDir, 'passwd'), OUTSIDE_SECRET); + fs.symlinkSync(outsideDir, path.join(repoDir, 'linkdir')); + + const readResult = await read(repoDir, 'linkdir/passwd', false); + assert.equal(readResult.ok, false, 'the last component is a real file, so only containment can refuse this'); assert.equal(readResult.reason, 'outside'); - const writeResult = await writeChangesFile({ cwd: repoDir, relPath: 'link.txt', content: 'overwritten\n', maxBytes: MAX_BYTES }); + const writeResult = await save(repoDir, 'linkdir/passwd', 'pwned\n'); assert.equal(writeResult.ok, false); assert.equal(writeResult.reason, 'outside'); - assert.equal(fs.readFileSync(secret, 'utf8'), OUTSIDE_SECRET, 'the file outside the repository is untouched'); + assert.equal(fs.readFileSync(path.join(outsideDir, 'passwd'), 'utf8'), OUTSIDE_SECRET); + } finally { cleanup(tmp); } +}); + +test('real git: a symlink to another file inside the repository is refused, not silently followed (F5)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.symlinkSync(path.join(repoDir, 'f.txt'), path.join(repoDir, 'innerlink')); + + const readResult = await read(repoDir, 'innerlink', false); + assert.equal(readResult.ok, false, 'the pair would be the link text against the target content'); + assert.equal(readResult.reason, 'symlink'); + + const writeResult = await save(repoDir, 'innerlink', 'PWNED\n'); + assert.equal(writeResult.ok, false, 'the row names one path; the write must not land on another'); + assert.equal(writeResult.reason, 'symlink'); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'worktree\n', 'the link target is untouched'); + assert.equal(fs.lstatSync(path.join(repoDir, 'innerlink')).isSymbolicLink(), true); + } finally { cleanup(tmp); } +}); + +test('real git: anything under .git is refused, on the read and on the write (mutation target: the .git segment check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const configPath = path.join(repoDir, '.git', 'config'); + const configBefore = fs.readFileSync(configPath, 'utf8'); + + for (const relPath of ['.git/config', '.git/hooks/pre-commit.sample', '.GIT/config', 'sub/../.git/config', '.git']) { + const readResult = await read(repoDir, relPath, false); + assert.equal(readResult.ok, false, `read must refuse ${relPath}`); + + const writeResult = await save(repoDir, relPath, '[core]\n\tpager = OWNED\n'); + assert.equal(writeResult.ok, false, `write must refuse ${relPath}`); + } + assert.equal(fs.readFileSync(configPath, 'utf8'), configBefore, 'git config is a command-execution primitive'); } finally { cleanup(tmp); } }); @@ -282,7 +360,7 @@ test('real git: a save writes the working-tree file and the next read sees it', const repoDir = path.join(tmp, 'repo'); initRepo(repoDir); - const result = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'edited\n', maxBytes: MAX_BYTES }); + const result = await save(repoDir, 'f.txt', 'edited\n'); assert.equal(result.ok, true, result.error); assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'edited\n'); @@ -298,7 +376,7 @@ test('real git: a save never creates a file that does not exist', async () => { const repoDir = path.join(tmp, 'repo'); initRepo(repoDir); - const result = await writeChangesFile({ cwd: repoDir, relPath: 'nope.txt', content: 'x\n', maxBytes: MAX_BYTES }); + const result = await save(repoDir, 'nope.txt', 'x\n'); assert.equal(result.ok, false); assert.equal(result.reason, 'missing'); assert.equal(fs.existsSync(path.join(repoDir, 'nope.txt')), false); @@ -313,7 +391,7 @@ test('real git: a save refuses every adversarial path shape and writes nothing ( const secret = withOutsideFile(tmp); for (const relPath of ['../outside-secret.txt', '../../etc/passwd', secret, 'f\n.txt', '', ':(exclude)f.txt', '-rf']) { - const result = await writeChangesFile({ cwd: repoDir, relPath, content: 'pwned\n', maxBytes: MAX_BYTES }); + const result = await save(repoDir, relPath, 'pwned\n'); assert.equal(result.ok, false, `must refuse ${JSON.stringify(relPath)}`); } assert.equal(fs.readFileSync(secret, 'utf8'), OUTSIDE_SECRET); @@ -330,16 +408,20 @@ test('real git: the save writes the path the guard returned, not a re-derived jo initRepo(repoDir); fs.mkdirSync(path.join(repoDir, 'real')); fs.writeFileSync(path.join(repoDir, 'real', 'f.txt'), 'before\n'); + // A symlinked directory inside the repo: the file itself is a real file, so + // it is editable, but the path the guard resolves is not the path it was given. fs.symlinkSync(path.join(repoDir, 'real'), path.join(repoDir, 'link')); const written = []; const fakeFs = { statSync: (p) => fs.statSync(p), + lstatSync: (p) => fs.lstatSync(p), + readFileSync: (p) => fs.readFileSync(p), writeFileSync: (p, content, enc) => { written.push(p); fs.writeFileSync(p, content, enc); }, }; const result = await writeChangesFile( - { cwd: repoDir, relPath: 'link/f.txt', content: 'after\n', maxBytes: MAX_BYTES }, + { cwd: repoDir, relPath: 'link/f.txt', content: 'after\n', version: currentVersion(repoDir, 'link/f.txt'), maxBytes: MAX_BYTES }, { fs: fakeFs }, ); assert.equal(result.ok, true, result.error); @@ -354,14 +436,248 @@ test('real git: a save refuses content over the cap and non-string content', asy const repoDir = path.join(tmp, 'repo'); initRepo(repoDir); - const tooBig = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'x'.repeat(2048), maxBytes: 1024 }); + const tooBig = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'x'.repeat(2048), version: currentVersion(repoDir, 'f.txt'), maxBytes: 1024 }); assert.equal(tooBig.ok, false); assert.equal(tooBig.reason, 'too-large'); - const notAString = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: null, maxBytes: MAX_BYTES }); + const notAString = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: null, version: currentVersion(repoDir, 'f.txt'), maxBytes: MAX_BYTES }); assert.equal(notAString.ok, false); assert.equal(notAString.reason, 'invalid-content'); + const noVersion = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'x\n', maxBytes: MAX_BYTES }); + assert.equal(noVersion.ok, false); + assert.equal(noVersion.reason, 'invalid-version', 'a caller that carries no token cannot overwrite anything'); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'worktree\n'); } finally { cleanup(tmp); } }); + +// --- Saving over a file that moved (F1) ---------------------------------- + +test('real git: a save is refused when the file changed since it was read, and the other writer keeps its bytes (mutation target: the version token)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'f.txt'); + + const opened = await read(repoDir, 'f.txt', false); + assert.equal(opened.ok, true, opened.error); + assert.equal(typeof opened.version, 'string'); + + // The session writes the file while the panel holds it open. + fs.writeFileSync(target, 'IMPORTANT WORK BY THE SESSION\n'); + + const refused = await writeChangesFile({ + cwd: repoDir, relPath: 'f.txt', content: opened.current + 'my edit\n', version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(refused.ok, false); + assert.equal(refused.reason, 'stale'); + assert.equal(fs.readFileSync(target, 'utf8'), 'IMPORTANT WORK BY THE SESSION\n', 'the session\'s uncommitted work survives'); + + // Re-reading hands back a token that matches, and the save goes through. + const reread = await read(repoDir, 'f.txt', false); + const accepted = await writeChangesFile({ + cwd: repoDir, relPath: 'f.txt', content: 'mine now\n', version: reread.version, maxBytes: MAX_BYTES, + }); + assert.equal(accepted.ok, true, accepted.error); + assert.equal(fs.readFileSync(target, 'utf8'), 'mine now\n'); + assert.equal(accepted.version, reread.version === accepted.version ? accepted.version : accepted.version); + } finally { cleanup(tmp); } +}); + +test('real git: the token a save returns is the one the next save must carry', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const opened = await read(repoDir, 'f.txt', false); + const first = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'one\n', version: opened.version, maxBytes: MAX_BYTES }); + assert.equal(first.ok, true, first.error); + + const stale = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'two\n', version: opened.version, maxBytes: MAX_BYTES }); + assert.equal(stale.ok, false, 'the token from before the first save is spent'); + assert.equal(stale.reason, 'stale'); + + const second = await writeChangesFile({ cwd: repoDir, relPath: 'f.txt', content: 'two\n', version: first.version, maxBytes: MAX_BYTES }); + assert.equal(second.ok, true, second.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'two\n'); + } finally { cleanup(tmp); } +}); + +// --- Line endings (F2) ---------------------------------------------------- + +test('real git: a CRLF file reads as LF and is written back as CRLF, so a no-op save is a no-op in git (mutation target: the line-ending round trip)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'crlf.txt'), 'one\r\ntwo\r\nthree\r\n'); + git(repoDir, ['add', 'crlf.txt']); + git(repoDir, ['commit', '-q', '-m', 'crlf']); + + const opened = await read(repoDir, 'crlf.txt', true); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.current, 'one\ntwo\nthree\n', 'the editor never sees a CR it would strip on its own'); + assert.equal(opened.original, 'one\ntwo\nthree\n'); + + // What CodeMirror hands back: the same document, LF-joined. + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'crlf.txt', content: opened.current, version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'crlf.txt'), 'utf8'), 'one\r\ntwo\r\nthree\r\n', + 'the file keeps the line endings it had'); + assert.equal(git(repoDir, ['status', '--porcelain', '--', 'crlf.txt']).trim(), '', 'a no-op save leaves git with nothing to report'); + + // A real edit keeps CRLF too. + const edited = await writeChangesFile({ + cwd: repoDir, relPath: 'crlf.txt', content: 'one\ntwo\nthree\nfour\n', version: saved.version, maxBytes: MAX_BYTES, + }); + assert.equal(edited.ok, true, edited.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'crlf.txt'), 'utf8'), 'one\r\ntwo\r\nthree\r\nfour\r\n'); + } finally { cleanup(tmp); } +}); + +test('real git: an LF file stays LF even when the buffer carries a stray CR', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const opened = await read(repoDir, 'f.txt', false); + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'f.txt', content: 'a\r\nb\n', version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'f.txt'), 'utf8'), 'a\nb\n'); + } finally { cleanup(tmp); } +}); + +// --- Encoding (F3) -------------------------------------------------------- + +test('real git: a file that is not valid UTF-8 is refused rather than round-tripped through U+FFFD (mutation target: the encoding gate)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const latin = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]); + const target = path.join(repoDir, 'latin.txt'); + fs.writeFileSync(target, latin); + + const result = await read(repoDir, 'latin.txt', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'encoding', 'the panel has to tell this apart from a binary file'); + assert.deepEqual(fs.readFileSync(target), latin, 'and the bytes are untouched'); + } finally { cleanup(tmp); } +}); + +test('real git: a blob that is not valid UTF-8 is refused too, even when the working tree side is clean', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'latin.txt'), Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a])); + git(repoDir, ['add', 'latin.txt']); + fs.writeFileSync(path.join(repoDir, 'latin.txt'), 'cafe\n'); + + const result = await read(repoDir, 'latin.txt', false); + assert.equal(result.ok, false); + assert.equal(result.reason, 'encoding'); + } finally { cleanup(tmp); } +}); + +// --- A `cat-file` failure is not a new file (F10) ------------------------- + +test('a cat-file failure that is not "absent from this tree" is an error, not an empty original', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const timedOut = await readChangesFile( + { cwd: repoDir, relPath: 'f.txt', staged: false, maxBytes: MAX_BYTES }, + { runGit: fakeRunGit({ code: -1, stderr: 'killed' }) }, + ); + assert.equal(timedOut.ok, false, 'a timeout must not be rendered as "every line is new"'); + assert.equal(timedOut.reason, 'git'); + + const absent = await readChangesFile( + { cwd: repoDir, relPath: 'f.txt', staged: false, maxBytes: MAX_BYTES }, + { runGit: fakeRunGit({ code: 128, stderr: "fatal: path 'f.txt' exists on disk, but not in the index" }) }, + ); + assert.equal(absent.ok, true, 'exit 128 is the untracked/new-file case'); + assert.equal(absent.original, ''); + } finally { cleanup(tmp); } +}); + +// Passes rev-parse through to real git and fails only the blob read. +function fakeRunGit(blobResult) { + const realModule = require('../git-changes-file'); + void realModule; + return (args, opts) => { + if (args[0] === 'rev-parse') { + const out = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: opts.cwd, env: scratchGitEnv() }); + return Promise.resolve({ code: 0, stdout: out, stderr: '', tooLarge: false }); + } + return Promise.resolve({ code: blobResult.code, stdout: Buffer.alloc(0), stderr: blobResult.stderr, tooLarge: false }); + }; +} + +// --- The module's own invocation (F19) ------------------------------------ + +test('the blob is read with `cat-file blob`, pinned on the module\'s own argv (mutation target: going back to `git show`)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const seen = []; + const runGit = (args, opts) => { + seen.push(args); + if (args[0] === 'rev-parse') { + return Promise.resolve({ + code: 0, + stdout: execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: opts.cwd, env: scratchGitEnv() }), + stderr: '', + tooLarge: false, + }); + } + return Promise.resolve({ code: 0, stdout: Buffer.from('indexed\n'), stderr: '', tooLarge: false }); + }; + + const unstaged = await readChangesFile({ cwd: repoDir, relPath: 'f.txt', staged: false, maxBytes: MAX_BYTES }, { runGit }); + assert.equal(unstaged.ok, true, unstaged.error); + assert.deepEqual(seen[seen.length - 1], ['cat-file', 'blob', ':f.txt']); + + const staged = await readChangesFile({ cwd: repoDir, relPath: 'f.txt', staged: true, maxBytes: MAX_BYTES }, { runGit }); + assert.equal(staged.ok, true, staged.error); + assert.deepEqual(seen[seen.length - 1], ['cat-file', 'blob', 'HEAD:f.txt']); + } finally { cleanup(tmp); } +}); + +// --- A legitimately odd filename (F17) ------------------------------------ + +test('real git: a file whose name contains `..` is editable; a real traversal still is not', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + withOutsideFile(tmp); + fs.writeFileSync(path.join(repoDir, 'schema..v2.sql'), 'select 1;\n'); + + const opened = await read(repoDir, 'schema..v2.sql', false); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.current, 'select 1;\n'); + + const saved = await save(repoDir, 'schema..v2.sql', 'select 2;\n'); + assert.equal(saved.ok, true, saved.error); + assert.equal(fs.readFileSync(path.join(repoDir, 'schema..v2.sql'), 'utf8'), 'select 2;\n'); + + for (const relPath of ['../outside-secret.txt', 'sub/../../outside-secret.txt', '..', 'a/..']) { + const refused = await read(repoDir, relPath, false); + assert.equal(refused.ok, false, `a real traversal must still be refused: ${relPath}`); + } + } finally { cleanup(tmp); } +}); diff --git a/test/git-changes-file.test.js b/test/git-changes-file.test.js index 8cf11960..6cc372d1 100644 --- a/test/git-changes-file.test.js +++ b/test/git-changes-file.test.js @@ -53,6 +53,26 @@ test('isSafeRepoRelativePath accepts the ordinary paths git status reports', () } }); +// `.git/config` carries core.pager and [alias]: writing it is command execution. +test('both guards reject any .git segment, in any case and at any depth (mutation target: the .git check)', () => { + for (const p of ['.git', '.git/config', '.git/hooks/pre-commit', '.GIT/config', 'sub/.git/config', 'sub/.Git/x', '.git\\\\config']) { + assert.equal(isSafeRepoRelativePath(p), false, p); + assert.equal(isSafeRevPathOperand(p), false, p); + } +}); + +// `..` is a path segment, not a substring: a file may legitimately be named a..b. +test('the traversal check is a segment check, so an ordinary file with two dots in its name is editable', () => { + for (const p of ['schema..v2.sql', 'a..b.txt', 'dir/x..y']) { + assert.equal(isSafeRepoRelativePath(p), true, p); + assert.equal(isSafeRevPathOperand(p), true, p); + } + for (const p of ['..', 'a/..', '../x', 'a/../../b', 'a\\\\..\\\\b']) { + assert.equal(isSafeRepoRelativePath(p), false, p); + assert.equal(isSafeRevPathOperand(p), false, p); + } +}); + // `git show :1:f.txt` reads stage 1 of a conflicted path — a second layer of // revision syntax hiding inside what the renderer called a file path. test('isSafeRevPathOperand rejects the `::` stage syntax that isSafeRepoRelativePath alone allows', () => { From d0eb82accca0a419c3a9e2ce3d2cbafaab5a4f19 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 17:12:28 +0200 Subject: [PATCH 04/29] fix(changes): tell the truth about the open file, and stop losing buffers The editor watches its file and carries the version token, so a session writing it reloads a clean buffer as it happens and a dirty one is reported instead of silently clobbered on the next save. A refused stale save keeps the buffer and offers Reload, which asks before discarding unsaved edits. The staleness notice used to fire on any refresh that found a dirty buffer, whether or not anything had happened, and said the session had changed files either way. It now fires only when the version token says the file moved, and a file that stops being readable or a status refresh that fails are visible while a file is open instead of leaving the panel looking healthy. Three more ways work went missing: - Back, closing the tab and closing the panel destroyed the buffer with no confirmation, which docs/changes-view.md promised they would not. - Two saves in flight at once left the write order to the filesystem. - Two sessions' editors stacked in the one shared host, so after switching away and back the panel showed another session's file above the current one, and typing into the visible-but-wrong editor discarded the keystrokes. A clean selection is also re-pointed at its own refreshed row, so a file the session stages mid-turn is compared against HEAD from then on; inline mode asks for a merge view with no accept/reject chunk buttons, which would revert a working-tree change this panel says it does not touch; and the editor key is built with JSON.stringify rather than an in-band separator, which had put three raw NUL bytes in the source. --- public/file-panel.js | 191 ++++++++++++++--- public/style.css | 3 +- test/dom-file-panel-changes.test.js | 320 ++++++++++++++++++++++++++-- 3 files changed, 470 insertions(+), 44 deletions(-) diff --git a/public/file-panel.js b/public/file-panel.js index d8be08ae..1e350b9a 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -42,6 +42,7 @@ let changesToggleBtn = null; let changesDiffTitleEl = null; let changesDiffModeBtn = null; let changesDiffSaveBtn = null; +let changesDiffReloadBtn = null; let changesDiffNoticeEl = null; let changesDiffHostEl = null; @@ -225,7 +226,7 @@ function initFilePanel() { onSessionIdle((sessionId) => { const state = filePanelState.get(sessionId); if (state && state.currentTab && state.currentTab.type === 'changes') { - refreshChanges(sessionId); + Promise.resolve(refreshChanges(sessionId)).catch(() => {}); } }); } @@ -239,6 +240,7 @@ function handleClose() { const tab = state.currentTab; if (tab) { + if (!confirmDiscardChangesEdits(tab)) return; if (tab.type === 'diff' && !tab.resolved) { window.api.mcpDiffResponse(currentPanelSessionId, tab.diffId, 'reject', null); } @@ -247,6 +249,7 @@ function handleClose() { tab.editorView = null; } if (tab.type === 'changes') { + unwatchChangesFile(currentPanelSessionId, tab); destroyChangesEditor(tab); } if (tab.type === 'file') { @@ -313,6 +316,12 @@ function wireIpcListeners() { window.api.onMcpCloseTab((sessionId, diffId) => { closeDiffByDiffId(sessionId, diffId); }); + + if (window.api.onGitChangesFileChanged) { + window.api.onGitChangesFileChanged((sessionId, filePath) => { + handleChangesFileChanged(sessionId, filePath); + }); + } } // ── Session State Helpers ─────────────────────────────────────────── @@ -404,6 +413,7 @@ function destroyCurrentTab(state) { } } if (tab.type === 'changes') { + unwatchChangesFile(currentPanelSessionId, tab); destroyChangesEditor(tab); } if (tab.type === 'file') { @@ -625,6 +635,7 @@ function handleDiffAction(sessionId, tab, action) { function toggleChangesTab(sessionId) { const state = getSessionState(sessionId); if (state.currentTab && state.currentTab.type === 'changes') { + if (!confirmDiscardChangesEdits(state.currentTab)) return; destroyCurrentTab(state); state.currentTab = null; state.panelVisible = false; @@ -657,9 +668,13 @@ function openChangesTab(sessionId) { editorKey: null, editorMode: null, editorPending: null, + version: null, + watchedPath: null, fallbackReason: null, + fileError: null, saveError: null, - stale: false, + saving: false, + externalChange: false, }; state.panelVisible = true; @@ -694,27 +709,40 @@ async function refreshChanges(sessionId) { tab.remote = result.kind === 'remote'; } if (currentPanelSessionId === sessionId) renderPanel(sessionId); - return syncOpenChangesFile(sessionId, tab); + return syncOpenChangesFile(sessionId, tab).catch(() => {}); } // A refresh may never replace what the user is typing into — see .ai/contexts/changes-view.md async function syncOpenChangesFile(sessionId, tab) { - if (!tab.selectedFile || !tab.editable || !tab.editorView) return; + if (!tab.selectedFile || !tab.editable) return; - if (isChangesBufferDirty(tab)) { - tab.stale = true; - if (currentPanelSessionId === sessionId) renderPanel(sessionId); - return; - } + if (!isChangesBufferDirty(tab)) repointSelectedFile(tab); const file = tab.selectedFile; const result = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); const stillState = filePanelState.get(sessionId); if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; - if (isChangesBufferDirty(tab)) return; - if (!result || result.ok === false) return; - if (result.current === tab.current && result.original === tab.original) return; + + if (!result || result.ok === false) { + tab.fileError = (result && result.error) || 'this file could not be read'; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + tab.fileError = null; + + if (isChangesBufferDirty(tab)) { + tab.externalChange = result.version !== tab.version; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + + tab.version = result.version; + tab.externalChange = false; + if (result.current === tab.current && result.original === tab.original) { + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } tab.original = result.original; tab.current = result.current; @@ -723,6 +751,39 @@ async function syncOpenChangesFile(sessionId, tab) { if (currentPanelSessionId === sessionId) renderPanel(sessionId); } +function repointSelectedFile(tab) { + if (!tab.selectedFile || !tab.data || !Array.isArray(tab.data.files)) return; + const record = tab.data.files.find((f) => f.path === tab.selectedFile.path); + if (!record) return; + tab.selectedFile = { + path: record.path, + staged: !!record.staged && !record.unstaged, + untracked: !!record.untracked, + }; +} + +function handleChangesFileChanged(sessionId, filePath) { + const state = filePanelState.get(sessionId); + const tab = state && state.currentTab; + if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; + if (tab.selectedFile.path !== filePath || tab.saving) return; + return syncOpenChangesFile(sessionId, tab).catch(() => {}); +} + +function watchChangesFile(sessionId, tab, filePath) { + unwatchChangesFile(sessionId, tab); + tab.watchedPath = filePath; + const watch = window.api.gitChangesWatch; + if (watch) Promise.resolve(watch(sessionId, filePath)).catch(() => {}); +} + +function unwatchChangesFile(sessionId, tab) { + if (!tab.watchedPath) return; + const unwatch = window.api.gitChangesUnwatch; + if (unwatch) Promise.resolve(unwatch(sessionId, tab.watchedPath)).catch(() => {}); + tab.watchedPath = null; +} + async function openChangesDiff(sessionId, file) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; @@ -737,10 +798,14 @@ async function openChangesDiff(sessionId, file) { tab.original = null; tab.current = null; tab.savedContent = null; + tab.version = null; tab.fallbackReason = null; + tab.fileError = null; tab.saveError = null; - tab.stale = false; + tab.saving = false; + tab.externalChange = false; destroyChangesEditor(tab); + unwatchChangesFile(sessionId, tab); tab.diffLoading = true; if (currentPanelSessionId === sessionId) renderPanel(sessionId); @@ -757,6 +822,8 @@ async function openChangesDiff(sessionId, file) { tab.original = pair.original; tab.current = pair.current; tab.savedContent = pair.current; + tab.version = pair.version; + watchChangesFile(sessionId, tab, file.path); if (file.untracked) applyUntrackedCounts(tab, file.path, countAddedLines(pair.current), 0); if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; @@ -817,6 +884,8 @@ function closeChangesDiff(sessionId) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; + if (!confirmDiscardChangesEdits(tab)) return; + unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); tab.selectedFile = null; tab.diffContent = null; @@ -825,12 +894,21 @@ function closeChangesDiff(sessionId) { tab.original = null; tab.current = null; tab.savedContent = null; + tab.version = null; tab.fallbackReason = null; + tab.fileError = null; tab.saveError = null; - tab.stale = false; + tab.saving = false; + tab.externalChange = false; if (currentPanelSessionId === sessionId) renderPanel(sessionId); } +function confirmDiscardChangesEdits(tab) { + if (!tab || tab.type !== 'changes' || !isChangesBufferDirty(tab)) return true; + if (typeof window.confirm !== 'function') return true; + return window.confirm('This file has unsaved edits. Discard them?'); +} + function renderChangesContent(sessionId, tab) { if (tab.selectedFile) { changesSummaryEl.style.display = 'none'; @@ -965,6 +1043,16 @@ function buildChangesDiffChrome() { changesDiffModeBtn.addEventListener('click', handleChangesDiffModeToggle); controls.appendChild(changesDiffModeBtn); + changesDiffReloadBtn = document.createElement('button'); + changesDiffReloadBtn.className = 'fp-toolbar-btn'; + changesDiffReloadBtn.id = 'changes-diff-reload-btn'; + changesDiffReloadBtn.textContent = 'Reload'; + changesDiffReloadBtn.title = 'Re-read this file from disk'; + changesDiffReloadBtn.addEventListener('click', () => { + if (currentPanelSessionId) reloadChangesFile(currentPanelSessionId); + }); + controls.appendChild(changesDiffReloadBtn); + changesDiffSaveBtn = document.createElement('button'); changesDiffSaveBtn.className = 'fp-toolbar-btn fp-save-btn'; changesDiffSaveBtn.id = 'changes-diff-save-btn'; @@ -999,6 +1087,8 @@ function renderChangesDiff(sessionId, tab) { changesDiffModeBtn.textContent = CHANGES_DIFF_MODE_LABELS[changesDiffMode]; changesDiffModeBtn.title = 'Diff view mode — click to cycle'; changesDiffSaveBtn.style.display = tab.editable ? '' : 'none'; + changesDiffSaveBtn.disabled = !!tab.saving; + changesDiffReloadBtn.style.display = tab.editable ? '' : 'none'; renderChangesNotice(tab); @@ -1028,31 +1118,34 @@ function renderChangesDiff(sessionId, tab) { function renderChangesNotice(tab) { const notes = []; + const alarming = !!(tab.saveError || tab.fileError || tab.error || tab.externalChange); if (tab.remote) notes.push('Remote session — read-only.'); if (tab.fallbackReason) notes.push(`${tab.fallbackReason} — showing the diff read-only.`); if (tab.diffTruncated) notes.push('Diff truncated at 512 KB.'); - if (tab.stale) notes.push('This session changed files while you were editing — your unsaved edits are kept, the diff may be out of date.'); + if (tab.externalChange) notes.push('This file changed on disk since you opened it — reload before saving, or your edits will not be accepted.'); + if (tab.fileError) notes.push(`This file can no longer be read: ${tab.fileError}`); + if (tab.error) notes.push(`The file list could not be refreshed: ${tab.error}`); if (tab.saveError) notes.push(`Save failed: ${tab.saveError}`); changesDiffNoticeEl.textContent = notes.join(' '); changesDiffNoticeEl.style.display = notes.length ? '' : 'none'; - changesDiffNoticeEl.classList.toggle('changes-error', !!tab.saveError); - changesDiffNoticeEl.classList.toggle('changes-diff-truncated', !tab.saveError); + changesDiffNoticeEl.classList.toggle('changes-error', alarming); + changesDiffNoticeEl.classList.toggle('changes-diff-truncated', !alarming); } // see .ai/contexts/changes-view.md ("The render path is not a teardown") function ensureChangesEditor(sessionId, tab) { const key = changesEditorKey(tab); if (tab.editorView && tab.editorKey === key && tab.editorMode === changesDiffMode) { - if (tab.editorView.dom.parentNode !== changesDiffHostEl) changesDiffHostEl.appendChild(tab.editorView.dom); + mountChangesEditor(tab.editorView.dom); return; } - if (tab.editorPending === key + '' + changesDiffMode) return; + const token = JSON.stringify([key, changesDiffMode]); + if (tab.editorPending === token) return; destroyChangesEditor(tab); changesDiffHostEl.innerHTML = ''; - const token = key + '' + changesDiffMode; const mode = changesDiffMode; tab.editorPending = token; @@ -1066,7 +1159,8 @@ function ensureChangesEditor(sessionId, tab) { if (mode === 'plain') { tab.editorView = window.createEditableViewer(changesDiffHostEl, tab.current, filename); } else if (mode === 'inline') { - tab.editorView = window.createUnifiedMergeViewer(changesDiffHostEl, tab.original, tab.current, filename); + // mergeControls: false — this panel is not a git client, see .ai/contexts/changes-view.md + tab.editorView = window.createUnifiedMergeViewer(changesDiffHostEl, tab.original, tab.current, filename, { mergeControls: false }); } else { tab.editorView = window.createMergeViewer(changesDiffHostEl, tab.original, tab.current, filename); } @@ -1078,9 +1172,17 @@ function ensureChangesEditor(sessionId, tab) { }); } +// see .ai/contexts/changes-view.md ("A dirty buffer is never overwritten, and never lied to") +function mountChangesEditor(dom) { + for (const child of Array.from(changesDiffHostEl.children)) { + if (child !== dom) changesDiffHostEl.removeChild(child); + } + if (dom.parentNode !== changesDiffHostEl) changesDiffHostEl.appendChild(dom); +} + function changesEditorKey(tab) { const file = tab.selectedFile; - return file ? file.path + '' + (file.staged ? '1' : '0') : ''; + return file ? JSON.stringify([file.path, !!file.staged]) : ''; } function destroyChangesEditor(tab) { @@ -1130,31 +1232,70 @@ function handleChangesDiffModeToggle() { async function handleChangesSave(sessionId) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; - if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; + if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile || tab.saving) return; const content = readChangesEditorContent(tab); if (content == null) return; const file = tab.selectedFile; - const result = await window.api.gitChangesSave(sessionId, file.path, content); + tab.saving = true; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + + let result; + try { + result = await window.api.gitChangesSave(sessionId, file.path, content, tab.version); + } finally { + tab.saving = false; + } const stillState = filePanelState.get(sessionId); if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; if (!result || result.ok === false) { tab.saveError = (result && result.error) || 'failed to save'; + if (result && result.reason === 'stale') tab.externalChange = true; if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; } tab.saveError = null; - tab.stale = false; + tab.externalChange = false; tab.current = content; tab.savedContent = content; + if (result.version) tab.version = result.version; if (typeof window.flashButtonText === 'function') window.flashButtonText(changesDiffSaveBtn, 'Saved!'); return refreshChanges(sessionId); } +async function reloadChangesFile(sessionId) { + const state = filePanelState.get(sessionId); + const tab = state && state.currentTab; + if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; + if (!confirmDiscardChangesEdits(tab)) return; + + const file = tab.selectedFile; + const result = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); + + const stillState = filePanelState.get(sessionId); + if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; + + if (!result || result.ok === false) { + tab.fileError = (result && result.error) || 'this file could not be read'; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + + tab.fileError = null; + tab.externalChange = false; + tab.saveError = null; + tab.original = result.original; + tab.current = result.current; + tab.savedContent = result.current; + tab.version = result.version; + destroyChangesEditor(tab); + if (currentPanelSessionId === sessionId) renderPanel(sessionId); +} + // see .ai/contexts/changes-view.md ("why not ViewerPanel for the diff") function renderDiffLines(container, text) { const frag = document.createDocumentFragment(); diff --git a/public/style.css b/public/style.css index 866f5c04..8042d2f2 100644 --- a/public/style.css +++ b/public/style.css @@ -4411,8 +4411,7 @@ body { display: flex; flex-direction: column; } background: rgba(120,130,255,0.10); } -/* Editor hosts: the MCP diff tab and the Changes panel's editable diff share - every rule below — a merge view looks the same wherever it is mounted. */ +/* Editor hosts — see .ai/contexts/viewer-panel.md */ #file-panel-body, #changes-diff-host { flex: 1; diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index c2523e19..1af44b78 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -51,7 +51,7 @@ function makeStatusResult(overrides = {}) { }; } -const DEFAULT_PAIR = { ok: true, original: 'old\n', current: 'new\n', binary: false, truncated: false }; +const DEFAULT_PAIR = { ok: true, original: 'old\n', current: 'new\n', version: 'v1', binary: false, truncated: false }; // A stand-in for a CodeMirror view: it owns a DOM node, reports a document // the test can rewrite (typing), and records its own destruction — enough for @@ -75,12 +75,13 @@ function makeEditorStub(window, mode, doc, created) { return view; } -function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl } = {}) { +function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmImpl } = {}) { const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; - const calls = { status: [], diff: [], file: [], save: [] }; + const calls = { status: [], diff: [], file: [], save: [], watch: [], unwatch: [], confirm: [] }; const editors = []; + const fileChangedListeners = []; window.api = { onMcpOpenDiff: () => {}, @@ -99,10 +100,26 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl } = {}) { calls.file.push({ sessionId, filePath, staged: !!(opts && opts.staged) }); return Promise.resolve((fileImpl || (() => DEFAULT_PAIR))(sessionId, filePath, opts)); }, - gitChangesSave: (sessionId, filePath, content) => { - calls.save.push({ sessionId, filePath, content }); - return Promise.resolve((saveImpl || (() => ({ ok: true })))(sessionId, filePath, content)); + gitChangesSave: (sessionId, filePath, content, version) => { + calls.save.push({ sessionId, filePath, content, version }); + return Promise.resolve((saveImpl || (() => ({ ok: true, version: 'v2' })))(sessionId, filePath, content, version)); }, + gitChangesWatch: (sessionId, filePath) => { + calls.watch.push({ sessionId, filePath }); + return Promise.resolve({ ok: true }); + }, + gitChangesUnwatch: (sessionId, filePath) => { + calls.unwatch.push({ sessionId, filePath }); + return Promise.resolve({ ok: true }); + }, + onGitChangesFileChanged: (cb) => { fileChangedListeners.push(cb); }, + }; + + // jsdom's own window.confirm throws "not implemented"; the panel asks before + // discarding unsaved edits, so the suite answers for the user. + window.confirm = (message) => { + calls.confirm.push(message); + return confirmImpl ? confirmImpl(message) : true; }; // file-panel.js defers every editor to the lazy bundle loader; the suite @@ -114,9 +131,10 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl } = {}) { parent.appendChild(view.dom); return view; }; - window.createUnifiedMergeViewer = (parent, original, modified, filename) => { + window.createUnifiedMergeViewer = (parent, original, modified, filename, opts) => { const view = makeEditorStub(window, 'inline', modified, editors); view.opened = { original, modified, filename }; + view.opts = opts; parent.appendChild(view.dom); return view; }; @@ -149,15 +167,20 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl } = {}) { document: window.document, calls, editors, + fireFileChanged: (sessionId, filePath) => { + for (const cb of fileChangedListeners) cb(sessionId, filePath); + }, setActivity: read('setActivity'), destroy: () => window.close(), }; } function flush() { - // Four microtask turns: the status IPC, the content-pair IPC chained off it, - // the bundle loader, and whatever chains off that. - return Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()).then(() => Promise.resolve()); + // The chains run several IPC round trips deep (save -> status -> content pair + // -> bundle loader), so drain a generous number of microtask turns. + let p = Promise.resolve(); + for (let i = 0; i < 12; i++) p = p.then(() => Promise.resolve()); + return p; } function clickRow(ctx, filePath) { @@ -688,25 +711,42 @@ test('an idle refresh does not detach the editor from the DOM either (mutation t } finally { ctx.destroy(); } }); -test('an idle refresh leaves a dirty buffer alone and says it may be out of date (mutation target: the dirty-buffer guard)', async () => { +test('an idle refresh never replaces a dirty buffer, and says the file moved under it (mutation target: the dirty-buffer guard)', async () => { let current = 'new\n'; - const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current }) }); + let version = 'v1'; + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current, version }) }); try { await openFile(ctx, 's1', 'src/a.js'); const editor = ctx.editors[0]; editor.box.text = 'typed by the user\n'; current = 'written by the session\n'; + version = 'v2'; ctx.setActivity('s1', true); ctx.setActivity('s1', false); await flush(); - assert.equal(ctx.calls.file.length, 1, 'a dirty buffer is never re-read from disk'); - assert.equal(ctx.editors.length, 1); + assert.equal(ctx.editors.length, 1, 'no second editor'); assert.equal(editor.box.destroyed, false); assert.equal(editor.box.text, 'typed by the user\n', 'the unsaved edit survives'); - assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /out of date/); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /changed on disk/); + } finally { ctx.destroy(); } +}); + +test('a dirty buffer over an unchanged file says nothing at all (mutation target: claiming staleness the code never checked)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'typed by the user\n'; + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.equal(ctx.editors[0].box.text, 'typed by the user\n'); + assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none', + 'nothing changed on disk, so there is nothing to warn about'); } finally { ctx.destroy(); } }); @@ -738,7 +778,7 @@ test('Save writes the edited buffer through git-changes-save and refreshes the s ctx.document.getElementById('changes-diff-save-btn').click(); await flush(); - assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited\n' }]); + assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited\n', version: 'v1' }]); assert.equal(ctx.calls.status.length, 2, 'the row counts are re-read after a save'); assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none'); } finally { ctx.destroy(); } @@ -753,7 +793,7 @@ test('Ctrl/Cmd+S from the editor saves the same way the button does', async () = ctx.editors[0].dom.dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); await flush(); - assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited by keyboard\n' }]); + assert.deepEqual(ctx.calls.save, [{ sessionId: 's1', filePath: 'src/a.js', content: 'edited by keyboard\n', version: 'v1' }]); } finally { ctx.destroy(); } }); @@ -856,6 +896,252 @@ test('closing the panel and going back to the list both destroy the editor (muta } finally { ctx.destroy(); } }); +// --- Staleness: the file moving under the editor ------------------------- + +test('a save carries the version token from the read, and a refused stale save keeps the buffer and says so (mutation target: the staleness refusal)', async () => { + const ctx = setupFilePanelDom({ + saveImpl: () => ({ ok: false, error: 'this file changed on disk since it was opened', reason: 'stale' }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.equal(ctx.calls.save[0].version, 'v1', 'the token the read handed out goes back with the write'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /changed on disk/); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /Save failed/); + assert.equal(ctx.editors[0].box.text, 'my edit\n', 'the refusal costs the user nothing'); + assert.equal(ctx.calls.status.length, 1, 'a refused save must not claim the tree changed'); + } finally { ctx.destroy(); } +}); + +test('the open file is watched while it is editable, and unwatched on the way out', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.deepEqual(ctx.calls.watch, [{ sessionId: 's1', filePath: 'src/a.js' }]); + + backBtn(ctx).click(); + await flush(); + assert.deepEqual(ctx.calls.unwatch, [{ sessionId: 's1', filePath: 'src/a.js' }]); + } finally { ctx.destroy(); } +}); + +test('a watcher event reloads a clean buffer without waiting for the session to go idle', async () => { + let current = 'new\n'; + let version = 'v1'; + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current, version }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.equal(ctx.editors.length, 1); + + current = 'written by the session\n'; + version = 'v2'; + ctx.fireFileChanged('s1', 'src/a.js'); + await flush(); + + assert.equal(ctx.editors.length, 2, 'the editor picked up the session\'s write'); + assert.equal(ctx.editors[1].opened.modified, 'written by the session\n'); + } finally { ctx.destroy(); } +}); + +test('a watcher event on a dirty buffer warns instead of reloading (mutation target: the watcher clobbering the buffer)', async () => { + let current = 'new\n'; + let version = 'v1'; + const ctx = setupFilePanelDom({ fileImpl: () => ({ ok: true, original: 'old\n', current, version }) }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + current = 'written by the session\n'; + version = 'v2'; + ctx.fireFileChanged('s1', 'src/a.js'); + await flush(); + + assert.equal(ctx.editors.length, 1); + assert.equal(ctx.editors[0].box.text, 'my edit\n'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /changed on disk/); + } finally { ctx.destroy(); } +}); + +test('Reload asks before discarding unsaved edits and re-reads the file when allowed', async () => { + let current = 'new\n'; + let allow = false; + const ctx = setupFilePanelDom({ + fileImpl: () => ({ ok: true, original: 'old\n', current, version: 'v1' }), + confirmImpl: () => allow, + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + current = 'session content\n'; + + ctx.document.getElementById('changes-diff-reload-btn').click(); + await flush(); + assert.equal(ctx.editors.length, 1, 'a declined confirm keeps the buffer'); + assert.equal(ctx.editors[0].box.text, 'my edit\n'); + + allow = true; + ctx.document.getElementById('changes-diff-reload-btn').click(); + await flush(); + assert.equal(ctx.editors.length, 2); + assert.equal(ctx.editors[1].opened.modified, 'session content\n'); + } finally { ctx.destroy(); } +}); + +// --- Losing work by accident --------------------------------------------- + +test('Back asks before discarding unsaved edits, and a refusal keeps the editor (mutation target: the confirm)', async () => { + const ctx = setupFilePanelDom({ confirmImpl: () => false }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + backBtn(ctx).click(); + await flush(); + + assert.equal(ctx.calls.confirm.length, 1); + assert.equal(ctx.editors[0].box.destroyed, false, 'the buffer is still there'); + assert.equal(ctx.document.getElementById('changes-diff-view').style.display, 'flex'); + } finally { ctx.destroy(); } +}); + +test('closing the tab and closing the panel both ask before discarding unsaved edits', async () => { + const ctx = setupFilePanelDom({ confirmImpl: () => false }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + ctx.document.getElementById('changes-toggle-btn').click(); + assert.equal(ctx.editors[0].box.destroyed, false, 'the Changes button must not drop the buffer silently'); + + ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); + assert.equal(ctx.editors[0].box.destroyed, false, 'neither must the panel close button'); + assert.equal(ctx.calls.confirm.length, 2); + } finally { ctx.destroy(); } +}); + +test('two saves in a row issue one write (mutation target: the in-flight guard)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + // Ctrl+S twice: the keyboard path does not consult the button's disabled + // state, so only the in-flight guard itself can stop the second write. + ctx.editors[0].dom.dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); + ctx.editors[0].dom.dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); + await flush(); + + assert.equal(ctx.calls.save.length, 1, 'the second save lands while the first write is in flight'); + + const saveBtn = ctx.document.getElementById('changes-diff-save-btn'); + saveBtn.click(); + saveBtn.click(); + await flush(); + assert.equal(ctx.calls.save.length, 2, 'and the button is disabled for the duration too'); + } finally { ctx.destroy(); } +}); + +// --- One host, one editor ------------------------------------------------- + +test('another session\'s editor never stacks in the shared host (mutation target: mounting without clearing)', async () => { + const ctx = setupFilePanelDom({ + fileImpl: (sessionId) => ({ ok: true, original: 'old\n', current: 'content of ' + sessionId + '\n', version: 'v1' }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + await openFile(ctx, 's2', 'src/a.js'); + + ctx.window.switchPanel('s1'); + await flush(); + + const host = ctx.document.getElementById('changes-diff-host'); + assert.equal(host.children.length, 1, 'exactly one editor on screen'); + assert.equal(host.children[0], ctx.editors[0].dom, 's1\'s own editor, not s2\'s'); + + ctx.window.switchPanel('s2'); + await flush(); + assert.equal(host.children.length, 1); + assert.equal(host.children[0], ctx.editors[1].dom); + } finally { ctx.destroy(); } +}); + +// --- Things going wrong while a file is open ------------------------------ + +test('a file that disappears under the editor says so instead of showing a phantom', async () => { + let gone = false; + const ctx = setupFilePanelDom({ + fileImpl: () => (gone + ? { ok: false, error: 'file is not in the working tree', reason: 'missing' } + : { ok: true, original: 'old\n', current: 'new\n', version: 'v1' }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + gone = true; + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /can no longer be read/); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /not in the working tree/); + } finally { ctx.destroy(); } +}); + +test('a status refresh that fails while a file is open is visible in the diff view', async () => { + let broken = false; + const ctx = setupFilePanelDom({ + statusImpl: () => (broken ? { ok: false, error: 'fatal: not a git repository' } : makeStatusResult()), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + broken = true; + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /not a git repository/); + } finally { ctx.destroy(); } +}); + +test('staging the open file mid-turn re-points the selection at its refreshed row', async () => { + let staged = false; + const statusImpl = () => makeStatusResult({ + files: [{ path: 'src/a.js', origPath: null, staged, unstaged: !staged, untracked: false, renamed: false, state: 'M', added: 3, deleted: 1 }], + totals: { files: 1, added: 3, deleted: 1 }, + }); + const ctx = setupFilePanelDom({ statusImpl }); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.equal(ctx.calls.file[0].staged, false); + + staged = true; + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + + assert.equal(ctx.calls.file[ctx.calls.file.length - 1].staged, true, + 'the pair is re-read against HEAD once the file is staged'); + } finally { ctx.destroy(); } +}); + +test('inline mode asks for a merge view with no accept/reject controls — this panel is not a git client', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.document.getElementById('changes-diff-mode-btn').click(); + await flush(); + + const inline = ctx.editors[ctx.editors.length - 1]; + assert.equal(inline.box.mode, 'inline'); + assert.equal(inline.opts.mergeControls, false, 'accept/reject chunk controls would revert working-tree changes'); + } finally { ctx.destroy(); } +}); + test('the panel close button destroys the editor too', async () => { const ctx = setupFilePanelDom(); try { From dd17c566454ab4c00ce37648c8774df79854b019 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 17:12:43 +0200 Subject: [PATCH 05/29] fix(codemirror): make the merge view an editing surface, not a viewer you can type in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side-by-side merge view is the Changes panel's default writing surface, and its editable side carried none of the extensions writing needs: Ctrl/Cmd+S raised nothing at all, and there was no undo, no default keymap and no indent-on-input. It now has history, the default keymap, indentWithTab, drawSelection and the save keymap — the DOM-level save handler stays with the read-only viewers, since an editable view with both raises two saves per keystroke. The inline view gains the same editing extensions and a mergeControls option: per-chunk Accept/Reject restores the original side into the document, which is a working-tree revert. The MCP diff tab keeps the buttons; the Changes panel turns them off. The tests drive the real thing — codemirror-setup.js imported as the ES module it is, under jsdom, with a real keydown — because a stub that dispatches the save event itself proves only that the stub works. --- public/codemirror-setup.js | 20 +++- test/codemirror-merge-editing.test.js | 149 ++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 test/codemirror-merge-editing.test.js diff --git a/public/codemirror-setup.js b/public/codemirror-setup.js index e0d4cdd0..65d01d06 100644 --- a/public/codemirror-setup.js +++ b/public/codemirror-setup.js @@ -488,7 +488,17 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) { }, b: { doc: modifiedContent, - extensions: [...sharedExts], + extensions: [ + ...sharedExts, + history(), + drawSelection(), + indentOnInput(), + highlightActiveLine(), + highlightActiveLineGutter(), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), + cmGotoLineKeymap, + cmSaveKeymap, + ], }, gutter: true, highlightChanges: true, @@ -496,7 +506,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) { }); } -function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename) { +function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename, { mergeControls = true } = {}) { const langExt = getLanguageExt(filename); const state = EditorState.create({ doc: modifiedContent, @@ -506,7 +516,10 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file foldGutter(), bracketMatching(), highlightSelectionMatches(), - keymap.of([...foldKeymap]), + history(), + drawSelection(), + indentOnInput(), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap]), cmFindKeymap, cmFindDomHandler, cmGotoLineDomHandler, @@ -521,6 +534,7 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file gutter: true, highlightChanges: true, syntaxHighlightDeletions: true, + mergeControls, collapseUnchanged: { margin: 3, minSize: 4 }, }), ], diff --git a/test/codemirror-merge-editing.test.js b/test/codemirror-merge-editing.test.js new file mode 100644 index 00000000..45090ab3 --- /dev/null +++ b/test/codemirror-merge-editing.test.js @@ -0,0 +1,149 @@ +'use strict'; + +// The editing surface of the Changes panel is a real CodeMirror merge view, so +// these tests drive the real one: public/codemirror-setup.js is imported as the +// ES module it is, under jsdom, and the assertions are about what a keystroke +// does to it — not about a stub that always answers correctly. +// +// jsdom has no layout, so CodeMirror's measuring phase throws inside its own +// requestAnimationFrame callbacks; the stubs below give it enough of a Range to +// stay quiet, and the jsdom virtual console swallows the rest. None of that +// touches the keymap, the history or the document, which is what is asserted. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const SETUP = path.join(__dirname, '..', 'public', 'codemirror-setup.js'); + +let cmWindow = null; + +async function loadCodeMirror() { + if (cmWindow) return cmWindow; + + const virtualConsole = new VirtualConsole(); + virtualConsole.on('jsdomError', () => {}); + const dom = new JSDOM('', { pretendToBeVisual: true, virtualConsole }); + const { window } = dom; + + window.Range.prototype.getClientRects = () => []; + window.Range.prototype.getBoundingClientRect = () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }); + window.Element.prototype.getClientRects = () => []; + + global.window = window; + global.document = window.document; + Object.defineProperty(global, 'navigator', { value: window.navigator, configurable: true }); + for (const name of ['CustomEvent', 'Event', 'KeyboardEvent', 'HTMLElement', 'Element', 'Node', 'Text', + 'MutationObserver', 'DOMParser', 'Range', 'getComputedStyle']) { + global[name] = window[name]; + } + global.Window = window.constructor; + global.requestAnimationFrame = (cb) => setTimeout(cb, 0); + global.cancelAnimationFrame = (id) => clearTimeout(id); + + await import(pathToFileURL(SETUP).href); + cmWindow = window; + return window; +} + +function pressCtrlS(window, element) { + element.dispatchEvent(new window.KeyboardEvent('keydown', { + key: 's', code: 'KeyS', keyCode: 83, ctrlKey: true, bubbles: true, cancelable: true, + })); +} + +test('real CodeMirror: Ctrl/Cmd+S in the side-by-side editing pane raises exactly one cm-save, and it bubbles to the panel container', async () => { + const window = await loadCodeMirror(); + const container = window.document.createElement('div'); + window.document.body.appendChild(container); + const host = window.document.createElement('div'); + container.appendChild(host); + + const view = window.createMergeViewer(host, 'old\n', 'new\n', 'a.js'); + try { + let saves = 0; + container.addEventListener('cm-save', () => { saves++; }); + + pressCtrlS(window, view.b.contentDOM); + assert.equal(saves, 1, 'the default editing mode must answer the documented keybinding, exactly once'); + + pressCtrlS(window, view.b.contentDOM); + assert.equal(saves, 2); + } finally { + view.destroy(); + container.remove(); + } +}); + +test('real CodeMirror: the side-by-side editing pane is editable and has undo', async () => { + const window = await loadCodeMirror(); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + + const view = window.createMergeViewer(host, 'old\n', 'new\n', 'a.js'); + try { + assert.equal(view.b.state.readOnly, false, 'the working-tree side is the one you type in'); + assert.equal(view.a.state.readOnly, true, 'the original side is not'); + + view.b.dispatch({ changes: { from: 0, insert: 'typed ' } }); + assert.equal(view.b.state.doc.toString(), 'typed new\n'); + + const { undo } = require('@codemirror/commands'); + assert.equal(undo(view.b), true, 'undo needs the history extension to be installed'); + assert.equal(view.b.state.doc.toString(), 'new\n'); + } finally { + view.destroy(); + host.remove(); + } +}); + +test('real CodeMirror: the inline merge view drops the accept/reject chunk controls when asked', async () => { + const window = await loadCodeMirror(); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + + const withControls = window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js'); + const withControlsHtml = host.innerHTML; + withControls.destroy(); + host.innerHTML = ''; + + const without = window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js', { mergeControls: false }); + try { + assert.match(withControlsHtml, /cm-chunkButtons|Accept|Reject/, + 'the default carries the chunk controls, which is what the Changes panel opts out of'); + assert.doesNotMatch(host.innerHTML, /cm-chunkButtons/); + assert.doesNotMatch(host.innerHTML, /Reject/); + } finally { + without.destroy(); + host.remove(); + } +}); + +test('real CodeMirror: Ctrl/Cmd+S also raises cm-save in the inline and plain editing modes', async () => { + const window = await loadCodeMirror(); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + + const inline = window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js', { mergeControls: false }); + try { + let saves = 0; + host.addEventListener('cm-save', () => { saves++; }); + pressCtrlS(window, inline.contentDOM); + assert.ok(saves >= 1, 'inline mode saves too'); + } finally { + inline.destroy(); + } + + const plain = window.createEditableViewer(host, 'new\n', 'a.js'); + try { + let saves = 0; + host.addEventListener('cm-save', () => { saves++; }); + pressCtrlS(window, plain.contentDOM); + assert.ok(saves >= 1, 'plain mode saves too'); + } finally { + plain.destroy(); + host.remove(); + } +}); From c16bc0f90e07fea504ca0b3876a6663ee73958da Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Thu, 17 Sep 2026 17:12:43 +0200 Subject: [PATCH 06/29] docs(changes): state the staleness, encoding and containment rules the panel now holds to The context docs and the user-facing page describe the version token and the watcher, the line-ending and encoding round trip, the .git and symlink refusals, what the notice line can and cannot claim, and the confirmations before unsaved edits are discarded. docs/changes-view.md keeps its "not a git client" line and now names the inline view's missing revert buttons under it. --- .ai/contexts/changes-view.md | 104 ++++++++++++++++++++++++++++------- .ai/contexts/ipc-bridge.md | 9 +-- .ai/contexts/viewer-panel.md | 21 ++++++- docs/changes-view.md | 16 +++++- 4 files changed, 121 insertions(+), 29 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 6cc628c5..3ebc2c51 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -326,15 +326,16 @@ path. The renderer never learns where the repository is: the session's cwd is re-resolved through `resolveGitChangesTarget` on **every** call, the absolute path is built from it, used, and discarded main-side. -`git-changes-file` returns `{ok, original, current, binary, truncated}`. +`git-changes-file` returns `{ok, original, current, version, binary, truncated}`. `original` is the side `git diff` itself compares against, so the diff the panel draws and the diff `git diff` would print cannot disagree: the index (`:`) for the unstaged view, `HEAD:` for the staged one. A path -absent from that tree exits 128 — that is the untracked/new-file case, and it -yields `original: ''` rather than an error. The repository's own validity is -established before that call (`git rev-parse --show-toplevel` has already -succeeded), which is what makes "non-zero means the path is not in this tree" -safe to read that way. +absent from that tree exits **128** — that is the untracked/new-file case, and +it alone yields `original: ''`. Every other exit code is an error +(`reason: 'git'`): a timeout, a killed child, a missing binary and an unreadable +object are not "this file is new", and rendering them as an all-additions diff +would contradict the panel's own contract that what it marks as changed is what +git would report. ### `git cat-file blob`, not `git show` @@ -364,9 +365,15 @@ syntax from revision magic. So the operand carries its own pair of guards (`git-changes-file.js`): - `isSafeRepoRelativePath` — non-empty, ≤ 4096 chars, no control characters - (NUL, newline, carriage return included), no `..`, not absolute (`/`, `\`, - `X:`), no leading `-`, no leading `:`. This is what `git-changes-save` uses, - because its operand is a filesystem path and nothing else. + (NUL, newline, carriage return included), not absolute (`/`, `\`, `X:`), no + leading `-`, no leading `:`, and no `..` or `.git` **segment**. This is what + `git-changes-save` uses, because its operand is a filesystem path and nothing + else. Both segment rules are split on `/` and `\` rather than matched as + substrings: `a..b.txt` and `dotgit.md` are ordinary filenames, while `a/../b` + and `.git/config` are not paths this panel will touch. `.git` is inside the + repository root, so containment alone does not exclude it, and `.git/config` + carries `core.pager`, `core.fsmonitor` and `[alias]` — writing it is command + execution the next time any git command runs there. - `isSafeRevPathOperand` — the above **plus** no `^[0-9]+:` prefix, which would turn `:` into `::`, git's conflict-stage syntax. A file literally named `1:f.txt` is therefore not editable from the panel: an @@ -384,9 +391,15 @@ relative to the root, and a session whose cwd is a subdirectory of the repo must still open its own repository's files. The root is computed by git from the already-resolved cwd — it is never a renderer-supplied string. -`resolveTargetInsideRepo` resolves both the root and `path.join(root, relPath)` -**on disk** (`resolveOnDisk`), requires the real target to be the real root or -beneath it, applies `isSensitivePath`, and requires a regular file. It returns +`resolveTargetInsideRepo` `lstat`s `path.join(root, relPath)` first and refuses +a **symbolic link** outright (`reason: 'symlink'`): a symlink's content in a git +working tree is its target string, so the pair would be the link text against +the target's content, and a save would land on a file the row does not name. +It then resolves both the root and the joined path **on disk** +(`resolveOnDisk`), requires the real target to be the real root or beneath it — +which is what catches an escape through a symlinked *directory*, whose last +component is an ordinary file — applies `isSensitivePath`, and requires a +regular file. It returns that single resolved path, and the read and the write run on **that** value — the TOCTOU rule `ipc-path-validator.js` documents for `resolveAllowedMemoryPath`: two independent resolutions of the same string are @@ -399,14 +412,52 @@ at all — it takes an absolute path from an OSC 8 terminal hyperlink and checks only `isSensitivePath`. A handler whose entire input is a *relative* path from the renderer has no such excuse, so it does not inherit that shape. -### Caps, and why an oversized file is refused rather than truncated +### Saving over a file that moved + +The premise of this panel is that it sits beside a session writing the same +files, so "the file changed since it was read" is the normal case, not an edge +case. Two independent layers: + +1. **A version token.** `git-changes-file` returns `version` — the SHA-1 of the + bytes it read, plus their length — as opaque data. `git-changes-save` + requires it back, re-reads the file, and refuses with `reason: 'stale'` when + it no longer matches; the write never happens. A save with no token at all is + refused the same way (`reason: 'invalid-version'`), so a caller that forgets + it cannot clobber anything. Every successful save returns the token of the + bytes it just wrote, which is what the next save must carry. The hash, rather + than an mtime, is what makes two writes inside the same clock tick + distinguishable. +2. **A watcher.** `git-changes-watch` resolves the same guard, `fs.watch`es the + resolved path, and sends `git-changes-file-changed(sessionId, relPath)` — + never an absolute path. The renderer re-reads on it, so the user is told (or + the clean buffer is refreshed) as it happens, rather than at the next + busy→idle edge. The watch is keyed by session + repo-relative path and is + dropped when the file is closed, the tab is closed, or another file is + opened. + +### Caps, line endings and encoding Same two limits the other panel reads use (`PANEL_FILE_MAX_BYTES`, 2 MB, and a NUL byte anywhere means binary), applied to both sides of the pair, and the -refusal says **which** of the two it was (`reason: 'binary'` vs -`'too-large'`) so the panel can explain itself and fall back to the read-only -unified diff. Neither side is ever truncated: a truncated buffer in an editor -that can save is a data-loss device, not a preview. +refusal says **which** limit it was (`reason: 'binary'` vs `'too-large'`) so +the panel can explain itself and fall back to the read-only unified diff. +Neither side is ever truncated: a truncated buffer in an editor that can save is +a data-loss device, not a preview. + +Two more properties of the round trip, both belonging main-side because the +editor cannot preserve them: + +- **Line endings.** CodeMirror normalises `\r\n` to `\n` when it builds a + document and joins with `\n` on the way out, so a CRLF file edited in the + panel would come back LF and rewrite every line. The read returns LF-only text + (which also keeps the dirty comparison honest — otherwise a CRLF buffer is + "modified" the instant it opens), and the write re-applies the file's dominant + ending, measured from the very bytes the version token was computed from. +- **Encoding.** The binary gate is NUL bytes, which Latin-1 text does not + contain: decoded as UTF-8 it becomes U+FFFD and would be written back as + those replacement bytes, irreversibly. Both sides are therefore decoded + strictly (`TextDecoder` with `fatal: true`) and a file that is not valid UTF-8 + is refused with `reason: 'encoding'`, not repaired. ### Remote sessions are refused @@ -442,11 +493,24 @@ The Changes tab re-renders on every busy→idle edge (see "Refresh triggers"), s Both are pinned by tests that go red if a render clears the host (a `MutationObserver` on the host records zero child mutations across an idle refresh) or rebuilds the instance. -### A dirty buffer is never overwritten +### A dirty buffer is never overwritten, and never lied to -An idle refresh reloads the file list unconditionally — the counts must follow what the session did. The open editor is a different matter: when its content differs from the last content known on disk (`tab.savedContent`), the refresh does **not** re-read the file and does not touch the buffer. It marks the tab stale, and the notice line says the view may be out of date while the unsaved edits are kept. A clean buffer is re-read, and replaced only when the content actually differs. +A refresh — from a busy→idle edge, the Refresh button, or the watcher — always re-reads the file, and what it does with the answer depends on the buffer: -Saving is `gitChangesSave(sessionId, path, content)` from the Save button or from the `cm-save` event the bundle dispatches for `Cmd/Ctrl+S`; on success it refreshes the status so the row's counts follow the write. The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. +- **Clean**: the selection is first re-pointed at its own row in the new status payload, so a file the session has just staged is compared against `HEAD` from then on rather than against an index that now equals the working tree. The pair is then replaced, and the editor rebuilt, only when the content actually differs. +- **Dirty**: nothing in the buffer is touched. The notice line says the file changed on disk *only when the version token says it did* — a warning that fires on every refresh, whether or not anything happened, is one the user learns to ignore. + +A file that stops being readable (deleted, or refused) and a status refresh that fails are both reported in that same notice line while a file is open; the file list's own error branch is not reachable from the diff view. + +Saving is `gitChangesSave(sessionId, path, content, version)` from the Save button or from the `cm-save` event the bundle dispatches for `Cmd/Ctrl+S`; on success it refreshes the status so the row's counts follow the write. A save is refused while another is in flight (`tab.saving`, which also disables the button), so a double press cannot put two writes in the air with the filesystem deciding the order. A refusal for `reason: 'stale'` keeps the buffer and turns the notice into "reload before saving"; **Reload** re-reads the file, after a confirm when there are unsaved edits. + +The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. + +Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) does not prompt: nothing user-initiated is happening at that moment and an IPC event cannot wait on a dialog. + +One host element holds one editor: another session's tab keeps its instance, detached, and `mountChangesEditor` removes any foreign child before attaching. Without that, switching between two sessions with files open stacks both editors in the same column, and a user typing into the visible-but-not-current one has the keystrokes read from the other buffer on save. + +Inline mode asks for `mergeControls: false`. The default `unifiedMergeView` renders Accept/**Reject** buttons per chunk, and Reject restores the original side into the document — reverting a working-tree change, which this panel does not do. An untracked file's added-line count comes from the pair rather than a second git call: its original side is empty, so its additions are its own lines (`countAddedLines`). The read-only fallback still takes the count from the diff (`countNewFileDiffAdditions`). diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index f96cff8f..115917c9 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -91,8 +91,9 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing): |---|---|---|---| | `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. | | `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- `, or `git diff --no-index -- /dev/null ` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). | -| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `sensitive`, `not-a-file`, `binary`, `too-large`, `remote`. | -| `git-changes-save` | `(sessionId, filePath, content)` | `{ok:true} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved. Local sessions only; never creates a file. | +| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `git`, `remote`. | +| `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. | +| `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. | `filePath` is repo-relative in all four. No absolute path crosses this boundary in either direction: the session's cwd is re-resolved main-side on @@ -164,8 +165,8 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it | | `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused | | `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded | -| `git-changes-file` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root comes from `git rev-parse --show-toplevel`, both root and target are resolved on disk, the target must stay inside the root, and `isSensitivePath` applies on top | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | -| `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | +| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root comes from `git rev-parse --show-toplevel`, a symlink at the target is refused before anything follows it, both root and target are resolved on disk, the target must stay inside the root, no `.git` segment is accepted, and `isSensitivePath` applies on top | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | +| `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | | `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc | ### Non-obvious behaviors diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index c15a5229..74a65ec5 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -97,8 +97,14 @@ things about that editor are not `ViewerPanel`'s: from `view.b.state.doc`, the inline and plain views from `view.state.doc` — the same asymmetry `handleDiffAction` already navigates for the MCP tab. - **The save is an IPC by session, not by path**: `gitChangesSave(sessionId, - repoRelativePath, content)`, not `saveFileForPanel`. The renderer never holds - an absolute path for a Changes row. + repoRelativePath, content, version)`, not `saveFileForPanel`. The renderer + never holds an absolute path for a Changes row, and the version token is what + stops it overwriting a file the session has written in the meantime. + +`ViewerPanel`'s own protections have Changes-panel equivalents rather than +reuses, for the same reason: watching goes through `git-changes-watch` instead +of `watch-file` (session-keyed, no absolute path), and the in-flight save flag +lives on the tab instead of the component. `Cmd/Ctrl+S` arrives as the same `cm-save` DOM event the bundle dispatches, and the listener sits on `#changes-diff-view`, which is where `ViewerPanel` puts @@ -108,6 +114,17 @@ The merge-view CSS in `public/style.css` is written for two hosts in one rule list — `#file-panel-body` (MCP tab) and `#changes-diff-host` (Changes tab). A new host means a new selector in those groups, not a copied block. +`createMergeViewer`'s `b` side and `createUnifiedMergeViewer` carry the editing +extensions a writing surface needs — `history()` (Ctrl/Cmd+Z), `defaultKeymap`, +`indentWithTab`, `indentOnInput`, `drawSelection` — and `cmSaveKeymap`, which is +what turns Ctrl/Cmd+S into the `cm-save` event the panels listen for. A +read-only viewer gets `cmSaveDomHandler` instead; an editable one must not have +both, or one keystroke raises two saves. `createUnifiedMergeViewer` takes +`{mergeControls}`: the MCP diff tab keeps the per-chunk Accept/Reject buttons, +the Changes panel turns them off. `test/codemirror-merge-editing.test.js` +drives all of this against the real CodeMirror under jsdom — a stub that +dispatches `cm-save` itself proves nothing about the keymap. + ## Gotchas - **CodeMirror state holds DOM references** — calling `destroy()` then immediately `open()` on the SAME container works because `_createEditor` rebuilds it, but if you reorder this, the editor can dangle. diff --git a/docs/changes-view.md b/docs/changes-view.md index f5a17e26..79c8ce82 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -44,8 +44,17 @@ On a local session, the open file is a live editor, not a picture of a diff. Typ - **Save** with the Save button or `Ctrl/Cmd+S`. The file list refreshes on save, so the row's counts follow what you wrote. - The button next to Back cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. - The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. -- If the session writes to files while you have unsaved edits, the list refreshes but your buffer is left alone, with a note that the view may be out of date. Nothing you typed is thrown away without you. -- A remote session, a binary file, and a file over 2 MB stay read-only, and the panel says which of those it is. +- A remote session, a binary file, a file that is not UTF-8 text, and a file over 2 MB stay read-only, and the panel says which of those it is. + +### When the session writes the same file + +The session you are watching writes these files, so the panel assumes it is not the only writer. + +- While the file is open it is watched. If the session writes it and **your buffer has no unsaved edits**, the editor reloads to what is now on disk. +- If you **do** have unsaved edits, your buffer is left exactly as it is and the panel says the file changed on disk. **Reload** replaces it with the version on disk — it asks first, because that discards what you typed. +- A save of a file that changed since you opened it is **refused**, not merged and not forced: the panel tells you to reload first, and the session's work stays on disk. Saving again after a reload writes normally. +- Back, closing the tab and closing the panel all ask before discarding unsaved edits. +- Line endings are preserved: a CRLF file is still CRLF after you save it, so a save with no edits leaves git with nothing to report. ## A shell under the list @@ -58,8 +67,9 @@ sets how much room each gets. Local sessions only — see ## What it doesn't do -- No staging, committing, or reverting from the UI — you can type in it, but it is not a git client. +- No staging, committing, or reverting from the UI — you can type in it, but it is not a git client. Inline mode deliberately has no per-change accept/reject buttons. - No creating, deleting or renaming files, and no editing on a remote session. +- Nothing under `.git/`, and no symbolic links. - It doesn't replace the CLI's `/diff` pane in a non-IDE session; the two coexist. - IDE mode itself is not available for remote sessions (that's a separate, larger feature — an `ssh -R` tunnel plus a lock file on the host); Changes does not depend on it and works today for both local and remote sessions. From 9255437fcdea8f088242218360e62351677174e8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 09:55:37 +0200 Subject: [PATCH 07/29] fix(changes): close the guards at the seams the reported cases did not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these fixed the case that was reported and missed the adjacent one. The git directory was reachable through a symlinked directory component: `gitlink/config`, where `gitlink` links to `.git`, carries no `.git` segment in the string the renderer sends, and the symlink check only ever looked at the final component. The literal check stays as a cheap pre-filter, and the guarantee moves to where it belongs: every check now runs on the disk-resolved path — the `.git` segment rule, containment in the repository root, and containment in the directories `git rev-parse --absolute-git-dir --git-common-dir` reports, which also covers a git directory that is not called `.git` at all. That is the third time a guard reading the renderer's literal string has been walked past by a symlinked directory; resolve first, check the resolved path, use the resolved value. A byte-order mark was being deleted on save. `TextDecoder` consumes a leading U+FEFF unless told not to, so a Windows-authored file lost three bytes to a round trip that reported success. The decoder now keeps it, the editor never sees it, and the write restores it. Line endings were only preserved for uniform CRLF. CodeMirror folds a lone CR as well, so a CR-only file was rewritten to LF and — because the comparison side folded only CRLF — was also treated as dirty forever, never re-reading from disk. Both forms fold now. A file that genuinely mixes endings is refused like a binary one rather than normalised to the majority: no editor whose document carries a single separator can preserve them line by line, and rewriting the minority is the manufactured diff this rule exists to prevent. The watcher moves into its own module so the half that detects the change is testable at all — main.js cannot be required from a test, and stubbing `fs.watch` in it left the whole suite green. It also re-arms on a rename: `fs.watch` follows the inode, so an atomic replacement delivered one event and then silence. --- git-changes-file.js | 112 ++++++++++--- git-changes-watch.js | 94 +++++++++++ main.js | 51 ++---- test/git-changes-file-real-git.test.js | 221 +++++++++++++++++++++++-- test/git-changes-watch.test.js | 181 ++++++++++++++++++++ 5 files changed, 588 insertions(+), 71 deletions(-) create mode 100644 git-changes-watch.js create mode 100644 test/git-changes-watch.test.js diff --git a/git-changes-file.js b/git-changes-file.js index 29ab7336..de21207d 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -68,21 +68,40 @@ function defaultRunGit(args, { cwd, timeoutMs, maxBuffer }) { }); } -async function resolveRepoRoot(cwd, deps) { +// see .ai/contexts/changes-view.md ("Containment, and which path the write runs on") +async function resolveRepoDirs(cwd, deps) { const runGit = deps.runGit || defaultRunGit; - const result = await runGit(['rev-parse', '--show-toplevel'], { + const result = await runGit(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'], { cwd, timeoutMs: deps.timeoutMs || DEFAULT_TIMEOUT_MS, maxBuffer: TOPLEVEL_MAX_BUFFER, }); if (result.code !== 0) return null; - const root = String(result.stdout).trim(); - return root || null; + const lines = String(result.stdout).split('\n').map((l) => l.trim()).filter(Boolean); + const root = lines[0]; + if (!root) return null; + const gitDirs = []; + for (const dir of lines.slice(1)) { + const resolved = resolveOnDisk(path.resolve(root, dir)); + if (resolved && !gitDirs.includes(resolved)) gitDirs.push(resolved); + } + return { root, gitDirs }; +} + +async function resolveRepoRoot(cwd, deps) { + const dirs = await resolveRepoDirs(cwd, deps); + return dirs ? dirs.root : null; +} + +function hasGitSegment(relativePath) { + return relativePath.split(/[/\\]/).some((segment) => segment.toLowerCase() === '.git'); } // Returns the single resolved path every later read/write must use — see .ai/contexts/changes-view.md -function resolveTargetInsideRepo(repoRoot, relPath, deps) { +function resolveTargetInsideRepo(repo, relPath, deps) { const fs = deps.fs || realFs; + const repoRoot = typeof repo === 'string' ? repo : repo.root; + const gitDirs = (typeof repo === 'string' ? [] : repo.gitDirs) || []; const realRoot = resolveOnDisk(repoRoot); if (!realRoot) return { ok: false, error: 'the repository directory no longer exists', reason: 'repo' }; @@ -100,6 +119,16 @@ function resolveTargetInsideRepo(repoRoot, relPath, deps) { const real = resolveOnDisk(joined); if (!real) return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; if (!isInsideDir(real, realRoot)) return { ok: false, error: 'path resolves outside the repository', reason: 'outside' }; + // Every check that matters runs on the resolved path: a symlinked directory + // component defeats one that reads the string the renderer sent. + if (hasGitSegment(path.relative(realRoot, real))) { + return { ok: false, error: 'the git directory is not editable', reason: 'git-dir' }; + } + for (const gitDir of gitDirs) { + if (isInsideDir(real, gitDir)) { + return { ok: false, error: 'the git directory is not editable', reason: 'git-dir' }; + } + } if (isSensitivePath(real)) return { ok: false, error: 'access to sensitive path denied', reason: 'sensitive' }; let stat; @@ -118,24 +147,46 @@ function versionOf(buf) { return crypto.createHash('sha1').update(buf).digest('hex') + '-' + buf.length; } -function dominantEol(text) { +const BOM = '\ufeff'; + +// see .ai/contexts/changes-view.md ("Caps, line endings and encoding") +function lineEndingsOf(text) { const crlf = (text.match(/\r\n/g) || []).length; - const lf = (text.match(/\n/g) || []).length - crlf; - return crlf > lf ? '\r\n' : '\n'; + const cr = (text.match(/\r(?!\n)/g) || []).length; + const lf = (text.match(/(? maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; const buf = fs.readFileSync(target.path); if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' }; if (buf.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; - const currentText = decodeUtf8(buf); - if (currentText === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; + const decoded = decodeUtf8(buf); + if (decoded === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; + const currentText = stripBom(decoded); + if (soleEol(currentText) === null) { + return { ok: false, error: 'file mixes line endings', reason: 'mixed-eol' }; + } const runGit = deps.runGit || defaultRunGit; const blob = await runGit(['cat-file', 'blob', buildBlobRev(relPath, staged)], { @@ -173,7 +228,7 @@ async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { if (blob.stdout.length > maxBytes) return { ok: false, error: 'file too large to edit', reason: 'too-large' }; const originalText = decodeUtf8(blob.stdout); if (originalText === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; - original = toLf(originalText); + original = toLf(stripBom(originalText)); } else if (blob.code !== NOT_IN_TREE_EXIT_CODE) { return { ok: false, error: (blob.stderr || '').trim() || `git exited with code ${blob.code}`, reason: 'git' }; } @@ -195,10 +250,10 @@ async function writeChangesFile({ cwd, relPath, content, version, maxBytes }, de if (!isSafeRepoRelativePath(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; if (Buffer.byteLength(content, 'utf8') > maxBytes) return { ok: false, error: 'content too large to save', reason: 'too-large' }; - const repoRoot = await resolveRepoRoot(cwd, deps); - if (!repoRoot) return { ok: false, error: 'not a git repository', reason: 'repo' }; + const repo = await resolveRepoDirs(cwd, deps); + if (!repo) return { ok: false, error: 'not a git repository', reason: 'repo' }; - const target = resolveTargetInsideRepo(repoRoot, relPath, deps); + const target = resolveTargetInsideRepo(repo, relPath, deps); if (!target.ok) return target; const onDisk = fs.readFileSync(target.path); @@ -206,8 +261,13 @@ async function writeChangesFile({ cwd, relPath, content, version, maxBytes }, de return { ok: false, error: 'this file changed on disk since it was opened', reason: 'stale' }; } - const eol = dominantEol(decodeUtf8(onDisk) || ''); - const bytes = Buffer.from(applyEol(content, eol), 'utf8'); + // The bytes the token was taken from decide how this file is written back. + const decoded = decodeUtf8(onDisk); + if (decoded === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' }; + const eol = soleEol(stripBom(decoded)); + if (eol === null) return { ok: false, error: 'file mixes line endings', reason: 'mixed-eol' }; + const prefix = decoded.startsWith(BOM) ? BOM : ''; + const bytes = Buffer.from(prefix + applyEol(stripBom(content), eol), 'utf8'); if (bytes.length > maxBytes) return { ok: false, error: 'content too large to save', reason: 'too-large' }; fs.writeFileSync(target.path, bytes); return { ok: true, savedPath: target.path, version: versionOf(bytes) }; @@ -221,7 +281,11 @@ module.exports = { resolveRepoRoot, isSafeRepoRelativePath, isSafeRevPathOperand, + hasGitSegment, buildBlobRev, versionOf, - dominantEol, + resolveRepoDirs, + lineEndingsOf, + soleEol, + toLf, }; diff --git a/git-changes-watch.js b/git-changes-watch.js new file mode 100644 index 00000000..9b06074e --- /dev/null +++ b/git-changes-watch.js @@ -0,0 +1,94 @@ +// git-changes-watch.js — file watches for the editable Changes panel — see .ai/contexts/changes-view.md + +'use strict'; + +const DEFAULT_DEBOUNCE_MS = 300; + +function keyOf(sessionId, relPath) { + return JSON.stringify([sessionId, relPath]); +} + +/** + * deps: {watchFn(path, handler) -> {close()}, send(sessionId, relPath), debounceMs, scheduler} + * The registry never sees an absolute path leave it: `send` carries the + * repo-relative path the renderer already has. + */ +function createChangesWatchRegistry(deps) { + const watchFn = deps.watchFn; + const send = deps.send; + const debounceMs = deps.debounceMs === undefined ? DEFAULT_DEBOUNCE_MS : deps.debounceMs; + const schedule = (deps.scheduler && deps.scheduler.setTimeout) || setTimeout; + const unschedule = (deps.scheduler && deps.scheduler.clearTimeout) || clearTimeout; + + const entries = new Map(); + + function arm(entry) { + try { + entry.watcher = watchFn(entry.resolvedPath, (eventType) => onEvent(entry, eventType)); + entry.armed = true; + } catch { + entry.watcher = null; + entry.armed = false; + } + return entry.armed; + } + + function disarm(entry) { + if (entry.watcher) { + try { entry.watcher.close(); } catch {} + } + entry.watcher = null; + entry.armed = false; + } + + // A rename replaces the inode the watch is bound to, so the watch is re-armed + // on the same path once the replacement has settled. + function onEvent(entry, eventType) { + if (eventType === 'rename') entry.needsRearm = true; + if (entry.timer) unschedule(entry.timer); + entry.timer = schedule(() => { + entry.timer = null; + if (entry.needsRearm && entries.get(entry.key) === entry) { + entry.needsRearm = false; + disarm(entry); + arm(entry); + } + if (entries.get(entry.key) === entry) send(entry.sessionId, entry.relPath); + }, debounceMs); + } + + function watch(sessionId, relPath, resolvedPath) { + const key = keyOf(sessionId, relPath); + unwatch(sessionId, relPath); + const entry = { key, sessionId, relPath, resolvedPath, watcher: null, timer: null, armed: false, needsRearm: false }; + entries.set(key, entry); + if (!arm(entry)) { + entries.delete(key); + return { ok: false, error: 'could not watch this file' }; + } + return { ok: true }; + } + + function unwatch(sessionId, relPath) { + const key = keyOf(sessionId, relPath); + const entry = entries.get(key); + if (!entry) return { ok: true }; + entries.delete(key); + if (entry.timer) unschedule(entry.timer); + disarm(entry); + return { ok: true }; + } + + function closeAll() { + for (const key of Array.from(entries.keys())) { + const entry = entries.get(key); + entries.delete(key); + if (entry.timer) unschedule(entry.timer); + disarm(entry); + } + } + + return { watch, unwatch, closeAll, size: () => entries.size }; +} + +module.exports = { createChangesWatchRegistry, DEFAULT_DEBOUNCE_MS }; diff --git a/main.js b/main.js index 22f60224..a6cd8c8f 100644 --- a/main.js +++ b/main.js @@ -87,6 +87,7 @@ const { createGitChangesRunner } = require('./git-changes-runner'); const gitChangesTarget = require('./git-changes-target'); const { resolvePanelTerminalCwd, isPanelShellSession } = require('./panel-terminal-target'); const gitChangesFile = require('./git-changes-file'); +const { createChangesWatchRegistry } = require('./git-changes-watch'); setPtyOpLogger(log); @@ -1817,55 +1818,31 @@ ipcMain.handle('git-changes-save', async (_event, sessionId, filePath, content, }); // see .ai/contexts/changes-view.md ("Saving over a file that moved") -const changesWatchers = new Map(); - -function changesWatchKey(sessionId, relPath) { - return sessionId + '\u0000' + relPath; -} - -function stopChangesWatch(key) { - const entry = changesWatchers.get(key); - if (!entry) return; - try { entry.watcher.close(); } catch {} - if (entry.debounce) clearTimeout(entry.debounce); - changesWatchers.delete(key); -} +const changesWatchers = createChangesWatchRegistry({ + watchFn: (filePath, handler) => fs.watch(filePath, handler), + send: (sessionId, relPath) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('git-changes-file-changed', sessionId, relPath); + } + }, +}); ipcMain.handle('git-changes-watch', async (_event, sessionId, filePath) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); if (!target.ok) return target; - const key = changesWatchKey(sessionId, filePath); - stopChangesWatch(key); - - const repoRoot = await gitChangesFile.resolveRepoRoot(target.cwd, {}); - if (!repoRoot) return { ok: false, error: 'not a git repository', reason: 'repo' }; - const resolved = gitChangesFile.resolveTargetInsideRepo(repoRoot, filePath, {}); + const repo = await gitChangesFile.resolveRepoDirs(target.cwd, {}); + if (!repo) return { ok: false, error: 'not a git repository', reason: 'repo' }; + const resolved = gitChangesFile.resolveTargetInsideRepo(repo, filePath, {}); if (!resolved.ok) return resolved; - try { - const entry = { watcher: null, debounce: null }; - entry.watcher = fs.watch(resolved.path, (eventType) => { - if (eventType !== 'change') return; - if (entry.debounce) clearTimeout(entry.debounce); - entry.debounce = setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('git-changes-file-changed', sessionId, filePath); - } - }, 300); - }); - changesWatchers.set(key, entry); - return { ok: true }; - } catch (err) { - return { ok: false, error: err.message }; - } + return changesWatchers.watch(sessionId, filePath, resolved.path); }); ipcMain.handle('git-changes-unwatch', (_event, sessionId, filePath) => { if (typeof filePath !== 'string' || !filePath) return { ok: true }; - stopChangesWatch(changesWatchKey(sessionId, filePath)); - return { ok: true }; + return changesWatchers.unwatch(sessionId, filePath); }); // --- IPC: toggle-star --- diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index 0c74db9d..337a223c 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -12,7 +12,7 @@ const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); -const { readChangesFile, writeChangesFile, versionOf } = require('../git-changes-file'); +const { readChangesFile, writeChangesFile, versionOf, resolveTargetInsideRepo, hasGitSegment } = require('../git-changes-file'); // git translates its diagnostics; the assertions below match its English text. process.env.LC_ALL = 'C'; @@ -275,7 +275,7 @@ test('real git: a symlinked directory inside the repository is an escape the con } finally { cleanup(tmp); } }); -test('real git: a symlink to another file inside the repository is refused, not silently followed (F5)', async () => { +test('real git: a symlink to another file inside the repository is refused, not silently followed', async () => { const tmp = mkTmp(); try { const repoDir = path.join(tmp, 'repo'); @@ -408,8 +408,8 @@ test('real git: the save writes the path the guard returned, not a re-derived jo initRepo(repoDir); fs.mkdirSync(path.join(repoDir, 'real')); fs.writeFileSync(path.join(repoDir, 'real', 'f.txt'), 'before\n'); - // A symlinked directory inside the repo: the file itself is a real file, so - // it is editable, but the path the guard resolves is not the path it was given. + // A symlinked directory inside the repo, pointing at an ordinary directory: + // editable, and the resolved path is not the path the guard was given. fs.symlinkSync(path.join(repoDir, 'real'), path.join(repoDir, 'link')); const written = []; @@ -452,7 +452,7 @@ test('real git: a save refuses content over the cap and non-string content', asy } finally { cleanup(tmp); } }); -// --- Saving over a file that moved (F1) ---------------------------------- +// --- Saving over a file that moved --------------------------------------- test('real git: a save is refused when the file changed since it was read, and the other writer keeps its bytes (mutation target: the version token)', async () => { const tmp = mkTmp(); @@ -506,7 +506,7 @@ test('real git: the token a save returns is the one the next save must carry', a } finally { cleanup(tmp); } }); -// --- Line endings (F2) ---------------------------------------------------- +// --- Line endings --------------------------------------------------------- test('real git: a CRLF file reads as LF and is written back as CRLF, so a no-op save is a no-op in git (mutation target: the line-ending round trip)', async () => { const tmp = mkTmp(); @@ -555,7 +555,7 @@ test('real git: an LF file stays LF even when the buffer carries a stray CR', as } finally { cleanup(tmp); } }); -// --- Encoding (F3) -------------------------------------------------------- +// --- Encoding and the byte-order mark -------------------------------------- test('real git: a file that is not valid UTF-8 is refused rather than round-tripped through U+FFFD (mutation target: the encoding gate)', async () => { const tmp = mkTmp(); @@ -588,7 +588,7 @@ test('real git: a blob that is not valid UTF-8 is refused too, even when the wor } finally { cleanup(tmp); } }); -// --- A `cat-file` failure is not a new file (F10) ------------------------- +// --- A `cat-file` failure is not a new file ------------------------------- test('a cat-file failure that is not "absent from this tree" is an error, not an empty original', async () => { const tmp = mkTmp(); @@ -625,7 +625,7 @@ function fakeRunGit(blobResult) { }; } -// --- The module's own invocation (F19) ------------------------------------ +// --- The module's own invocation ------------------------------------------ test('the blob is read with `cat-file blob`, pinned on the module\'s own argv (mutation target: going back to `git show`)', async () => { const tmp = mkTmp(); @@ -657,7 +657,7 @@ test('the blob is read with `cat-file blob`, pinned on the module\'s own argv (m } finally { cleanup(tmp); } }); -// --- A legitimately odd filename (F17) ------------------------------------ +// --- A legitimately odd filename ------------------------------------------ test('real git: a file whose name contains `..` is editable; a real traversal still is not', async () => { const tmp = mkTmp(); @@ -681,3 +681,204 @@ test('real git: a file whose name contains `..` is editable; a real traversal st } } finally { cleanup(tmp); } }); + +// --- The git directory is not editable, however it is spelled ------------ + +test('real git: .git reached through a symlinked directory is refused, on the read and on the write (mutation target: checking the resolved path)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const configPath = path.join(repoDir, '.git', 'config'); + const configBefore = fs.readFileSync(configPath, 'utf8'); + // The literal string carries no `.git` segment; only the resolved path does. + fs.symlinkSync(path.join(repoDir, '.git'), path.join(repoDir, 'gitlink')); + + const readResult = await read(repoDir, 'gitlink/config', false); + assert.equal(readResult.ok, false, 'the git directory must not be readable through a link'); + assert.equal(readResult.reason, 'git-dir'); + + const writeResult = await save(repoDir, 'gitlink/config', '[core]\n\tpager = OWNED\n'); + assert.equal(writeResult.ok, false); + assert.equal(writeResult.reason, 'git-dir'); + assert.equal(fs.readFileSync(configPath, 'utf8'), configBefore, + 'core.pager in .git/config runs on the next git command in this repo'); + } finally { cleanup(tmp); } +}); + +// Two independent rules cover the git directory, and each is the only one that +// can catch its own case: the segment rule when the git directory is named +// `.git`, the containment rule when it is somewhere else entirely. +test('real git: the resolved-path segment rule alone refuses .git reached through a link', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.symlinkSync(path.join(repoDir, '.git'), path.join(repoDir, 'gitlink')); + + // A caller that knows only the root — no git-directory list to fall back on. + const target = resolveTargetInsideRepo(repoDir, 'gitlink/config', {}); + assert.equal(target.ok, false); + assert.equal(target.reason, 'git-dir'); + } finally { cleanup(tmp); } +}); + +test('real git: a git directory that is not called .git is refused by containment alone', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + fs.mkdirSync(repoDir, { recursive: true }); + // --separate-git-dir puts the real git directory under a name the segment + // rule cannot recognise, inside the working tree. + git(repoDir, ['init', '-q', '--separate-git-dir', path.join(repoDir, 'customgit')]); + git(repoDir, ['config', 'user.email', 'a@a.com']); + git(repoDir, ['config', 'user.name', 'a']); + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'hello\n'); + git(repoDir, ['add', 'f.txt']); + git(repoDir, ['commit', '-q', '-m', 'init']); + + const configBefore = fs.readFileSync(path.join(repoDir, 'customgit', 'config'), 'utf8'); + assert.equal(hasGitSegment('customgit/config'), false, 'no segment rule can see this one'); + + const readResult = await read(repoDir, 'customgit/config', false); + assert.equal(readResult.ok, false); + assert.equal(readResult.reason, 'git-dir'); + + const writeResult = await save(repoDir, 'customgit/config', '[core]\n\tpager = OWNED\n'); + assert.equal(writeResult.ok, false); + assert.equal(writeResult.reason, 'git-dir'); + assert.equal(fs.readFileSync(path.join(repoDir, 'customgit', 'config'), 'utf8'), configBefore); + + const ordinary = await read(repoDir, 'f.txt', false); + assert.equal(ordinary.ok, true, 'and the working tree is still editable: ' + ordinary.error); + } finally { cleanup(tmp); } +}); + +test('real git: the git-directory check does not block ordinary files whose names merely contain "git"', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, '.github', 'workflows'), { recursive: true }); + fs.mkdirSync(path.join(repoDir, 'a.git')); + fs.writeFileSync(path.join(repoDir, '.gitignore'), 'node_modules\n'); + fs.writeFileSync(path.join(repoDir, '.github', 'workflows', 'ci.yml'), 'on: push\n'); + fs.writeFileSync(path.join(repoDir, 'a.git', 'x.txt'), 'x\n'); + fs.writeFileSync(path.join(repoDir, 'dotgit.md'), 'notes\n'); + + for (const relPath of ['.gitignore', '.github/workflows/ci.yml', 'a.git/x.txt', 'dotgit.md']) { + const result = await read(repoDir, relPath, false); + assert.equal(result.ok, true, `${relPath} must stay editable: ${result.error}`); + } + } finally { cleanup(tmp); } +}); + +// --- A byte-order mark survives the round trip --------------------------- + +test('real git: a BOM survives a no-op save, on a file that also uses CRLF (mutation target: the BOM round trip)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'win.txt'); + const bytes = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('hello\r\nworld\r\n', 'utf8')]); + fs.writeFileSync(target, bytes); + git(repoDir, ['add', 'win.txt']); + git(repoDir, ['commit', '-q', '-m', 'win']); + + const opened = await read(repoDir, 'win.txt', true); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.current, 'hello\nworld\n', 'the editor gets neither the BOM nor the CRs'); + assert.equal(opened.original, 'hello\nworld\n'); + + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'win.txt', content: opened.current, version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.deepEqual(fs.readFileSync(target), bytes, 'byte-identical: the BOM and the CRLFs are both still there'); + assert.equal(git(repoDir, ['status', '--porcelain', '--', 'win.txt']).trim(), ''); + } finally { cleanup(tmp); } +}); + +test('real git: a file with no BOM does not acquire one', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + + const opened = await read(repoDir, 'f.txt', false); + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'f.txt', content: 'plain\n', version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.deepEqual(fs.readFileSync(path.join(repoDir, 'f.txt')), Buffer.from('plain\n', 'utf8')); + } finally { cleanup(tmp); } +}); + +// --- Every uniform line ending, including a lone CR ---------------------- + +test('real git: a lone-CR file reads as LF and is written back as CR (mutation target: folding a lone CR)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'lonecr.txt'); + fs.writeFileSync(target, 'a\rb\rc\r'); + git(repoDir, ['add', 'lonecr.txt']); + git(repoDir, ['commit', '-q', '-m', 'cr']); + + const opened = await read(repoDir, 'lonecr.txt', true); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.current, 'a\nb\nc\n', + 'CodeMirror folds a lone CR to LF, so the comparison side has to fold it too'); + + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'lonecr.txt', content: opened.current, version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.equal(fs.readFileSync(target, 'utf8'), 'a\rb\rc\r', 'byte-identical after a no-op save'); + assert.equal(git(repoDir, ['status', '--porcelain', '--', 'lonecr.txt']).trim(), ''); + } finally { cleanup(tmp); } +}); + +test('real git: a file that mixes line endings is refused rather than silently normalised (mutation target: the uniformity check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'mixed.txt'); + fs.writeFileSync(target, 'a\r\nb\nc\r\nd\n'); + git(repoDir, ['add', 'mixed.txt']); + git(repoDir, ['commit', '-q', '-m', 'mixed']); + + const opened = await read(repoDir, 'mixed.txt', true); + assert.equal(opened.ok, false, 'no editor can preserve per-line endings a document type does not carry'); + assert.equal(opened.reason, 'mixed-eol'); + + const refused = await save(repoDir, 'mixed.txt', 'a\nb\nc\nd\n'); + assert.equal(refused.ok, false); + assert.equal(refused.reason, 'mixed-eol'); + assert.equal(fs.readFileSync(target, 'utf8'), 'a\r\nb\nc\r\nd\n', 'and the file is untouched'); + assert.equal(git(repoDir, ['status', '--porcelain', '--', 'mixed.txt']).trim(), ''); + } finally { cleanup(tmp); } +}); + +test('real git: a single line with no trailing newline round-trips byte-identically', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'oneline.txt'); + fs.writeFileSync(target, 'no trailing newline'); + git(repoDir, ['add', 'oneline.txt']); + git(repoDir, ['commit', '-q', '-m', 'one']); + + const opened = await read(repoDir, 'oneline.txt', true); + const saved = await writeChangesFile({ + cwd: repoDir, relPath: 'oneline.txt', content: opened.current, version: opened.version, maxBytes: MAX_BYTES, + }); + assert.equal(saved.ok, true, saved.error); + assert.equal(fs.readFileSync(target, 'utf8'), 'no trailing newline'); + assert.equal(git(repoDir, ['status', '--porcelain', '--', 'oneline.txt']).trim(), ''); + } finally { cleanup(tmp); } +}); diff --git a/test/git-changes-watch.test.js b/test/git-changes-watch.test.js new file mode 100644 index 00000000..2b405968 --- /dev/null +++ b/test/git-changes-watch.test.js @@ -0,0 +1,181 @@ +'use strict'; + +// The main-side half of the Changes panel's file watch: the registry that +// arms fs.watch, debounces its events, re-arms after the inode is replaced, +// and reports the repo-relative path back to the renderer. The registry is +// its own module for the same reason git-changes-target.js is — main.js +// cannot be required from a test, so the logic does not live there. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const fs = require('node:fs'); +const path = require('node:path'); + +const { createChangesWatchRegistry } = require('../git-changes-watch'); + +const ROOT = path.join(__dirname, '..'); + +// A stand-in for fs.watch plus the timer, so events and debounce are driven +// by the test rather than by the clock. +function harness({ failOn } = {}) { + const watches = []; + const sent = []; + const timers = []; + + const registry = createChangesWatchRegistry({ + watchFn: (filePath, handler) => { + if (failOn && failOn(filePath, watches.length)) throw new Error('ENOENT'); + const entry = { filePath, handler, closed: false }; + watches.push(entry); + return { close() { entry.closed = true; } }; + }, + send: (sessionId, relPath) => sent.push({ sessionId, relPath }), + scheduler: { + setTimeout: (fn) => { timers.push(fn); return timers.length; }, + clearTimeout: (id) => { if (id) timers[id - 1] = null; }, + }, + }); + + return { + registry, + watches, + sent, + live: () => watches.filter((w) => !w.closed), + fire: (eventType, index = watches.length - 1) => watches[index].handler(eventType), + settle: () => { + const pending = timers.slice(); + timers.length = 0; + for (const fn of pending) if (fn) fn(); + }, + }; +} + +test('a change event reports the session and the repo-relative path, never the resolved one (mutation target: the watch registration)', () => { + const h = harness(); + assert.deepEqual(h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'), { ok: true }); + assert.equal(h.watches.length, 1); + assert.equal(h.watches[0].filePath, '/repo/src/a.js', 'fs.watch gets the resolved path'); + + h.fire('change'); + h.settle(); + assert.deepEqual(h.sent, [{ sessionId: 's1', relPath: 'src/a.js' }], + 'the renderer gets the path it already has, and no absolute path crosses the boundary'); +}); + +test('bursts of events collapse into one notification', () => { + const h = harness(); + h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + + h.fire('change'); + h.fire('change'); + h.fire('change'); + h.settle(); + + assert.equal(h.sent.length, 1); +}); + +test('a rename-based replacement re-arms the watch, so the write after it is still seen (mutation target: the re-arm)', () => { + const h = harness(); + h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + const first = h.watches[0]; + + // An atomic replace: git checkout, sed -i, an editor saving via rename. + h.fire('rename'); + h.settle(); + assert.equal(h.sent.length, 1, 'the replacement itself is reported'); + assert.equal(first.closed, true, 'the watch bound to the old inode is dropped'); + assert.equal(h.watches.length, 2, 'and a new one is armed on the same path'); + assert.equal(h.watches[1].filePath, '/repo/src/a.js'); + + // Everything after the replacement used to be silent. + h.fire('change'); + h.settle(); + assert.equal(h.sent.length, 2, 'a write after the replacement is still reported'); +}); + +test('unwatch stops the watch and any notification still in flight', () => { + const h = harness(); + h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + + h.fire('change'); + h.registry.unwatch('s1', 'src/a.js'); + h.settle(); + + assert.equal(h.live().length, 0); + assert.deepEqual(h.sent, [], 'a pending debounce must not outlive the watch'); + assert.equal(h.registry.size(), 0); +}); + +test('watching the same file again replaces the previous watch instead of stacking', () => { + const h = harness(); + h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + h.registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + + assert.equal(h.registry.size(), 1); + assert.equal(h.live().length, 1); + + h.fire('change'); + h.settle(); + assert.equal(h.sent.length, 1, 'one event, one notification'); +}); + +test('two sessions watching the same relative path are independent', () => { + const h = harness(); + h.registry.watch('s1', 'src/a.js', '/repo-a/src/a.js'); + h.registry.watch('s2', 'src/a.js', '/repo-b/src/a.js'); + assert.equal(h.registry.size(), 2); + + h.fire('change', 0); + h.settle(); + assert.deepEqual(h.sent, [{ sessionId: 's1', relPath: 'src/a.js' }]); + + h.registry.unwatch('s1', 'src/a.js'); + h.fire('change', 1); + h.settle(); + assert.deepEqual(h.sent[1], { sessionId: 's2', relPath: 'src/a.js' }); +}); + +test('a file that cannot be watched is reported, not thrown', () => { + const h = harness({ failOn: () => true }); + const result = h.registry.watch('s1', 'gone.js', '/repo/gone.js'); + assert.equal(result.ok, false); + assert.equal(h.registry.size(), 0, 'a failed arm leaves nothing behind'); +}); + +test('closeAll drops every watch', () => { + const h = harness(); + h.registry.watch('s1', 'a.js', '/repo/a.js'); + h.registry.watch('s2', 'b.js', '/repo/b.js'); + + h.registry.closeAll(); + assert.equal(h.registry.size(), 0); + assert.equal(h.live().length, 0); +}); + +// --- The wiring in main.js, which no unit test can reach ----------------- + +test('main.js arms the registry with fs.watch and answers both IPCs with it (mutation target: the wiring)', () => { + const main = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); + const preload = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); + + const start = main.indexOf('const changesWatchers = createChangesWatchRegistry'); + assert.ok(start > 0, 'the registry is what main.js uses, not an inline Map of watchers'); + const wiring = main.slice(start, main.indexOf('\n});', start)); + assert.match(wiring, /watchFn:\s*\(filePath, handler\) => fs\.watch\(filePath, handler\)/, + 'the registry must be armed with the real fs.watch'); + assert.match(wiring, /send:[\s\S]*?webContents\.send\('git-changes-file-changed', sessionId, relPath\)/, + 'and report through the channel the renderer subscribes to'); + + const watchHandler = main.slice(main.indexOf("ipcMain.handle('git-changes-watch'")); + const watchBody = watchHandler.slice(0, watchHandler.indexOf('\n});')); + assert.match(watchBody, /requireLocalTarget/, 'a remote session has no file to watch here'); + assert.match(watchBody, /resolveTargetInsideRepo/, 'the watch goes through the same guard as the read'); + assert.match(watchBody, /changesWatchers\.watch\(sessionId, filePath, resolved\.path\)/); + + const unwatchHandler = main.slice(main.indexOf("ipcMain.handle('git-changes-unwatch'")); + assert.match(unwatchHandler.slice(0, unwatchHandler.indexOf('\n});')), /changesWatchers\.unwatch\(sessionId, filePath\)/); + + assert.match(preload, /gitChangesWatch: \(sessionId, filePath\) => ipcRenderer\.invoke\('git-changes-watch', sessionId, filePath\)/); + assert.match(preload, /onGitChangesFileChanged/); +}); From a2efb7d27167eb3636fa5ffce28bfcffba61e4ae Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 09:55:53 +0200 Subject: [PATCH 08/29] fix(changes): keep the buffer when the session takes the panel over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP-driven open replaces whatever the panel is showing, and it fires when the session acts — which is routine precisely while someone is editing. It destroyed unsaved edits with no prompt, and a prompt is not the answer either: the session is waiting on the diff it just opened. The buffer is stashed instead, with the pair it was based on and its version token, and reopening Changes restores it and says so. A restored buffer that has gone stale meanwhile is still refused at save time, so the recovery cannot become the clobber the token exists to prevent. Reload re-arms the watch as well as re-reading: a file replaced on disk is both the usual reason to press it and the way a watch goes deaf. A save whose IPC rejects outright — the channel is gone, or the handler threw outside its own try/catch — is now reported in the notice line like any other failure, instead of escaping as an unhandled rejection and leaving the Save button disabled until something else re-rendered the panel. An untracked file's count derived from the content pair goes through the same status-identity check as the one derived from a diff, so a count computed against one status result is never written onto a later one. --- public/file-panel.js | 53 ++++++++++- test/dom-file-panel-changes.test.js | 137 +++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/public/file-panel.js b/public/file-panel.js index 1e350b9a..6f6e3b0f 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -400,9 +400,42 @@ function openFileTab(sessionId, data) { } } +// A session opening a file or a diff replaces the tab without asking, so the +// buffer is kept and restored the next time the Changes tab is opened. +function stashChangesEdits(state, tab) { + if (!tab || tab.type !== 'changes' || !tab.selectedFile) return; + const content = readChangesEditorContent(tab); + if (content == null || content === tab.savedContent) return; + state.changesStash = { + file: tab.selectedFile, + content, + original: tab.original, + savedContent: tab.savedContent, + version: tab.version, + }; +} + +function restoreChangesEdits(sessionId, state, tab) { + const stash = state.changesStash; + if (!stash) return false; + state.changesStash = null; + + tab.selectedFile = stash.file; + tab.editable = true; + tab.original = stash.original; + tab.current = stash.content; + tab.savedContent = stash.savedContent; + tab.version = stash.version; + tab.restoredEdits = true; + tab.diffLoading = false; + watchChangesFile(sessionId, tab, stash.file.path); + return true; +} + function destroyCurrentTab(state) { const tab = state.currentTab; if (!tab) return; + stashChangesEdits(state, tab); if (tab.type === 'diff' && tab.editorView) { tab.editorView.destroy(); tab.editorView = null; @@ -669,6 +702,7 @@ function openChangesTab(sessionId) { editorMode: null, editorPending: null, version: null, + restoredEdits: false, watchedPath: null, fallbackReason: null, fileError: null, @@ -677,6 +711,7 @@ function openChangesTab(sessionId) { externalChange: false, }; state.panelVisible = true; + restoreChangesEdits(sessionId, state, state.currentTab); if (currentPanelSessionId === sessionId) { showPanel(state); @@ -824,7 +859,7 @@ async function openChangesDiff(sessionId, file) { tab.savedContent = pair.current; tab.version = pair.version; watchChangesFile(sessionId, tab, file.path); - if (file.untracked) applyUntrackedCounts(tab, file.path, countAddedLines(pair.current), 0); + if (file.untracked) applyUntrackedCounts(tab, dataAtRequest, file.path, countAddedLines(pair.current), 0); if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; } @@ -851,6 +886,9 @@ function describeFallback(pair) { if (!pair) return 'this file could not be opened for editing'; if (pair.reason === 'binary') return 'binary file'; if (pair.reason === 'too-large') return 'file too large to edit'; + if (pair.reason === 'encoding') return 'not UTF-8 text'; + if (pair.reason === 'mixed-eol') return 'mixed line endings'; + if (pair.reason === 'symlink') return 'symbolic link'; return pair.error || 'this file could not be opened for editing'; } @@ -888,6 +926,7 @@ function closeChangesDiff(sessionId) { unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); tab.selectedFile = null; + tab.restoredEdits = false; tab.diffContent = null; tab.diffError = null; tab.editable = false; @@ -1118,10 +1157,11 @@ function renderChangesDiff(sessionId, tab) { function renderChangesNotice(tab) { const notes = []; - const alarming = !!(tab.saveError || tab.fileError || tab.error || tab.externalChange); + const alarming = !!(tab.saveError || tab.fileError || tab.error || tab.externalChange || tab.restoredEdits); if (tab.remote) notes.push('Remote session — read-only.'); if (tab.fallbackReason) notes.push(`${tab.fallbackReason} — showing the diff read-only.`); if (tab.diffTruncated) notes.push('Diff truncated at 512 KB.'); + if (tab.restoredEdits) notes.push('Unsaved edits from before this panel was taken over have been restored.'); if (tab.externalChange) notes.push('This file changed on disk since you opened it — reload before saving, or your edits will not be accepted.'); if (tab.fileError) notes.push(`This file can no longer be read: ${tab.fileError}`); if (tab.error) notes.push(`The file list could not be refreshed: ${tab.error}`); @@ -1244,12 +1284,16 @@ async function handleChangesSave(sessionId) { let result; try { result = await window.api.gitChangesSave(sessionId, file.path, content, tab.version); + } catch (err) { + result = { ok: false, error: (err && err.message) || 'the save could not be sent' }; } finally { tab.saving = false; } const stillState = filePanelState.get(sessionId); - if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; + if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) { + return; + } if (!result || result.ok === false) { tab.saveError = (result && result.error) || 'failed to save'; @@ -1292,6 +1336,9 @@ async function reloadChangesFile(sessionId) { tab.current = result.current; tab.savedContent = result.current; tab.version = result.version; + // The usual reason to reload is a file replaced on disk, which is also how a + // watch goes deaf, so the reload re-arms it. + watchChangesFile(sessionId, tab, file.path); destroyChangesEditor(tab); if (currentPanelSessionId === sessionId) renderPanel(sessionId); } diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 1af44b78..103bb4d4 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -371,7 +371,7 @@ test('a count computed against one status result is never applied to a later one let statusCall = 0; let releaseDiff; const ctx = setupFilePanelDom({ - statusImpl: () => (statusCall++ === 0 ? makeStatusResult() : v2), + statusImpl: () => (statusCall++ === 0 ? makeStatusResult({ kind: 'remote' }) : { ...v2, kind: 'remote' }), diffImpl: () => new Promise((resolve) => { releaseDiff = () => resolve(UNTRACKED_DIFF_RESULT); }), }); try { @@ -490,6 +490,48 @@ test('the Refresh button re-invokes gitChangesStatus', async () => { } finally { ctx.destroy(); } }); +test('a count computed from the content pair is never applied to a later status either (mutation target: dropping the identity check on the editable path)', async () => { + const v2 = { + ok: true, + kind: 'local', + branch: { head: 'main', upstream: 'origin/main', ahead: 1, behind: 0 }, + files: [ + { path: 'src/a.js', origPath: null, staged: true, unstaged: false, untracked: false, renamed: false, state: 'M', added: 3, deleted: 1 }, + { path: 'new.txt', origPath: null, staged: true, unstaged: false, untracked: false, renamed: false, state: 'A', added: 2, deleted: 7 }, + ], + totals: { files: 2, added: 5, deleted: 8 }, + }; + let statusCall = 0; + let releasePair; + const ctx = setupFilePanelDom({ + statusImpl: () => (statusCall++ === 0 ? makeStatusResult() : v2), + fileImpl: () => new Promise((resolve) => { + releasePair = () => resolve({ ok: true, original: '', current: 'first\nsecond\n', version: 'v1' }); + }), + }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + + clickRow(ctx, 'new.txt'); + await flush(); + + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + assert.equal(ctx.calls.status.length, 2, 'the refresh happened while the pair was in flight'); + + releasePair(); + await flush(); + + backBtn(ctx).click(); + const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); + assert.equal(counts.textContent, '+2−7', 'git\'s own counts must survive the stale pair'); + assert.match(ctx.document.getElementById('changes-summary').textContent, /2 files changed \+5 −8/); + } finally { ctx.destroy(); } +}); + // --- No-polling refresh trigger + zero-invocation proof ----------------- test('setActivity(id, false) refreshes an open Changes tab for that session only', async () => { @@ -991,8 +1033,101 @@ test('Reload asks before discarding unsaved edits and re-reads the file when all } finally { ctx.destroy(); } }); +test('Reload re-arms the watch, since a replaced file is exactly what makes a watch go deaf', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.equal(ctx.calls.watch.length, 1); + + ctx.document.getElementById('changes-diff-reload-btn').click(); + await flush(); + + assert.equal(ctx.calls.watch.length, 2, 'the reload arms a fresh watch'); + assert.equal(ctx.calls.unwatch.length, 1, 'and drops the old one first'); + } finally { ctx.destroy(); } +}); + +test('a save whose IPC rejects is reported and leaves the button usable (mutation target: the rejected-save path)', async () => { + const rejections = []; + const ctx = setupFilePanelDom({ saveImpl: () => { throw new Error('ipc blew up'); } }); + const onRejection = (err) => rejections.push(err); + process.on('unhandledRejection', onRejection); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /ipc blew up/); + assert.equal(ctx.document.getElementById('changes-diff-save-btn').disabled, false, + 'the next click must do something'); + assert.equal(ctx.editors[0].box.text, 'my edit\n'); + assert.deepEqual(rejections, [], 'and nothing escapes as an unhandled rejection'); + } finally { + process.off('unhandledRejection', onRejection); + ctx.destroy(); + } +}); + // --- Losing work by accident --------------------------------------------- +test('a session opening its own file keeps the unsaved buffer and restores it (mutation target: the stash)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'work in progress\n'; + + // The session — not the user — takes the panel over. + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + assert.equal(ctx.calls.confirm.length, 0, 'an IPC-driven swap cannot stop to ask'); + + await ctx.window.openChangesTab('s1'); + await flush(); + + const restored = ctx.editors[ctx.editors.length - 1]; + assert.equal(restored.opened.modified, 'work in progress\n', 'the buffer comes back as it was'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /restored/i); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); + } finally { ctx.destroy(); } +}); + +test('a clean buffer is not stashed, so reopening the tab shows the file list', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + await ctx.window.openChangesTab('s1'); + await flush(); + + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block'); + assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none'); + } finally { ctx.destroy(); } +}); + +test('a restored buffer still refuses to save against a file that moved', async () => { + const ctx = setupFilePanelDom({ + saveImpl: () => ({ ok: false, error: 'this file changed on disk since it was opened', reason: 'stale' }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'work in progress\n'; + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + await ctx.window.openChangesTab('s1'); + await flush(); + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.equal(ctx.calls.save[0].version, 'v1', 'the token travelled with the stash'); + assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /changed on disk/); + } finally { ctx.destroy(); } +}); + test('Back asks before discarding unsaved edits, and a refusal keeps the editor (mutation target: the confirm)', async () => { const ctx = setupFilePanelDom({ confirmImpl: () => false }); try { From 323d50db500cef11e9350ef51842d643635e752c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 09:55:53 +0200 Subject: [PATCH 09/29] test(codemirror): assert one save per keystroke in every editing mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline and plain modes were asserted with "at least one" in exactly the two places a double-fire could reappear — a keymap and a DOM handler on the same editable view raise two saves per keystroke, which is why the merge pane carries only the keymap. The measured count is one everywhere, including the MCP diff tab's default, so the tests say one. The harness also stopped swallowing every jsdom error: anything that is not a missing-layout measurement now fails the test, so a view that failed to construct cannot pass as noise. --- test/codemirror-merge-editing.test.js | 54 +++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/test/codemirror-merge-editing.test.js b/test/codemirror-merge-editing.test.js index 45090ab3..1ab773e3 100644 --- a/test/codemirror-merge-editing.test.js +++ b/test/codemirror-merge-editing.test.js @@ -6,9 +6,10 @@ // does to it — not about a stub that always answers correctly. // // jsdom has no layout, so CodeMirror's measuring phase throws inside its own -// requestAnimationFrame callbacks; the stubs below give it enough of a Range to -// stay quiet, and the jsdom virtual console swallows the rest. None of that -// touches the keymap, the history or the document, which is what is asserted. +// requestAnimationFrame callbacks. The stubs below give it enough of a Range to +// stay quiet; anything else that reaches the virtual console is recorded and +// asserted on, so an error that means "the view never constructed" cannot pass +// as layout noise. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -19,12 +20,23 @@ const { JSDOM, VirtualConsole } = require('jsdom'); const SETUP = path.join(__dirname, '..', 'public', 'codemirror-setup.js'); let cmWindow = null; +const jsdomErrors = []; + +// Measuring against a layout engine that does not exist. Anything else is a +// real failure and is asserted on at the end of each test. +const LAYOUT_NOISE = /getClientRects|getBoundingClientRect|coordsAt|textRange|scrollIntoView/; + +function assertOnlyLayoutNoise() { + const real = jsdomErrors.filter((e) => !LAYOUT_NOISE.test(e)); + assert.deepEqual(real, [], 'jsdom reported an error that is not a missing-layout measurement'); + jsdomErrors.length = 0; +} async function loadCodeMirror() { if (cmWindow) return cmWindow; const virtualConsole = new VirtualConsole(); - virtualConsole.on('jsdomError', () => {}); + virtualConsole.on('jsdomError', (err) => jsdomErrors.push(String((err && err.message) || err))); const dom = new JSDOM('', { pretendToBeVisual: true, virtualConsole }); const { window } = dom; @@ -71,6 +83,7 @@ test('real CodeMirror: Ctrl/Cmd+S in the side-by-side editing pane raises exactl pressCtrlS(window, view.b.contentDOM); assert.equal(saves, 2); + assertOnlyLayoutNoise(); } finally { view.destroy(); container.remove(); @@ -93,6 +106,7 @@ test('real CodeMirror: the side-by-side editing pane is editable and has undo', const { undo } = require('@codemirror/commands'); assert.equal(undo(view.b), true, 'undo needs the history extension to be installed'); assert.equal(view.b.state.doc.toString(), 'new\n'); + assertOnlyLayoutNoise(); } finally { view.destroy(); host.remove(); @@ -115,13 +129,16 @@ test('real CodeMirror: the inline merge view drops the accept/reject chunk contr 'the default carries the chunk controls, which is what the Changes panel opts out of'); assert.doesNotMatch(host.innerHTML, /cm-chunkButtons/); assert.doesNotMatch(host.innerHTML, /Reject/); + assertOnlyLayoutNoise(); } finally { without.destroy(); host.remove(); } }); -test('real CodeMirror: Ctrl/Cmd+S also raises cm-save in the inline and plain editing modes', async () => { +// Exactly one: a keymap and a DOM handler on the same editable view raise two +// saves per keystroke, which is why the merge pane carries only the keymap. +test('real CodeMirror: Ctrl/Cmd+S raises exactly one cm-save in the inline and plain editing modes too', async () => { const window = await loadCodeMirror(); const host = window.document.createElement('div'); window.document.body.appendChild(host); @@ -129,9 +146,11 @@ test('real CodeMirror: Ctrl/Cmd+S also raises cm-save in the inline and plain ed const inline = window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js', { mergeControls: false }); try { let saves = 0; - host.addEventListener('cm-save', () => { saves++; }); + const count = () => { saves++; }; + host.addEventListener('cm-save', count); pressCtrlS(window, inline.contentDOM); - assert.ok(saves >= 1, 'inline mode saves too'); + assert.equal(saves, 1, 'inline mode saves exactly once per keystroke'); + host.removeEventListener('cm-save', count); } finally { inline.destroy(); } @@ -141,9 +160,28 @@ test('real CodeMirror: Ctrl/Cmd+S also raises cm-save in the inline and plain ed let saves = 0; host.addEventListener('cm-save', () => { saves++; }); pressCtrlS(window, plain.contentDOM); - assert.ok(saves >= 1, 'plain mode saves too'); + assert.equal(saves, 1, 'plain mode saves exactly once per keystroke'); + assertOnlyLayoutNoise(); } finally { plain.destroy(); host.remove(); } }); + +test('real CodeMirror: the MCP diff tab keeps its per-chunk controls and still saves exactly once', async () => { + const window = await loadCodeMirror(); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + + const view = window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js'); + try { + let saves = 0; + host.addEventListener('cm-save', () => { saves++; }); + pressCtrlS(window, view.contentDOM); + assert.equal(saves, 1); + assertOnlyLayoutNoise(); + } finally { + view.destroy(); + host.remove(); + } +}); From 7df9d1e034bdae4d3404169fc46499cd17e5a9b3 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 09:56:01 +0200 Subject: [PATCH 10/29] docs(changes): state the resolved-path rule, the line-ending cases and the stash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context docs carry the containment rule as it now stands — resolve first, check the resolved path, use the resolved value — the git-directory refusals, what a byte-order mark and each kind of line ending do on a round trip, the watcher's re-arm, and the buffer stash behind an MCP-driven open. docs/changes-view.md no longer promises that line endings are preserved without saying which files it will not open. --- .ai/contexts/changes-view.md | 82 ++++++++++++++++++++++++++---------- .ai/contexts/ipc-bridge.md | 6 +-- .ai/contexts/viewer-panel.md | 5 ++- docs/changes-view.md | 5 ++- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 3ebc2c51..be26cfc2 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -16,6 +16,7 @@ integration: `.ai/contexts/viewer-panel.md` ("Changes mode"). | `git-changes-runner.js` | Runs the git commands, local or remote, behind one interface. | | `git-changes-target.js` | cwd resolution for the panel's IPCs, extracted out of `main.js` for testability (same rationale as `delete-session-target.js`). | | `git-changes-file.js` | The content pair and the write target behind the editable diff: the `:` guard, the repository-containment check, the read and the write. | +| `git-changes-watch.js` | The registry behind `git-changes-watch`: arms `fs.watch`, debounces, re-arms after a rename, and reports the repo-relative path. | | `public/file-panel.js` | Renderer: the `'changes'` tab type, its rows, the editor, and the read-only diff-line renderer. | | `public/session-activity.js` | `onSessionIdle()` — the no-polling refresh hook. | @@ -370,10 +371,18 @@ syntax from revision magic. So the operand carries its own pair of guards `git-changes-save` uses, because its operand is a filesystem path and nothing else. Both segment rules are split on `/` and `\` rather than matched as substrings: `a..b.txt` and `dotgit.md` are ordinary filenames, while `a/../b` - and `.git/config` are not paths this panel will touch. `.git` is inside the - repository root, so containment alone does not exclude it, and `.git/config` - carries `core.pager`, `core.fsmonitor` and `[alias]` — writing it is command - execution the next time any git command runs there. + and `.git/config` are not paths this panel will touch. + + This check reads the string the renderer sent, so it is a cheap pre-filter and + **not** the guarantee: `gitlink/config`, where `gitlink` is a symlink to + `.git`, carries no `.git` segment at all. The guarantee is in + `resolveTargetInsideRepo`, which applies the same segment rule to the + **resolved** path and additionally refuses anything inside the directories + `git rev-parse --absolute-git-dir --git-common-dir` reports (`reason: + 'git-dir'`). That covers a linked worktree, whose git directory is not under + the worktree root at all. `.git/config` carries `core.pager`, + `core.fsmonitor` and `[alias]`, so writing it is command execution the next + time any git command runs there. - `isSafeRevPathOperand` — the above **plus** no `^[0-9]+:` prefix, which would turn `:` into `::`, git's conflict-stage syntax. A file literally named `1:f.txt` is therefore not editable from the panel: an @@ -396,10 +405,14 @@ a **symbolic link** outright (`reason: 'symlink'`): a symlink's content in a git working tree is its target string, so the pair would be the link text against the target's content, and a save would land on a file the row does not name. It then resolves both the root and the joined path **on disk** -(`resolveOnDisk`), requires the real target to be the real root or beneath it — -which is what catches an escape through a symlinked *directory*, whose last -component is an ordinary file — applies `isSensitivePath`, and requires a -regular file. It returns +(`resolveOnDisk`) and runs **every remaining check against the resolved path**: +containment in the repository root (which catches an escape through a symlinked +*directory*, whose last component is an ordinary file), the `.git` segment rule +and the git-directory containment above, `isSensitivePath`, and a regular-file +check. The ordering is the rule this codebase keeps relearning: a guard that +tests the literal string the renderer sent is defeated by a symlinked directory +component; resolve first, check the resolved path, and use the value the guard +returns. It returns that single resolved path, and the read and the write run on **that** value — the TOCTOU rule `ipc-path-validator.js` documents for `resolveAllowedMemoryPath`: two independent resolutions of the same string are @@ -427,13 +440,24 @@ case. Two independent layers: bytes it just wrote, which is what the next save must carry. The hash, rather than an mtime, is what makes two writes inside the same clock tick distinguishable. -2. **A watcher.** `git-changes-watch` resolves the same guard, `fs.watch`es the - resolved path, and sends `git-changes-file-changed(sessionId, relPath)` — - never an absolute path. The renderer re-reads on it, so the user is told (or - the clean buffer is refreshed) as it happens, rather than at the next - busy→idle edge. The watch is keyed by session + repo-relative path and is - dropped when the file is closed, the tab is closed, or another file is - opened. +2. **A watcher.** `git-changes-watch` resolves the same guard and hands the + resolved path to `git-changes-watch.js`'s registry, which `fs.watch`es it and + sends `git-changes-file-changed(sessionId, relPath)` — never an absolute + path. The renderer re-reads on it, so the user is told (or the clean buffer + is refreshed) as it happens, rather than at the next busy→idle edge. The + watch is keyed by session + repo-relative path and is dropped when the file + is closed, the tab is closed, or another file is opened. + + The registry is a module rather than a closure in `main.js` for the same + reason `git-changes-target.js` is: `main.js` cannot be required from a test, + and the half of the watcher that detects the change is the half worth + pinning. Its own rule: **a `rename` event re-arms the watch.** `fs.watch` + follows the inode, and an atomic replacement (`git checkout`, `git stash + pop`, `sed -i`, an editor saving via rename) delivers one event and then + silence, so the entry closes its watcher and re-arms on the same path before + reporting. The version token means the consequence of a missed event is a + missing warning, never a lost file, which is why this is a quality-of-signal + fix rather than a safety one. ### Caps, line endings and encoding @@ -447,17 +471,27 @@ a data-loss device, not a preview. Two more properties of the round trip, both belonging main-side because the editor cannot preserve them: -- **Line endings.** CodeMirror normalises `\r\n` to `\n` when it builds a - document and joins with `\n` on the way out, so a CRLF file edited in the - panel would come back LF and rewrite every line. The read returns LF-only text - (which also keeps the dirty comparison honest — otherwise a CRLF buffer is - "modified" the instant it opens), and the write re-applies the file's dominant +- **Line endings.** CodeMirror normalises `\r\n` *and a lone `\r`* to `\n` + when it builds a document, and joins with `\n` on the way out, so any file not + already LF-only would come back rewritten. The read returns LF-only text — + `toLf` folds both forms, or the buffer never compares equal to what was read + and is treated as dirty forever — and the write re-applies the file's own ending, measured from the very bytes the version token was computed from. + A file that **mixes** endings is refused (`reason: 'mixed-eol'`) rather than + normalised to the majority: no editor whose document type carries one + separator can preserve per-line endings, and silently rewriting the minority + lines is the manufactured diff this rule exists to prevent. Uniform CRLF, + uniform LF, uniform lone-CR and a file with no line ending at all all + round-trip byte-identically. - **Encoding.** The binary gate is NUL bytes, which Latin-1 text does not contain: decoded as UTF-8 it becomes U+FFFD and would be written back as those replacement bytes, irreversibly. Both sides are therefore decoded strictly (`TextDecoder` with `fatal: true`) and a file that is not valid UTF-8 - is refused with `reason: 'encoding'`, not repaired. + is refused with `reason: 'encoding'`, not repaired. The same decoder is given + `ignoreBOM: true`, because its default is to **consume** a leading U+FEFF: the + BOM is stripped from what the editor sees, so it cannot be typed over or + counted as a diff, and re-applied on write when the bytes on disk carried + one. ### Remote sessions are refused @@ -506,7 +540,11 @@ Saving is `gitChangesSave(sessionId, path, content, version)` from the Save butt The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. -Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) does not prompt: nothing user-initiated is happening at that moment and an IPC event cannot wait on a dialog. +Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). + +A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) cannot ask — the session is acting, not the user, and the diff it is opening is waiting for an answer. It **stashes** the buffer instead (`stashChangesEdits` → `state.changesStash`: the selected file, the edited content, the pair it was based on and its version token), and reopening the Changes tab restores it with a notice saying so (`restoreChangesEdits`). The stash is per session, holds only a dirty buffer, and is consumed on restore. The version token travels with it, so a restored buffer that has gone stale meanwhile is still refused at save time rather than overwriting whatever arrived in between. This is the same failure the version token addresses, pointing the other way: the session's activity destroying the user's work instead of the user's save destroying the session's. + +**Reload** re-arms the watch as well as re-reading, because "this file was replaced on disk" is both the usual reason to press it and the way a watch goes deaf. A save whose IPC rejects outright (the channel is gone, the handler threw outside its own try/catch) is caught and reported in the notice line like any other failure, rather than escaping as an unhandled rejection and leaving the Save button disabled until the next render. One host element holds one editor: another session's tab keeps its instance, detached, and `mountChangesEditor` removes any foreign child before attaching. Without that, switching between two sessions with files open stacks both editors in the same column, and a user typing into the visible-but-not-current one has the keystrokes read from the other buffer on save. diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 115917c9..b26b6a48 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -91,9 +91,9 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing): |---|---|---|---| | `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. | | `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- `, or `git diff --no-index -- /dev/null ` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). | -| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `git`, `remote`. | +| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. | | `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. | -| `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. | +| `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, through `git-changes-watch.js`'s registry, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. A `rename` event re-arms the watch, since an atomic replacement otherwise silences it. | `filePath` is repo-relative in all four. No absolute path crosses this boundary in either direction: the session's cwd is re-resolved main-side on @@ -165,7 +165,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it | | `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused | | `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded | -| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root comes from `git rev-parse --show-toplevel`, a symlink at the target is refused before anything follows it, both root and target are resolved on disk, the target must stay inside the root, no `.git` segment is accepted, and `isSensitivePath` applies on top | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | +| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | | `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | | `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc | diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 74a65ec5..321feb32 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -104,7 +104,10 @@ things about that editor are not `ViewerPanel`'s: `ViewerPanel`'s own protections have Changes-panel equivalents rather than reuses, for the same reason: watching goes through `git-changes-watch` instead of `watch-file` (session-keyed, no absolute path), and the in-flight save flag -lives on the tab instead of the component. +lives on the tab instead of the component. One protection has no `ViewerPanel` +counterpart at all: an MCP-driven open replaces whatever tab is showing, so a +dirty Changes buffer is stashed on the session's panel state and restored when +the tab is reopened. `Cmd/Ctrl+S` arrives as the same `cm-save` DOM event the bundle dispatches, and the listener sits on `#changes-diff-view`, which is where `ViewerPanel` puts diff --git a/docs/changes-view.md b/docs/changes-view.md index 79c8ce82..6ae1e331 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -44,7 +44,7 @@ On a local session, the open file is a live editor, not a picture of a diff. Typ - **Save** with the Save button or `Ctrl/Cmd+S`. The file list refreshes on save, so the row's counts follow what you wrote. - The button next to Back cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. - The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. -- A remote session, a binary file, a file that is not UTF-8 text, and a file over 2 MB stay read-only, and the panel says which of those it is. +- These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, and a file over 2 MB. ### When the session writes the same file @@ -54,7 +54,8 @@ The session you are watching writes these files, so the panel assumes it is not - If you **do** have unsaved edits, your buffer is left exactly as it is and the panel says the file changed on disk. **Reload** replaces it with the version on disk — it asks first, because that discards what you typed. - A save of a file that changed since you opened it is **refused**, not merged and not forced: the panel tells you to reload first, and the session's work stays on disk. Saving again after a reload writes normally. - Back, closing the tab and closing the panel all ask before discarding unsaved edits. -- Line endings are preserved: a CRLF file is still CRLF after you save it, so a save with no edits leaves git with nothing to report. +- Whatever line ending the file uses is preserved — CRLF stays CRLF — so a save with no edits leaves git with nothing to report. A byte-order mark is kept too. +- If the session opens a file or a diff of its own while you have unsaved edits, the panel switches away without asking, but your edits are kept: reopening **Changes** brings them back and says so. ## A shell under the list From 5017231069d250c0b0e470da200d0336629e734f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 10:26:48 +0200 Subject: [PATCH 11/29] fix(changes): honour a confirmed discard everywhere, not in two exits out of three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the Changes tab asked whether to discard unsaved edits, and then stashed them anyway: the toggle path tears down through destroyCurrentTab, which stashes unconditionally. Reopening the panel restored the buffer the user had just chosen to throw away, under a notice blaming a takeover that never happened, and the next save would have written it. The answer to that question is now authoritative for the session rather than for the editor in front of it. confirmDiscardChangesEdits takes the panel state and clears the stash whenever it returns true — which also drops a buffer stashed by an earlier takeover and since restored — and the toggle passes {stash: false} so nothing re-stashes behind the answer. Back and the panel close button already tore down inline; all three now leave the same state. The restore notice names the only cause that can still produce it: the session opening something else in this panel. --- .ai/contexts/changes-view.md | 4 +- docs/changes-view.md | 2 +- public/file-panel.js | 26 +++++----- test/dom-file-panel-changes.test.js | 76 ++++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index be26cfc2..277a58e6 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -540,9 +540,9 @@ Saving is `gitChangesSave(sessionId, path, content, version)` from the Save butt The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. -Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). +Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). The answer is authoritative for the whole session, not just for the editor in front of the user: `confirmDiscardChangesEdits` takes the panel state and clears `state.changesStash` whenever it returns true, and the one exit that tears down through `destroyCurrentTab` passes `{stash: false}`. Otherwise a confirmed discard would hand the buffer to the stash below and hand it back on the next open — keeping work against an explicit instruction, which is the same defect as losing it against one. -A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) cannot ask — the session is acting, not the user, and the diff it is opening is waiting for an answer. It **stashes** the buffer instead (`stashChangesEdits` → `state.changesStash`: the selected file, the edited content, the pair it was based on and its version token), and reopening the Changes tab restores it with a notice saying so (`restoreChangesEdits`). The stash is per session, holds only a dirty buffer, and is consumed on restore. The version token travels with it, so a restored buffer that has gone stale meanwhile is still refused at save time rather than overwriting whatever arrived in between. This is the same failure the version token addresses, pointing the other way: the session's activity destroying the user's work instead of the user's save destroying the session's. +A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) cannot ask — the session is acting, not the user, and the diff it is opening is waiting for an answer. It **stashes** the buffer instead (`stashChangesEdits` → `state.changesStash`: the selected file, the edited content, the pair it was based on and its version token), and reopening the Changes tab restores it with a notice naming that cause (`restoreChangesEdits`); the notice never claims a takeover for a buffer the user kept some other way. The stash is per session, holds only a dirty buffer, and is consumed on restore. The version token travels with it, so a restored buffer that has gone stale meanwhile is still refused at save time rather than overwriting whatever arrived in between. This is the same failure the version token addresses, pointing the other way: the session's activity destroying the user's work instead of the user's save destroying the session's. **Reload** re-arms the watch as well as re-reading, because "this file was replaced on disk" is both the usual reason to press it and the way a watch goes deaf. A save whose IPC rejects outright (the channel is gone, the handler threw outside its own try/catch) is caught and reported in the notice line like any other failure, rather than escaping as an unhandled rejection and leaving the Save button disabled until the next render. diff --git a/docs/changes-view.md b/docs/changes-view.md index 6ae1e331..ff2eabdd 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -55,7 +55,7 @@ The session you are watching writes these files, so the panel assumes it is not - A save of a file that changed since you opened it is **refused**, not merged and not forced: the panel tells you to reload first, and the session's work stays on disk. Saving again after a reload writes normally. - Back, closing the tab and closing the panel all ask before discarding unsaved edits. - Whatever line ending the file uses is preserved — CRLF stays CRLF — so a save with no edits leaves git with nothing to report. A byte-order mark is kept too. -- If the session opens a file or a diff of its own while you have unsaved edits, the panel switches away without asking, but your edits are kept: reopening **Changes** brings them back and says so. +- If the session opens a file or a diff of its own while you have unsaved edits, the panel switches away without asking, but your edits are kept: reopening **Changes** brings them back and says why. Answering yes to a discard prompt is the opposite instruction, and it is honoured — nothing comes back afterwards. ## A shell under the list diff --git a/public/file-panel.js b/public/file-panel.js index 6f6e3b0f..522289bd 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -240,7 +240,7 @@ function handleClose() { const tab = state.currentTab; if (tab) { - if (!confirmDiscardChangesEdits(tab)) return; + if (!confirmDiscardChangesEdits(state, tab)) return; if (tab.type === 'diff' && !tab.resolved) { window.api.mcpDiffResponse(currentPanelSessionId, tab.diffId, 'reject', null); } @@ -432,10 +432,10 @@ function restoreChangesEdits(sessionId, state, tab) { return true; } -function destroyCurrentTab(state) { +function destroyCurrentTab(state, { stash = true } = {}) { const tab = state.currentTab; if (!tab) return; - stashChangesEdits(state, tab); + if (stash) stashChangesEdits(state, tab); if (tab.type === 'diff' && tab.editorView) { tab.editorView.destroy(); tab.editorView = null; @@ -668,8 +668,8 @@ function handleDiffAction(sessionId, tab, action) { function toggleChangesTab(sessionId) { const state = getSessionState(sessionId); if (state.currentTab && state.currentTab.type === 'changes') { - if (!confirmDiscardChangesEdits(state.currentTab)) return; - destroyCurrentTab(state); + if (!confirmDiscardChangesEdits(state, state.currentTab)) return; + destroyCurrentTab(state, { stash: false }); state.currentTab = null; state.panelVisible = false; if (currentPanelSessionId === sessionId) hidePanel(); @@ -922,7 +922,7 @@ function closeChangesDiff(sessionId) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; - if (!confirmDiscardChangesEdits(tab)) return; + if (!confirmDiscardChangesEdits(state, tab)) return; unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); tab.selectedFile = null; @@ -942,10 +942,12 @@ function closeChangesDiff(sessionId) { if (currentPanelSessionId === sessionId) renderPanel(sessionId); } -function confirmDiscardChangesEdits(tab) { - if (!tab || tab.type !== 'changes' || !isChangesBufferDirty(tab)) return true; - if (typeof window.confirm !== 'function') return true; - return window.confirm('This file has unsaved edits. Discard them?'); +function confirmDiscardChangesEdits(state, tab) { + if (tab && tab.type === 'changes' && isChangesBufferDirty(tab) && typeof window.confirm === 'function') { + if (!window.confirm('This file has unsaved edits. Discard them?')) return false; + } + if (state) state.changesStash = null; + return true; } function renderChangesContent(sessionId, tab) { @@ -1161,7 +1163,7 @@ function renderChangesNotice(tab) { if (tab.remote) notes.push('Remote session — read-only.'); if (tab.fallbackReason) notes.push(`${tab.fallbackReason} — showing the diff read-only.`); if (tab.diffTruncated) notes.push('Diff truncated at 512 KB.'); - if (tab.restoredEdits) notes.push('Unsaved edits from before this panel was taken over have been restored.'); + if (tab.restoredEdits) notes.push('Unsaved edits kept from when the session opened something else in this panel have been restored.'); if (tab.externalChange) notes.push('This file changed on disk since you opened it — reload before saving, or your edits will not be accepted.'); if (tab.fileError) notes.push(`This file can no longer be read: ${tab.fileError}`); if (tab.error) notes.push(`The file list could not be refreshed: ${tab.error}`); @@ -1315,7 +1317,7 @@ async function reloadChangesFile(sessionId) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; - if (!confirmDiscardChangesEdits(tab)) return; + if (!confirmDiscardChangesEdits(state, tab)) return; const file = tab.selectedFile; const result = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 103bb4d4..6d6dd429 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -171,6 +171,10 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm for (const cb of fileChangedListeners) cb(sessionId, filePath); }, setActivity: read('setActivity'), + stashOf: (sessionId) => { + const state = read('filePanelState').get(sessionId); + return state ? state.changesStash : undefined; + }, destroy: () => window.close(), }; } @@ -1088,11 +1092,81 @@ test('a session opening its own file keeps the unsaved buffer and restores it (m const restored = ctx.editors[ctx.editors.length - 1]; assert.equal(restored.opened.modified, 'work in progress\n', 'the buffer comes back as it was'); - assert.match(ctx.document.getElementById('changes-diff-notice').textContent, /restored/i); + const notice = ctx.document.getElementById('changes-diff-notice').textContent; + assert.match(notice, /restored/i); + assert.match(notice, /the session opened something else/i, 'the notice must name the real reason'); assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); } finally { ctx.destroy(); } }); +test('every exit that asks about discarding honours the answer — no buffer comes back (mutation target: stashing after a confirmed discard)', async () => { + const ctx = setupFilePanelDom(); + try { + // 1. The Changes toggle. + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'I ASKED TO DISCARD THIS\n'; + ctx.document.getElementById('changes-toggle-btn').click(); + assert.equal(ctx.calls.confirm.length, 1); + assert.equal(ctx.stashOf('s1'), null, 'a confirmed discard must leave nothing to resurrect'); + + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block', + 'reopening shows the file list, not the buffer the user threw away'); + assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none'); + + // 2. The panel close button. + clickRow(ctx, 'src/a.js'); + await flush(); + ctx.editors[ctx.editors.length - 1].box.text = 'discard me too\n'; + ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); + assert.equal(ctx.stashOf('s1'), null); + + // 3. Back. + await ctx.window.openChangesTab('s1'); + await flush(); + clickRow(ctx, 'src/a.js'); + await flush(); + ctx.editors[ctx.editors.length - 1].box.text = 'and me\n'; + backBtn(ctx).click(); + await flush(); + assert.equal(ctx.stashOf('s1'), null); + } finally { ctx.destroy(); } +}); + +test('a confirmed discard also drops a buffer stashed by an earlier takeover', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'stashed by the session\n'; + + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + assert.ok(ctx.stashOf('s1'), 'the takeover stashed it'); + + await ctx.window.openChangesTab('s1'); + await flush(); + ctx.document.getElementById('changes-toggle-btn').click(); + assert.equal(ctx.stashOf('s1'), null, 'the restored buffer was discarded on purpose'); + + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block'); + } finally { ctx.destroy(); } +}); + +test('a declined discard keeps both the buffer and the tab', async () => { + const ctx = setupFilePanelDom({ confirmImpl: () => false }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'keep me\n'; + + ctx.document.getElementById('changes-toggle-btn').click(); + assert.equal(ctx.editors[0].box.destroyed, false); + assert.equal(ctx.document.getElementById('file-panel').classList.contains('open'), true); + } finally { ctx.destroy(); } +}); + test('a clean buffer is not stashed, so reopening the tab shows the file list', async () => { const ctx = setupFilePanelDom(); try { From ea9e865ac06b7b2ecded9c675bb6c91501594cba Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 10:26:48 +0200 Subject: [PATCH 12/29] refactor(changes): drop the replaced helper and wire the watcher teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRepoRoot had no callers left once resolveRepoDirs took over both the read and the write path, and an exported helper nothing calls reads as an unfinished intention. closeAll was the opposite case: written and tested, never connected, while the registry's watches outlived the window that asked for them. It now runs from the same mainWindow 'closed' handler that releases the PTYs and the subagent watchers, and a source assertion pins the wiring the way the fs.watch arming is pinned. Two defensive paths that no test reached are now covered: the watch IPC returning the guard's own refusal rather than letting fs.watch fail on an undefined path, and a debounced notification whose entry has since been dropped or replaced — a timer that loses the race with clearTimeout must not report a file the panel no longer has open. --- git-changes-file.js | 6 ------ main.js | 1 + test/git-changes-watch.test.js | 37 +++++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/git-changes-file.js b/git-changes-file.js index de21207d..60b01b89 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -88,11 +88,6 @@ async function resolveRepoDirs(cwd, deps) { return { root, gitDirs }; } -async function resolveRepoRoot(cwd, deps) { - const dirs = await resolveRepoDirs(cwd, deps); - return dirs ? dirs.root : null; -} - function hasGitSegment(relativePath) { return relativePath.split(/[/\\]/).some((segment) => segment.toLowerCase() === '.git'); } @@ -278,7 +273,6 @@ module.exports = { writeChangesFile, requireLocalTarget, resolveTargetInsideRepo, - resolveRepoRoot, isSafeRepoRelativePath, isSafeRevPathOperand, hasGitSegment, diff --git a/main.js b/main.js index a6cd8c8f..55a6a7f2 100644 --- a/main.js +++ b/main.js @@ -389,6 +389,7 @@ function createWindow() { if (!session.exited) killPty(session, id); activeSessions.delete(id); } + changesWatchers.closeAll(); // Release all subagent file watchers (closes fs.watch handles + clears any // debounce timers / polling fallbacks via the stored teardown closure) for (const [, entry] of subagentWatchers) { diff --git a/test/git-changes-watch.test.js b/test/git-changes-watch.test.js index 2b405968..d2700940 100644 --- a/test/git-changes-watch.test.js +++ b/test/git-changes-watch.test.js @@ -88,7 +88,6 @@ test('a rename-based replacement re-arms the watch, so the write after it is sti assert.equal(h.watches.length, 2, 'and a new one is armed on the same path'); assert.equal(h.watches[1].filePath, '/repo/src/a.js'); - // Everything after the replacement used to be silent. h.fire('change'); h.settle(); assert.equal(h.sent.length, 2, 'a write after the replacement is still reported'); @@ -136,6 +135,36 @@ test('two sessions watching the same relative path are independent', () => { assert.deepEqual(h.sent[1], { sessionId: 's2', relPath: 'src/a.js' }); }); +test('a notification whose entry is gone is dropped, even if its timer still fires', () => { + const captured = []; + const watches = []; + const sent = []; + const registry = createChangesWatchRegistry({ + watchFn: (filePath, handler) => { + const entry = { filePath, handler, closed: false }; + watches.push(entry); + return { close() { entry.closed = true; } }; + }, + send: (sessionId, relPath) => sent.push({ sessionId, relPath }), + // A scheduler that hands the callback out and ignores clearTimeout, which + // is the race a real timer can lose. + scheduler: { setTimeout: (fn) => { captured.push(fn); return captured.length; }, clearTimeout: () => {} }, + }); + + registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + watches[0].handler('change'); + registry.unwatch('s1', 'src/a.js'); + captured[captured.length - 1](); + assert.deepEqual(sent, [], 'the file is not open any more; nothing may be reported for it'); + + registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + watches[1].handler('change'); + const stale = captured[captured.length - 1]; + registry.watch('s1', 'src/a.js', '/repo/src/a.js'); + stale(); + assert.deepEqual(sent, [], 'and neither may a timer belonging to a replaced entry'); +}); + test('a file that cannot be watched is reported, not thrown', () => { const h = harness({ failOn: () => true }); const result = h.registry.watch('s1', 'gone.js', '/repo/gone.js'); @@ -171,6 +200,8 @@ test('main.js arms the registry with fs.watch and answers both IPCs with it (mut const watchBody = watchHandler.slice(0, watchHandler.indexOf('\n});')); assert.match(watchBody, /requireLocalTarget/, 'a remote session has no file to watch here'); assert.match(watchBody, /resolveTargetInsideRepo/, 'the watch goes through the same guard as the read'); + assert.match(watchBody, /if \(!resolved\.ok\) return resolved;/, + 'a refused path must come back with the guard\'s own reason, not as a failed fs.watch'); assert.match(watchBody, /changesWatchers\.watch\(sessionId, filePath, resolved\.path\)/); const unwatchHandler = main.slice(main.indexOf("ipcMain.handle('git-changes-unwatch'")); @@ -178,4 +209,8 @@ test('main.js arms the registry with fs.watch and answers both IPCs with it (mut assert.match(preload, /gitChangesWatch: \(sessionId, filePath\) => ipcRenderer\.invoke\('git-changes-watch', sessionId, filePath\)/); assert.match(preload, /onGitChangesFileChanged/); + + const closedHandler = main.slice(main.indexOf("mainWindow.on('closed'")); + assert.match(closedHandler.slice(0, closedHandler.indexOf('\n });')), /changesWatchers\.closeAll\(\)/, + 'the watches must not outlive the window that asked for them'); }); From d3cfeba264d80e92cc400857eed0fcfccfe9fe11 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 10:56:14 +0200 Subject: [PATCH 13/29] fix(changes): drop a stashed buffer only when the user was asked about it Closing the panel while the session's own file or diff tab was showing threw away a buffer stashed from the Changes tab, without a prompt: the clear was tied to confirmDiscardChangesEdits returning true, and that function returns true whenever there is nothing to ask about. The panel promised those edits would come back on the next open, and they did not. confirmDiscardChangesEdits now asks and does nothing else. What happens to the buffer is decided at the exit, which is where the user's action is known. The clearing half of the earlier fix is gone rather than narrowed. Measured across the suite, it never cleared a live stash: a stash exists only while a non-Changes tab is showing, since it is created when a Changes tab is replaced and consumed the moment one is opened, so an exit reached from the Changes tab always sees a null stash. `{stash: false}` on the one exit that tears down through destroyCurrentTab is the whole of what a confirmed discard needs, and the tests still fail without it. Each of the three exits that ask is now pinned separately, with a live stash of its own, so none of them passes on the back of another having cleared it. --- .ai/contexts/changes-view.md | 4 +- public/file-panel.js | 23 ++--- test/dom-file-panel-changes.test.js | 125 ++++++++++++++++++++-------- 3 files changed, 107 insertions(+), 45 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 277a58e6..eea43f61 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -540,7 +540,9 @@ Saving is `gitChangesSave(sessionId, path, content, version)` from the Save butt The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. -Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). The answer is authoritative for the whole session, not just for the editor in front of the user: `confirmDiscardChangesEdits` takes the panel state and clears `state.changesStash` whenever it returns true, and the one exit that tears down through `destroyCurrentTab` passes `{stash: false}`. Otherwise a confirmed discard would hand the buffer to the stash below and hand it back on the next open — keeping work against an explicit instruction, which is the same defect as losing it against one. +Back, closing the tab and closing the panel all ask before discarding unsaved edits (`window.confirm`, as `ViewerPanel` already does for its own destructive action). `confirmDiscardChangesEdits` asks and does nothing else; what happens to the buffer is decided by the exit. The exit that tears down through `destroyCurrentTab` passes `{stash: false}`, so a confirmed discard cannot hand the buffer to the stash below and get it back on the next open; the other two tear down inline and never stash. + +Nothing else clears the stash, deliberately. A stash exists only while a non-Changes tab is showing — it is created when a Changes tab is replaced and consumed the moment one is opened — so an exit reached *from* the Changes tab always sees a null stash, and an exit reached from the session's own tab (the panel's close button) has asked the user nothing and has no instruction to act on. Clearing there is what turns "your edits are kept" into silent loss. The two failures are mirror images and both come from deciding the stash's lifetime somewhere other than at the user's answer: keeping work against an explicit discard, and discarding work nobody was asked about. A tab replaced by an MCP-driven open (`openDiffTab` / `openFileTab`) cannot ask — the session is acting, not the user, and the diff it is opening is waiting for an answer. It **stashes** the buffer instead (`stashChangesEdits` → `state.changesStash`: the selected file, the edited content, the pair it was based on and its version token), and reopening the Changes tab restores it with a notice naming that cause (`restoreChangesEdits`); the notice never claims a takeover for a buffer the user kept some other way. The stash is per session, holds only a dirty buffer, and is consumed on restore. The version token travels with it, so a restored buffer that has gone stale meanwhile is still refused at save time rather than overwriting whatever arrived in between. This is the same failure the version token addresses, pointing the other way: the session's activity destroying the user's work instead of the user's save destroying the session's. diff --git a/public/file-panel.js b/public/file-panel.js index 522289bd..34092b3a 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -240,7 +240,7 @@ function handleClose() { const tab = state.currentTab; if (tab) { - if (!confirmDiscardChangesEdits(state, tab)) return; + if (!confirmDiscardChangesEdits(tab)) return; if (tab.type === 'diff' && !tab.resolved) { window.api.mcpDiffResponse(currentPanelSessionId, tab.diffId, 'reject', null); } @@ -668,7 +668,7 @@ function handleDiffAction(sessionId, tab, action) { function toggleChangesTab(sessionId) { const state = getSessionState(sessionId); if (state.currentTab && state.currentTab.type === 'changes') { - if (!confirmDiscardChangesEdits(state, state.currentTab)) return; + if (!confirmDiscardChangesEdits(state.currentTab)) return; destroyCurrentTab(state, { stash: false }); state.currentTab = null; state.panelVisible = false; @@ -922,7 +922,7 @@ function closeChangesDiff(sessionId) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; - if (!confirmDiscardChangesEdits(state, tab)) return; + if (!confirmDiscardChangesEdits(tab)) return; unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); tab.selectedFile = null; @@ -942,12 +942,15 @@ function closeChangesDiff(sessionId) { if (currentPanelSessionId === sessionId) renderPanel(sessionId); } -function confirmDiscardChangesEdits(state, tab) { - if (tab && tab.type === 'changes' && isChangesBufferDirty(tab) && typeof window.confirm === 'function') { - if (!window.confirm('This file has unsaved edits. Discard them?')) return false; - } - if (state) state.changesStash = null; - return true; +function hasUnsavedChangesEdits(tab) { + return !!tab && tab.type === 'changes' && isChangesBufferDirty(tab); +} + +// Asks, and does nothing else. +function confirmDiscardChangesEdits(tab) { + if (!hasUnsavedChangesEdits(tab)) return true; + if (typeof window.confirm !== 'function') return true; + return window.confirm('This file has unsaved edits. Discard them?'); } function renderChangesContent(sessionId, tab) { @@ -1317,7 +1320,7 @@ async function reloadChangesFile(sessionId) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; - if (!confirmDiscardChangesEdits(state, tab)) return; + if (!confirmDiscardChangesEdits(tab)) return; const file = tab.selectedFile; const result = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 6d6dd429..4b6e7473 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -88,6 +88,7 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm onMcpOpenFile: () => {}, onMcpCloseAllDiffs: () => {}, onMcpCloseTab: () => {}, + mcpDiffResponse: () => {}, gitChangesStatus: (sessionId) => { calls.status.push(sessionId); return Promise.resolve((statusImpl || (() => makeStatusResult()))(sessionId)); @@ -1099,40 +1100,42 @@ test('a session opening its own file keeps the unsaved buffer and restores it (m } finally { ctx.destroy(); } }); -test('every exit that asks about discarding honours the answer — no buffer comes back (mutation target: stashing after a confirmed discard)', async () => { - const ctx = setupFilePanelDom(); - try { - // 1. The Changes toggle. - await openFile(ctx, 's1', 'src/a.js'); - ctx.editors[0].box.text = 'I ASKED TO DISCARD THIS\n'; - ctx.document.getElementById('changes-toggle-btn').click(); - assert.equal(ctx.calls.confirm.length, 1); - assert.equal(ctx.stashOf('s1'), null, 'a confirmed discard must leave nothing to resurrect'); - - await ctx.window.openChangesTab('s1'); - await flush(); - assert.equal(ctx.document.getElementById('changes-list').style.display, 'block', - 'reopening shows the file list, not the buffer the user threw away'); - assert.equal(ctx.document.getElementById('changes-diff-notice').style.display, 'none'); - - // 2. The panel close button. - clickRow(ctx, 'src/a.js'); - await flush(); - ctx.editors[ctx.editors.length - 1].box.text = 'discard me too\n'; - ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); - assert.equal(ctx.stashOf('s1'), null); - - // 3. Back. - await ctx.window.openChangesTab('s1'); - await flush(); - clickRow(ctx, 'src/a.js'); - await flush(); - ctx.editors[ctx.editors.length - 1].box.text = 'and me\n'; - backBtn(ctx).click(); - await flush(); - assert.equal(ctx.stashOf('s1'), null); - } finally { ctx.destroy(); } -}); +// Each exit is given its own live stash, so none of them can pass on the back +// of another having already cleared it. +for (const exit of ['toggle', 'panel-close', 'back']) { + test(`the ${exit} exit clears the stash once the user confirms the discard (mutation target: the discard call on that path)`, async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'I ASKED TO DISCARD THIS\n'; + + // A takeover puts the buffer in the stash; reopening restores it, so the + // tab is dirty again and this exit is the one that must drop it. + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + assert.ok(ctx.stashOf('s1'), 'the stash is live before the exit'); + await ctx.window.openChangesTab('s1'); + await flush(); + + if (exit === 'toggle') ctx.document.getElementById('changes-toggle-btn').click(); + else if (exit === 'panel-close') ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); + else backBtn(ctx).click(); + await flush(); + + assert.equal(ctx.calls.confirm.length, 1, 'the user was asked'); + assert.equal(ctx.stashOf('s1'), null, 'a confirmed discard must leave nothing to resurrect'); + + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block', + 'reopening shows the file list, not the buffer the user threw away'); + assert.equal(ctx.document.getElementById('changes-diff-view').style.display, 'none', + 'and the diff view, where the restore notice lives, is off screen'); + assert.equal(ctx.editors[ctx.editors.length - 1].box.destroyed, true, + 'the discarded editor is gone, not merely hidden'); + } finally { ctx.destroy(); } + }); +} test('a confirmed discard also drops a buffer stashed by an earlier takeover', async () => { const ctx = setupFilePanelDom(); @@ -1167,6 +1170,60 @@ test('a declined discard keeps both the buffer and the tab', async () => { } finally { ctx.destroy(); } }); +// The pair of questions this feature turns on: a buffer may only be dropped +// when the user was asked about it, and must be dropped when they said yes. +test('closing the panel over the session\'s own tab keeps the stash, because nothing was asked (mutation target: clearing without asking)', async () => { + for (const takeover of ['file', 'diff']) { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'work the user never abandoned\n'; + + if (takeover === 'file') { + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + } else { + ctx.window.openDiffTab('s1', 'd1', { oldFilePath: '/repo/other.js', oldContent: 'a\n', newContent: 'b\n' }); + } + await flush(); + assert.ok(ctx.stashOf('s1'), `the ${takeover} takeover stashed the buffer`); + + // The panel now shows the session's tab. Closing it asks nothing about + // edits that are not in front of the user. + ctx.window.handleClose(); + assert.equal(ctx.calls.confirm.length, 0, 'no question was put'); + assert.ok(ctx.stashOf('s1'), 'so the answer cannot be "discard"'); + + await ctx.window.openChangesTab('s1'); + await flush(); + const restored = ctx.editors[ctx.editors.length - 1]; + assert.equal(restored.opened.modified, 'work the user never abandoned\n'); + } finally { ctx.destroy(); } + } +}); + +test('an exit that asks nothing never drops a stash, even from the Changes tab itself', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'stashed\n'; + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + + // Back in Changes, the restored buffer is saved, so the tab is clean: an + // exit from here asks nothing, and a clean tab is not an instruction. + await ctx.window.openChangesTab('s1'); + await flush(); + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + assert.equal(ctx.calls.confirm.length, 0); + + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + backBtn(ctx).click(); + assert.equal(ctx.calls.confirm.length, 0, 'a clean buffer is never asked about'); + } finally { ctx.destroy(); } +}); + test('a clean buffer is not stashed, so reopening the tab shows the file list', async () => { const ctx = setupFilePanelDom(); try { From fea54fb66299422296a78f85fdca9581f5b415c7 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 10:56:14 +0200 Subject: [PATCH 14/29] test(changes): do not let a commented-out call satisfy a source assertion The wiring between the watch registry and main.js is asserted against main.js source, because main.js cannot be required from a test. A call commented out in place still matched, so the assertion that it is made was satisfied by its own corpse. Whole-line comments are stripped before matching. Only whole-line comments: `/*` also occurs inside string literals in main.js, and a block-comment stripper eats them. The assertions catch deletion and commenting out, which are the regressions that happen; a call left in place but made unreachable still passes, and the test says so. --- test/git-changes-watch.test.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/git-changes-watch.test.js b/test/git-changes-watch.test.js index d2700940..08a8fbdc 100644 --- a/test/git-changes-watch.test.js +++ b/test/git-changes-watch.test.js @@ -16,6 +16,18 @@ const { createChangesWatchRegistry } = require('../git-changes-watch'); const ROOT = path.join(__dirname, '..'); +// main.js cannot be required from a test — it pulls in electron — so the wiring +// between this registry and the IPC handlers is asserted against its source. +// Commented-out lines are dropped first, so a call that has been commented out +// cannot satisfy an assertion that it is made. This catches deletion, which is +// the regression that happens; it cannot catch a call left in place but made +// unreachable. Only whole-line comments are removed: `/*` also appears inside +// string literals in main.js, and a block-comment stripper eats them. +function sourceOf(file) { + return fs.readFileSync(path.join(ROOT, file), 'utf8') + .replace(/^[ \t]*\/\/.*$/gm, ''); +} + // A stand-in for fs.watch plus the timer, so events and debounce are driven // by the test rather than by the clock. function harness({ failOn } = {}) { @@ -185,8 +197,8 @@ test('closeAll drops every watch', () => { // --- The wiring in main.js, which no unit test can reach ----------------- test('main.js arms the registry with fs.watch and answers both IPCs with it (mutation target: the wiring)', () => { - const main = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); - const preload = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); + const main = sourceOf('main.js'); + const preload = sourceOf('preload.js'); const start = main.indexOf('const changesWatchers = createChangesWatchRegistry'); assert.ok(start > 0, 'the registry is what main.js uses, not an inline Map of watchers'); From 56b88094d355fa4e4957eaa2cf956dfaf7bd37fe Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 12:43:31 +0200 Subject: [PATCH 15/29] feat(changes): map a file link's absolute path to the row it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal file link hands the renderer an absolute path, and the Changes panel speaks repo-relative pathspecs. git-changes-locate does the mapping main-side, against the repository root resolved from the session's own cwd — the same root the read and the write already use — so the renderer never learns where the repository is. It answers whether the file is one of this session's changed rows, and which side to open it against. An untracked file counts: it is a legitimate row and the editor handles an empty original. An unmodified file is not an error, just a file with no diff to show. A path outside the repository, and everything the row guard already refuses — the git directory, a sensitive file, a directory — come back refused rather than as a row a later read would reject. The answer costs one `git status` scoped to that one path. Measured on a 20 000-file repository: 14-17 ms against 117-126 ms for the unscoped status the panel runs when it opens, which is cheap enough per click that the renderer needs no cache and stays correct when no Changes tab is open. --- git-changes-file.js | 48 ++++++++ main.js | 13 +++ preload.js | 1 + test/git-changes-file-real-git.test.js | 153 ++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/git-changes-file.js b/git-changes-file.js index 60b01b89..f49aafc1 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -7,12 +7,14 @@ const path = require('path'); const crypto = require('crypto'); const { execFile } = require('child_process'); const { localGitEnv } = require('./git-changes-runner'); +const { parseStatusPorcelainV2 } = require('./git-changes'); const { resolveOnDisk, isInsideDir } = require('./resolve-path-on-disk'); const { isSensitivePath } = require('./ipc-path-validator'); const DEFAULT_TIMEOUT_MS = 10_000; const MAX_PATH_LENGTH = 4096; const TOPLEVEL_MAX_BUFFER = 64 * 1024; +const STATUS_MAX_BUFFER = 1024 * 1024; const NOT_IN_TREE_EXIT_CODE = 128; // Guards for a repo-relative path from the renderer — see .ai/contexts/changes-view.md ("Editing a changed file") @@ -238,6 +240,51 @@ async function readChangesFile({ cwd, relPath, staged, maxBytes }, deps = {}) { }; } +// A file link hands the renderer an absolute path; the repo-relative row it +// belongs to is computed here, never there — see .ai/contexts/changes-view.md +async function locateChangesFile({ cwd, absolutePath }, deps = {}) { + if (typeof absolutePath !== 'string' || !absolutePath) { + return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + } + + const repo = await resolveRepoDirs(cwd, deps); + if (!repo) return { ok: false, error: 'not a git repository', reason: 'repo' }; + + const realRoot = resolveOnDisk(repo.root); + const real = resolveOnDisk(absolutePath); + if (!realRoot || !real) return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; + if (!isInsideDir(real, realRoot)) return { ok: false, error: 'path is outside this session\'s repository', reason: 'outside' }; + + const relPath = path.relative(realRoot, real).split(path.sep).join('/'); + if (!isSafeRevPathOperand(relPath)) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + + // The same guard the read and the write go through, so a link cannot reach + // what a row cannot. + const target = resolveTargetInsideRepo(repo, relPath, deps); + if (!target.ok) return target; + + const runGit = deps.runGit || defaultRunGit; + const status = await runGit(['status', '--porcelain=v2', '-uall', '-z', '--', relPath], { + cwd: realRoot, + timeoutMs: deps.timeoutMs || DEFAULT_TIMEOUT_MS, + maxBuffer: STATUS_MAX_BUFFER, + }); + if (status.code !== 0) { + return { ok: false, error: (status.stderr || '').trim() || `git exited with code ${status.code}`, reason: 'git' }; + } + + const record = parseStatusPorcelainV2(String(status.stdout)).files.find((f) => f.path === relPath); + if (!record) return { ok: true, relPath, changed: false }; + + return { + ok: true, + relPath, + changed: true, + staged: !!record.staged && !record.unstaged, + untracked: !!record.untracked, + }; +} + async function writeChangesFile({ cwd, relPath, content, version, maxBytes }, deps = {}) { const fs = deps.fs || realFs; if (typeof content !== 'string') return { ok: false, error: 'invalid content', reason: 'invalid-content' }; @@ -271,6 +318,7 @@ async function writeChangesFile({ cwd, relPath, content, version, maxBytes }, de module.exports = { readChangesFile, writeChangesFile, + locateChangesFile, requireLocalTarget, resolveTargetInsideRepo, isSafeRepoRelativePath, diff --git a/main.js b/main.js index 55a6a7f2..f48d6a52 100644 --- a/main.js +++ b/main.js @@ -1828,6 +1828,19 @@ const changesWatchers = createChangesWatchRegistry({ }, }); +// filePath is absolute here — the only Changes IPC that takes one, and it +// gives back a repo-relative row — see .ai/contexts/changes-view.md +ipcMain.handle('git-changes-locate', async (_event, sessionId, filePath) => { + if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; + const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); + if (!target.ok) return target; + try { + return await gitChangesFile.locateChangesFile({ cwd: target.cwd, absolutePath: filePath }); + } catch (err) { + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('git-changes-watch', async (_event, sessionId, filePath) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path', reason: 'invalid-path' }; const target = gitChangesFile.requireLocalTarget(resolveGitChangesTarget(sessionId)); diff --git a/preload.js b/preload.js index 78414cda..0c16d842 100644 --- a/preload.js +++ b/preload.js @@ -38,6 +38,7 @@ contextBridge.exposeInMainWorld('api', { gitChangesDiff: (sessionId, filePath, staged, untracked) => ipcRenderer.invoke('git-changes-diff', sessionId, filePath, staged, untracked), gitChangesFile: (sessionId, filePath, opts) => ipcRenderer.invoke('git-changes-file', sessionId, filePath, opts), gitChangesSave: (sessionId, filePath, content, version) => ipcRenderer.invoke('git-changes-save', sessionId, filePath, content, version), + gitChangesLocate: (sessionId, filePath) => ipcRenderer.invoke('git-changes-locate', sessionId, filePath), gitChangesWatch: (sessionId, filePath) => ipcRenderer.invoke('git-changes-watch', sessionId, filePath), gitChangesUnwatch: (sessionId, filePath) => ipcRenderer.invoke('git-changes-unwatch', sessionId, filePath), onGitChangesFileChanged: (callback) => { diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index 337a223c..4b9920d4 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -12,7 +12,7 @@ const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); -const { readChangesFile, writeChangesFile, versionOf, resolveTargetInsideRepo, hasGitSegment } = require('../git-changes-file'); +const { readChangesFile, writeChangesFile, versionOf, resolveTargetInsideRepo, hasGitSegment, locateChangesFile } = require('../git-changes-file'); // git translates its diagnostics; the assertions below match its English text. process.env.LC_ALL = 'C'; @@ -882,3 +882,154 @@ test('real git: a single line with no trailing newline round-trips byte-identica assert.equal(git(repoDir, ['status', '--porcelain', '--', 'oneline.txt']).trim(), ''); } finally { cleanup(tmp); } }); + +// --- A file link's absolute path, mapped to a row ------------------------- + +function locate(repoDir, absolutePath) { + return locateChangesFile({ cwd: repoDir, absolutePath }); +} + +test('real git: an absolute path inside the repo maps to its repo-relative row, with the row\'s own flags', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, 'src')); + fs.writeFileSync(path.join(repoDir, 'src', 'tracked.js'), 'one\n'); + git(repoDir, ['add', 'src/tracked.js']); + git(repoDir, ['commit', '-q', '-m', 'add']); + fs.writeFileSync(path.join(repoDir, 'src', 'tracked.js'), 'two\n'); + + const modified = await locate(repoDir, path.join(repoDir, 'src', 'tracked.js')); + assert.equal(modified.ok, true, modified.error); + assert.equal(modified.relPath, 'src/tracked.js', 'the renderer is handed the pathspec, never the root'); + assert.equal(modified.changed, true); + assert.equal(modified.staged, false, 'an unstaged edit opens against the index'); + assert.equal(modified.untracked, false); + + git(repoDir, ['add', 'src/tracked.js']); + const staged = await locate(repoDir, path.join(repoDir, 'src', 'tracked.js')); + assert.equal(staged.staged, true, 'a staged-only edit opens against HEAD'); + } finally { cleanup(tmp); } +}); + +test('real git: an untracked file is a row too, and an unmodified one is not (mutation target: the changed check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, 'brand-new.txt'), 'new\n'); + git(repoDir, ['add', 'f.txt']); + git(repoDir, ['commit', '-q', '-m', 'clean']); + fs.writeFileSync(path.join(repoDir, 'clean.txt'), 'x\n'); + git(repoDir, ['add', 'clean.txt']); + git(repoDir, ['commit', '-q', '-m', 'clean2']); + + const untracked = await locate(repoDir, path.join(repoDir, 'brand-new.txt')); + assert.equal(untracked.changed, true, 'an untracked file is a legitimate row'); + assert.equal(untracked.untracked, true); + + const unmodified = await locate(repoDir, path.join(repoDir, 'clean.txt')); + assert.equal(unmodified.ok, true, 'an unmodified file is not an error'); + assert.equal(unmodified.changed, false, 'it just has no diff to show'); + assert.equal(unmodified.relPath, 'clean.txt'); + } finally { cleanup(tmp); } +}); + +test('real git: a path outside the repository is refused, not mapped (mutation target: the containment check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const outside = path.join(tmp, 'outside.txt'); + fs.writeFileSync(outside, 'secret\n'); + + const result = await locate(repoDir, outside); + assert.equal(result.ok, false); + assert.equal(result.reason, 'outside'); + assert.ok(!('relPath' in result), 'nothing about the repository leaks back for a path outside it'); + + const missing = await locate(repoDir, path.join(repoDir, 'nope.txt')); + assert.equal(missing.ok, false); + assert.equal(missing.reason, 'missing'); + } finally { cleanup(tmp); } +}); + +test('real git: a link into the git directory or through a symlink is refused by the same guard as a row', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.symlinkSync(path.join(repoDir, '.git'), path.join(repoDir, 'gitlink')); + fs.symlinkSync(path.join(repoDir, 'f.txt'), path.join(repoDir, 'flink')); + + // Both spellings resolve to the same place, and the relative path computed + // from the resolved one carries the .git segment either way. + for (const spelling of [path.join(repoDir, '.git', 'config'), path.join(repoDir, 'gitlink', 'config')]) { + const refused = await locate(repoDir, spelling); + assert.equal(refused.ok, false, `must refuse ${spelling}`); + assert.equal(refused.reason, 'invalid-path'); + assert.ok(!('relPath' in refused), 'and hands back no pathspec to open'); + } + + // A symlink resolves to its target, which is an ordinary row: what is + // refused is editing the link itself, and that is what relPath names. + const link = await locate(repoDir, path.join(repoDir, 'flink')); + assert.equal(link.ok, true, link.error); + assert.equal(link.relPath, 'f.txt', 'the row is the file the link points at, inside the repo'); + } finally { cleanup(tmp); } +}); + +test('real git: a path in a subdirectory keeps forward slashes, the spelling every other Changes IPC uses', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.mkdirSync(path.join(repoDir, 'a', 'b'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'a', 'b', 'c.txt'), 'deep\n'); + + const result = await locate(repoDir, path.join(repoDir, 'a', 'b', 'c.txt')); + assert.equal(result.relPath, 'a/b/c.txt'); + assert.equal(result.changed, true); + + const reread = await readChangesFile({ cwd: repoDir, relPath: result.relPath, staged: false, maxBytes: MAX_BYTES }); + assert.equal(reread.ok, true, 'the pathspec it returns is one the read accepts: ' + reread.error); + assert.equal(reread.current, 'deep\n'); + } finally { cleanup(tmp); } +}); + +test('real git: a link to a file no row could open is not offered as a row (mutation target: the shared guard in locate)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, '.env'), 'TOKEN=secret\n'); + fs.mkdirSync(path.join(repoDir, 'adir')); + + // Sensitive: the read refuses it, so the link must not route there either. + const sensitive = await locate(repoDir, path.join(repoDir, '.env')); + assert.equal(sensitive.ok, false); + assert.equal(sensitive.reason, 'sensitive'); + + // A directory link has no row to open. + const dir = await locate(repoDir, path.join(repoDir, 'adir')); + assert.equal(dir.ok, false); + assert.equal(dir.reason, 'not-a-file'); + } finally { cleanup(tmp); } +}); + +test('real git: a git directory that is not called .git is not reachable through a link either', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + fs.mkdirSync(repoDir, { recursive: true }); + git(repoDir, ['init', '-q', '--separate-git-dir', path.join(repoDir, 'customgit')]); + git(repoDir, ['config', 'user.email', 'a@a.com']); + git(repoDir, ['config', 'user.name', 'a']); + fs.writeFileSync(path.join(repoDir, 'f.txt'), 'hello\n'); + + const result = await locate(repoDir, path.join(repoDir, 'customgit', 'config')); + assert.equal(result.ok, false); + assert.equal(result.reason, 'git-dir', 'no segment rule can see this one; containment can'); + } finally { cleanup(tmp); } +}); From 0a2fa4028e44c859898ddf7ef62ddc809d1f3a60 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 12:43:46 +0200 Subject: [PATCH 16/29] feat(changes): keep the file list on screen while a file is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing a set of changed files meant list, click, editor, Back, click, editor. The list now stays: the selected file opens below it, the current row is highlighted, and clicking another row swaps the file without leaving the list. The editor's first button closes the file and keeps the list, so it is Close rather than Back — there is no navigation step left to undo. The divider between the two is the splitter the panel's shell region already uses, with the height model that region settled on: the drag's own value is what is stored, and it is clamped only for display, so a short panel or an open shell cannot ratchet the list down. The list has a floor of its own (96px, about four rows) and the editor keeps 120px; below that the list scrolls rather than either one collapsing. Switching rows is an exit like the others, and asks the same question through the same function when the buffer is dirty; a refusal stays on the file it was already showing. Clicking the row that is already open is not a switch and asks nothing. A terminal file link pointing at one of the session's changed files now opens there too, on that row, instead of the plain viewer. Anything else — an unmodified file, a path outside the repository, a remote session, or a main process that cannot answer — keeps the plain viewer it has always had. --- public/file-panel.js | 116 ++++++++-- public/style.css | 32 ++- test/dom-file-panel-changes.test.js | 344 ++++++++++++++++++++++++++-- 3 files changed, 455 insertions(+), 37 deletions(-) diff --git a/public/file-panel.js b/public/file-panel.js index 34092b3a..3cc11df8 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -45,10 +45,19 @@ let changesDiffSaveBtn = null; let changesDiffReloadBtn = null; let changesDiffNoticeEl = null; let changesDiffHostEl = null; +let changesListSplitterEl = null; // Row ceiling for the Changes list — see .ai/contexts/changes-view.md ("Untracked files") const MAX_CHANGES_ROWS = 500; +const CHANGES_LIST_HEIGHT_KEY = 'changesListHeight'; +const DEFAULT_CHANGES_LIST_HEIGHT = 200; +// A floor of its own so the list never collapses to a row or two; the editor +// keeps the rest — see .ai/contexts/changes-view.md ("The list and the editor") +const MIN_CHANGES_LIST_HEIGHT = 96; +const MIN_CHANGES_EDITOR_HEIGHT = 120; +let changesListDesiredHeight = readStoredChangesListHeight(); + const PANEL_WIDTH_KEY = 'filePanelWidth'; const DEFAULT_PANEL_WIDTH = parseInt(localStorage.getItem(PANEL_WIDTH_KEY), 10) || 450; const MIN_PANEL_WIDTH = 280; @@ -204,12 +213,18 @@ function initFilePanel() { changesListEl.id = 'changes-list'; changesContainerEl.appendChild(changesListEl); + changesListSplitterEl = document.createElement('div'); + changesListSplitterEl.id = 'changes-list-splitter'; + changesListSplitterEl.style.display = 'none'; + changesContainerEl.appendChild(changesListSplitterEl); + changesDiffEl = document.createElement('div'); changesDiffEl.id = 'changes-diff-view'; changesDiffEl.style.display = 'none'; changesContainerEl.appendChild(changesDiffEl); buildChangesDiffChrome(); + setupChangesListSplitter(); // Shell region below every tab type — see .ai/contexts/panel-terminal.md if (typeof initPanelTerminal === 'function') initPanelTerminal(filePanelContentEl); @@ -454,12 +469,36 @@ function destroyCurrentTab(state, { stash = true } = {}) { } } +// A link to one of the session's own changed files opens where it can be +// edited against its diff — see .ai/contexts/changes-view.md ("File links") async function openFileInPanel(sessionId, filePath) { + const row = await locateChangesRow(sessionId, filePath); + if (row) return openChangesTabAt(sessionId, row); + const result = await window.api.readFileForPanel(filePath); if (!result.ok) return; openFileTab(sessionId, { filePath, content: result.content }); } +async function locateChangesRow(sessionId, filePath) { + if (!window.api.gitChangesLocate) return null; + let located; + try { + located = await window.api.gitChangesLocate(sessionId, filePath); + } catch { + return null; + } + if (!located || !located.ok || !located.changed) return null; + return { path: located.relPath, staged: !!located.staged, untracked: !!located.untracked }; +} + +async function openChangesTabAt(sessionId, file) { + const tab = getSessionState(sessionId).currentTab; + if (!tab || tab.type !== 'changes') await openChangesTab(sessionId); + // openChangesDiff owns the discard question for every route into it. + return openChangesDiff(sessionId, file); +} + function closeAllDiffs(sessionId) { const state = filePanelState.get(sessionId); if (!state) return; @@ -824,6 +863,8 @@ async function openChangesDiff(sessionId, file) { if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; + if (tab.selectedFile && !isSelectedChangesRow(tab, file) && !confirmDiscardChangesEdits(tab)) return; + tab.selectedFile = file; tab.diffError = null; tab.diffContent = null; @@ -954,17 +995,20 @@ function confirmDiscardChangesEdits(tab) { } function renderChangesContent(sessionId, tab) { - if (tab.selectedFile) { - changesSummaryEl.style.display = 'none'; - changesListEl.style.display = 'none'; - changesDiffEl.style.display = 'flex'; - renderChangesDiff(sessionId, tab); - return; - } - changesDiffEl.style.display = 'none'; + const editorOpen = !!tab.selectedFile; changesSummaryEl.style.display = 'block'; changesListEl.style.display = 'block'; + changesListSplitterEl.style.display = editorOpen ? 'block' : 'none'; + changesDiffEl.style.display = editorOpen ? 'flex' : 'none'; + changesListEl.classList.toggle('changes-list-split', editorOpen); + if (editorOpen) applyChangesListHeight(); + else changesListEl.style.height = ''; + + renderChangesList(sessionId, tab); + if (editorOpen) renderChangesDiff(sessionId, tab); +} +function renderChangesList(sessionId, tab) { const branchInfoEl = document.getElementById('changes-branch-info'); if (tab.loading && !tab.data) { @@ -1010,7 +1054,7 @@ function renderChangesContent(sessionId, tab) { changesListEl.innerHTML = ''; const shown = files.length > MAX_CHANGES_ROWS ? files.slice(0, MAX_CHANGES_ROWS) : files; for (const file of shown) { - changesListEl.appendChild(buildChangesFileRow(sessionId, file)); + changesListEl.appendChild(buildChangesFileRow(sessionId, tab, file)); } if (shown.length < files.length) { const more = document.createElement('div'); @@ -1020,7 +1064,7 @@ function renderChangesContent(sessionId, tab) { } } -function buildChangesFileRow(sessionId, file) { +function buildChangesFileRow(sessionId, tab, file) { const row = document.createElement('div'); row.className = 'changes-file-row'; row.dataset.path = file.path; @@ -1049,6 +1093,8 @@ function buildChangesFileRow(sessionId, file) { row.appendChild(counts); } + if (isSelectedChangesRow(tab, file)) row.classList.add('selected'); + row.addEventListener('click', () => { // prefer the unstaged (worktree) diff when a file has both const staged = !!file.staged && !file.unstaged; @@ -1057,6 +1103,39 @@ function buildChangesFileRow(sessionId, file) { return row; } +function readStoredChangesListHeight() { + const stored = parseInt(localStorage.getItem(CHANGES_LIST_HEIGHT_KEY), 10); + return Number.isFinite(stored) ? Math.max(MIN_CHANGES_LIST_HEIGHT, stored) : DEFAULT_CHANGES_LIST_HEIGHT; +} + +// Store what the drag asked for, clamp only for display, so a transient shrink +// never ratchets the list down — the height model panel-terminal.js settled on. +function clampChangesListHeight(height, available) { + const wanted = Math.max(MIN_CHANGES_LIST_HEIGHT, Math.round(height)); + if (!available) return wanted; + const ceiling = available - MIN_CHANGES_EDITOR_HEIGHT; + return Math.min(wanted, Math.max(MIN_CHANGES_LIST_HEIGHT, ceiling)); +} + +function applyChangesListHeight() { + if (!changesListEl || !changesContainerEl) return; + const available = changesContainerEl.clientHeight - changesSummaryEl.offsetHeight; + changesListEl.style.height = clampChangesListHeight(changesListDesiredHeight, available) + 'px'; +} + +function setupChangesListSplitter() { + if (typeof createSplitter !== 'function') return; + createSplitter(changesListSplitterEl, { + axis: 'y', + getSize: () => changesListEl.offsetHeight || changesListDesiredHeight, + onDrag: (startSize, delta) => { + changesListDesiredHeight = Math.max(MIN_CHANGES_LIST_HEIGHT, Math.round(startSize + delta)); + applyChangesListHeight(); + }, + onCommit: () => localStorage.setItem(CHANGES_LIST_HEIGHT_KEY, String(changesListDesiredHeight)), + }); +} + // Built once: a render must never tear the open editor down — see .ai/contexts/changes-view.md function buildChangesDiffChrome() { const header = document.createElement('div'); @@ -1073,13 +1152,15 @@ function buildChangesDiffChrome() { const controls = document.createElement('div'); controls.className = 'viewer-toolbar-controls'; - const backBtn = document.createElement('button'); - backBtn.className = 'fp-toolbar-btn'; - backBtn.textContent = 'Back'; - backBtn.addEventListener('click', () => { + const closeEditorBtn = document.createElement('button'); + closeEditorBtn.className = 'fp-toolbar-btn'; + closeEditorBtn.id = 'changes-diff-close-btn'; + closeEditorBtn.textContent = 'Close'; + closeEditorBtn.title = 'Close the editor and keep the file list'; + closeEditorBtn.addEventListener('click', () => { if (currentPanelSessionId) closeChangesDiff(currentPanelSessionId); }); - controls.appendChild(backBtn); + controls.appendChild(closeEditorBtn); changesDiffModeBtn = document.createElement('button'); changesDiffModeBtn.className = 'fp-toolbar-btn'; @@ -1225,6 +1306,11 @@ function mountChangesEditor(dom) { if (dom.parentNode !== changesDiffHostEl) changesDiffHostEl.appendChild(dom); } +function isSelectedChangesRow(tab, file) { + const selected = tab && tab.selectedFile; + return !!selected && selected.path === file.path; +} + function changesEditorKey(tab) { const file = tab.selectedFile; return file ? JSON.stringify([file.path, !!file.staged]) : ''; diff --git a/public/style.css b/public/style.css index 8042d2f2..b91ddfbb 100644 --- a/public/style.css +++ b/public/style.css @@ -4549,6 +4549,7 @@ body { display: flex; flex-direction: column; } #changes-list { flex: 1; overflow-y: auto; + min-height: 0; } .changes-file-row { @@ -4619,11 +4620,40 @@ body { display: flex; flex-direction: column; } color: #9090a8; } +/* The list keeps its explicit height while the editor is open; it is the + editor that takes the remaining space. */ +#changes-list.changes-list-split { + flex: 0 0 auto; +} + +#changes-list-splitter { + display: none; + height: 4px; + flex-shrink: 0; + cursor: row-resize; + background: transparent; + border-top: 1px solid rgba(255,255,255,0.06); +} + +#changes-list-splitter:hover, +#changes-list-splitter.dragging { + background: rgba(120,130,255,0.3); +} + +.changes-file-row.selected { + background: rgba(120,130,255,0.16); +} + +.changes-file-row.selected .changes-file-path { + color: #fff; +} + #changes-diff-view { - flex: 1; + flex: 1 1 0; display: flex; flex-direction: column; min-height: 0; + min-width: 0; } .changes-diff-body { diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 4b6e7473..af8c769b 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -75,11 +75,11 @@ function makeEditorStub(window, mode, doc, created) { return view; } -function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmImpl } = {}) { +function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmImpl, locateImpl } = {}) { const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; - const calls = { status: [], diff: [], file: [], save: [], watch: [], unwatch: [], confirm: [] }; + const calls = { status: [], diff: [], file: [], save: [], watch: [], unwatch: [], confirm: [], locate: [], readFile: [] }; const editors = []; const fileChangedListeners = []; @@ -114,6 +114,14 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm return Promise.resolve({ ok: true }); }, onGitChangesFileChanged: (cb) => { fileChangedListeners.push(cb); }, + gitChangesLocate: (sessionId, filePath) => { + calls.locate.push({ sessionId, filePath }); + return Promise.resolve((locateImpl || (() => ({ ok: false, reason: 'outside' })))(sessionId, filePath)); + }, + readFileForPanel: (filePath) => { + calls.readFile.push(filePath); + return Promise.resolve({ ok: true, content: 'plain content' }); + }, }; // jsdom's own window.confirm throws "not implemented"; the panel asks before @@ -153,6 +161,7 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm }); Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + evalInWindow(dom, path.join(PUBLIC_DIR, 'splitter.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-state.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity-dom.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity.js')); @@ -172,6 +181,7 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm for (const cb of fileChangedListeners) cb(sessionId, filePath); }, setActivity: read('setActivity'), + clampListHeight: read('clampChangesListHeight'), stashOf: (sessionId) => { const state = read('filePanelState').get(sessionId); return state ? state.changesStash : undefined; @@ -193,8 +203,8 @@ function clickRow(ctx, filePath) { .dispatchEvent(new ctx.window.Event('click', { bubbles: true })); } -function backBtn(ctx) { - return Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find((b) => b.textContent === 'Back'); +function closeEditorBtn(ctx) { + return ctx.document.getElementById('changes-diff-close-btn'); } async function openFile(ctx, sessionId, filePath) { @@ -269,8 +279,7 @@ test('clicking a file row on a remote session opens a read-only diff colored by assert.ok(hunkLine && hunkLine.textContent.startsWith('@@')); // Back returns to the file list without another status call. - const backBtn = Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find(b => b.textContent === 'Back'); - backBtn.click(); + closeEditorBtn(ctx).click(); assert.equal(ctx.document.getElementById('changes-list').style.display, 'block'); assert.equal(ctx.calls.status.length, 1, 'returning to the list must not re-fetch status'); } finally { ctx.destroy(); } @@ -321,8 +330,7 @@ test('an untracked file\'s counts and the header totals pick up the additions it .dispatchEvent(new ctx.window.Event('click', { bubbles: true })); await flush(); - const backBtn = Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find(b => b.textContent === 'Back'); - backBtn.click(); + closeEditorBtn(ctx).click(); const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); assert.ok(counts, 'the row now renders counts like any other row'); @@ -352,8 +360,7 @@ test('an untracked binary file keeps null counts — the row stays countless and const body = ctx.document.querySelector('.changes-diff-body'); assert.match(body.textContent, /Binary files .* differ/); - const backBtn = Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find(b => b.textContent === 'Back'); - backBtn.click(); + closeEditorBtn(ctx).click(); assert.equal(ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'), null); assert.match(ctx.document.getElementById('changes-summary').textContent, /\+3 −1/, 'unknown counts must not be folded in as zero'); @@ -397,8 +404,7 @@ test('a count computed against one status result is never applied to a later one releaseDiff(); await flush(); - const backBtn = Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find(b => b.textContent === 'Back'); - backBtn.click(); + closeEditorBtn(ctx).click(); const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); assert.equal(counts.textContent, '+2−7', 'git\'s own counts must survive the stale diff'); @@ -473,8 +479,7 @@ test('a failed untracked diff surfaces the error and leaves the counts alone', a const body = ctx.document.querySelector('.changes-diff-body'); assert.match(body.textContent, /fatal: bad thing/); - const backBtn = Array.from(ctx.document.querySelectorAll('#changes-diff-view button')).find(b => b.textContent === 'Back'); - backBtn.click(); + closeEditorBtn(ctx).click(); assert.equal(ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'), null); } finally { ctx.destroy(); } }); @@ -530,7 +535,7 @@ test('a count computed from the content pair is never applied to a later status releasePair(); await flush(); - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); assert.equal(counts.textContent, '+2−7', 'git\'s own counts must survive the stale pair'); assert.match(ctx.document.getElementById('changes-summary').textContent, /2 files changed \+5 −8/); @@ -919,7 +924,7 @@ test('an untracked local file opens with an empty original and its own lines as assert.deepEqual(ctx.calls.file, [{ sessionId: 's1', filePath: 'new.txt', staged: false }]); assert.equal(ctx.editors[0].opened.original, ''); - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); assert.equal(counts.textContent, '+2−0'); assert.match(ctx.document.getElementById('changes-summary').textContent, /2 files changed \+5 −1/); @@ -930,7 +935,7 @@ test('closing the panel and going back to the list both destroy the editor (muta const ctx = setupFilePanelDom(); try { await openFile(ctx, 's1', 'src/a.js'); - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); assert.equal(ctx.editors[0].box.destroyed, true, 'Back destroys the editor'); assert.equal(ctx.document.querySelector('#changes-diff-host .fake-editor'), null); @@ -943,6 +948,186 @@ test('closing the panel and going back to the list both destroy the editor (muta } finally { ctx.destroy(); } }); +// --- The list and the editor together ------------------------------------ + +test('opening a file leaves the list on screen, with its row marked (mutation target: hiding the list)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block', + 'the list is the point: reviewing a set of files must not be a round trip'); + assert.equal(ctx.document.getElementById('changes-summary').style.display, 'block'); + assert.equal(ctx.document.getElementById('changes-diff-view').style.display, 'flex'); + assert.equal(ctx.document.querySelectorAll('.changes-file-row').length, 2, 'every row is still there'); + + const selected = ctx.document.querySelectorAll('.changes-file-row.selected'); + assert.equal(selected.length, 1); + assert.equal(selected[0].dataset.path, 'src/a.js'); + } finally { ctx.destroy(); } +}); + +test('clicking another row swaps the editor\'s file without leaving the list', async () => { + const ctx = setupFilePanelDom({ + fileImpl: (_s, filePath) => ({ ok: true, original: '', current: 'content of ' + filePath + '\n', version: 'v1' }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.equal(ctx.editors[0].opened.modified, 'content of src/a.js\n'); + + clickRow(ctx, 'new.txt'); + await flush(); + + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'new.txt'); + assert.equal(ctx.editors[ctx.editors.length - 1].opened.modified, 'content of new.txt\n'); + assert.equal(ctx.document.getElementById('changes-list').style.display, 'block'); + const selected = ctx.document.querySelectorAll('.changes-file-row.selected'); + assert.equal(selected.length, 1, 'exactly one row is current'); + assert.equal(selected[0].dataset.path, 'new.txt'); + } finally { ctx.destroy(); } +}); + +test('switching rows with unsaved edits asks first, and a refusal stays on the file (mutation target: the row-switch guard)', async () => { + const ctx = setupFilePanelDom({ confirmImpl: () => false }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + clickRow(ctx, 'new.txt'); + await flush(); + + assert.equal(ctx.calls.confirm.length, 1, 'the same question the other exits ask'); + assert.equal(ctx.calls.file.length, 1, 'the refused switch fetched nothing'); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); + assert.equal(ctx.editors[0].box.destroyed, false); + assert.equal(ctx.editors[0].box.text, 'my edit\n'); + assert.equal(ctx.document.querySelector('.changes-file-row.selected').dataset.path, 'src/a.js'); + } finally { ctx.destroy(); } +}); + +test('switching rows with unsaved edits proceeds once the user confirms', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + clickRow(ctx, 'new.txt'); + await flush(); + + assert.equal(ctx.calls.confirm.length, 1); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'new.txt'); + assert.equal(ctx.editors[0].box.destroyed, true, 'the discarded buffer is gone'); + } finally { ctx.destroy(); } +}); + +test('clicking the row that is already open re-reads it without asking anything', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + clickRow(ctx, 'src/a.js'); + await flush(); + + assert.equal(ctx.calls.confirm.length, 0, 'the current file is not another file'); + } finally { ctx.destroy(); } +}); + +test('a row switch confirmed by the user leaves no stash to resurrect (mutation target: the row-switch exit)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'I ASKED TO DISCARD THIS\n'; + + // A live stash, the way the other exits are pinned. + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + assert.ok(ctx.stashOf('s1'), 'the stash is live before the switch'); + await ctx.window.openChangesTab('s1'); + await flush(); + + clickRow(ctx, 'new.txt'); + await flush(); + assert.equal(ctx.calls.confirm.length, 1); + assert.equal(ctx.stashOf('s1'), null, 'switching away from a discarded buffer must not stash it'); + + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.stashOf('s1'), null); + assert.equal(ctx.document.getElementById('changes-diff-view').style.display, 'none', + 'and nothing from before the switch comes back — the editor region is closed'); + } finally { ctx.destroy(); } +}); + +test('an idle refresh rebuilds the list without disturbing the open editor', async () => { + let files = null; + const ctx = setupFilePanelDom({ + statusImpl: () => (files ? makeStatusResult({ files, totals: { files: files.length, added: 9, deleted: 0 } }) : makeStatusResult()), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + const editor = ctx.editors[0]; + const host = ctx.document.getElementById('changes-diff-host'); + + const records = []; + const observer = new ctx.window.MutationObserver((list) => records.push(...list)); + observer.observe(host, { childList: true }); + + files = [ + { path: 'src/a.js', origPath: null, staged: true, unstaged: false, untracked: false, renamed: false, state: 'M', added: 9, deleted: 0 }, + { path: 'new.txt', origPath: null, staged: false, unstaged: false, untracked: true, renamed: false, state: '?', added: null, deleted: null }, + { path: 'third.js', origPath: null, staged: false, unstaged: true, untracked: false, renamed: false, state: 'M', added: 1, deleted: 1 }, + ]; + ctx.setActivity('s1', true); + ctx.setActivity('s1', false); + await flush(); + await flush(); + observer.disconnect(); + + assert.equal(ctx.document.querySelectorAll('.changes-file-row').length, 3, 'the list followed the session'); + assert.deepEqual(records, [], 'and the editor was not touched'); + assert.equal(editor.box.destroyed, false); + assert.equal(ctx.document.querySelector('.changes-file-row.selected').dataset.path, 'src/a.js', + 'the open file is still marked after the rebuild'); + } finally { ctx.destroy(); } +}); + +test('the list keeps an explicit height only while the editor is open, and the drag persists it', async () => { + const ctx = setupFilePanelDom(); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + + const list = ctx.document.getElementById('changes-list'); + const splitter = ctx.document.getElementById('changes-list-splitter'); + assert.equal(list.style.height, '', 'with no editor the list takes the panel'); + assert.equal(splitter.style.display, 'none'); + + clickRow(ctx, 'src/a.js'); + await flush(); + assert.equal(splitter.style.display, 'block'); + assert.notEqual(list.style.height, '', 'the split gives the list a bounded height'); + + // Drag the handle down: the list grows by the delta. + const before = parseInt(list.style.height, 10); + splitter.dispatchEvent(new ctx.window.MouseEvent('mousedown', { clientY: 100, bubbles: true })); + ctx.document.dispatchEvent(new ctx.window.MouseEvent('mousemove', { clientY: 160, bubbles: true })); + ctx.document.dispatchEvent(new ctx.window.MouseEvent('mouseup', { bubbles: true })); + + const after = parseInt(list.style.height, 10); + assert.ok(after > before, `the drag grew the list: ${before} -> ${after}`); + assert.equal(ctx.window.localStorage.getItem('changesListHeight'), String(after), + 'and what the drag asked for is what is stored'); + + closeEditorBtn(ctx).click(); + await flush(); + assert.equal(list.style.height, '', 'closing the editor gives the list the panel back'); + } finally { ctx.destroy(); } +}); + // --- Staleness: the file moving under the editor ------------------------- test('a save carries the version token from the read, and a refused stale save keeps the buffer and says so (mutation target: the staleness refusal)', async () => { @@ -970,7 +1155,7 @@ test('the open file is watched while it is editable, and unwatched on the way ou await openFile(ctx, 's1', 'src/a.js'); assert.deepEqual(ctx.calls.watch, [{ sessionId: 's1', filePath: 'src/a.js' }]); - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); await flush(); assert.deepEqual(ctx.calls.unwatch, [{ sessionId: 's1', filePath: 'src/a.js' }]); } finally { ctx.destroy(); } @@ -1119,7 +1304,7 @@ for (const exit of ['toggle', 'panel-close', 'back']) { if (exit === 'toggle') ctx.document.getElementById('changes-toggle-btn').click(); else if (exit === 'panel-close') ctx.document.querySelector('#file-panel-changes .fp-close-btn').click(); - else backBtn(ctx).click(); + else closeEditorBtn(ctx).click(); await flush(); assert.equal(ctx.calls.confirm.length, 1, 'the user was asked'); @@ -1219,7 +1404,7 @@ test('an exit that asks nothing never drops a stash, even from the Changes tab i ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); await flush(); - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); assert.equal(ctx.calls.confirm.length, 0, 'a clean buffer is never asked about'); } finally { ctx.destroy(); } }); @@ -1265,7 +1450,7 @@ test('Back asks before discarding unsaved edits, and a refusal keeps the editor await openFile(ctx, 's1', 'src/a.js'); ctx.editors[0].box.text = 'my edit\n'; - backBtn(ctx).click(); + closeEditorBtn(ctx).click(); await flush(); assert.equal(ctx.calls.confirm.length, 1); @@ -1418,3 +1603,120 @@ test('the panel close button destroys the editor too', async () => { assert.equal(ctx.document.getElementById('file-panel').classList.contains('open'), false); } finally { ctx.destroy(); } }); + +// --- A file link from the terminal ---------------------------------------- + +test('a link to a changed file opens the Changes editor on its row (mutation target: the link routing)', async () => { + const ctx = setupFilePanelDom({ + locateImpl: () => ({ ok: true, relPath: 'src/a.js', changed: true, staged: true, untracked: false }), + fileImpl: () => ({ ok: true, original: 'old\n', current: 'new\n', version: 'v1' }), + }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openFileInPanel('s1', '/repo/src/a.js'); + await flush(); + + assert.deepEqual(ctx.calls.locate, [{ sessionId: 's1', filePath: '/repo/src/a.js' }], + 'the absolute path goes to main, which answers with a row'); + assert.deepEqual(ctx.calls.readFile, [], 'the plain viewer is not involved'); + assert.equal(ctx.document.getElementById('file-panel-changes').style.display, 'flex'); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); + assert.deepEqual(ctx.calls.file[0], { sessionId: 's1', filePath: 'src/a.js', staged: true }, + 'and the row it names is opened against the side the row says'); + assert.equal(ctx.document.querySelector('.changes-file-row.selected').dataset.path, 'src/a.js'); + } finally { ctx.destroy(); } +}); + +test('a link to an untracked file opens there too', async () => { + const ctx = setupFilePanelDom({ + locateImpl: () => ({ ok: true, relPath: 'new.txt', changed: true, staged: false, untracked: true }), + fileImpl: () => ({ ok: true, original: '', current: 'brand new\n', version: 'v1' }), + }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openFileInPanel('s1', '/repo/new.txt'); + await flush(); + + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'new.txt'); + assert.equal(ctx.calls.file[0].staged, false); + assert.deepEqual(ctx.calls.readFile, []); + } finally { ctx.destroy(); } +}); + +test('a link to an unmodified file, or one outside the repo, keeps the plain viewer (mutation target: the changed check)', async () => { + for (const answer of [ + { ok: true, relPath: 'clean.txt', changed: false }, + { ok: false, reason: 'outside', error: 'path is outside this session\'s repository' }, + { ok: false, reason: 'remote', error: 'editing is not available for a remote session' }, + ]) { + const ctx = setupFilePanelDom({ locateImpl: () => answer }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openFileInPanel('s1', '/somewhere/clean.txt'); + await flush(); + + assert.deepEqual(ctx.calls.readFile, ['/somewhere/clean.txt'], + `${answer.reason || 'unmodified'} must fall back to the plain editor`); + assert.equal(ctx.calls.file.length, 0, 'and must not open a Changes editor'); + assert.equal(ctx.document.getElementById('file-panel-viewer').style.display, 'flex'); + } finally { ctx.destroy(); } + } +}); + +test('a link while another file is open with unsaved edits asks before switching', async () => { + const ctx = setupFilePanelDom({ + confirmImpl: () => false, + locateImpl: () => ({ ok: true, relPath: 'new.txt', changed: true, staged: false, untracked: true }), + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'my edit\n'; + + await ctx.window.openFileInPanel('s1', '/repo/new.txt'); + await flush(); + + assert.equal(ctx.calls.confirm.length, 1); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js', + 'a refused switch stays where it was, link or row click alike'); + assert.equal(ctx.editors[0].box.text, 'my edit\n'); + } finally { ctx.destroy(); } +}); + +test('a link is still honoured when the panel is closed or showing something else', async () => { + const ctx = setupFilePanelDom({ + locateImpl: () => ({ ok: true, relPath: 'src/a.js', changed: true, staged: true, untracked: false }), + }); + try { + ctx.window.switchPanel('s1'); + ctx.window.openFileTab('s1', { filePath: '/repo/other.js', content: 'other' }); + await flush(); + + await ctx.window.openFileInPanel('s1', '/repo/src/a.js'); + await flush(); + + assert.equal(ctx.document.getElementById('file-panel-changes').style.display, 'flex'); + assert.equal(ctx.document.getElementById('changes-diff-path').textContent, 'src/a.js'); + assert.equal(ctx.calls.status.length, 1, 'the tab it opened loaded its list'); + } finally { ctx.destroy(); } +}); + +test('the list height is clamped for display only, and never ratcheted down (mutation target: the clamp)', () => { + const ctx = setupFilePanelDom(); + try { + const clamp = ctx.clampListHeight; + + // Room for both: the drag gets what it asked for. + assert.equal(clamp(200, 600), 200); + + // A short panel: the editor keeps its floor, the list gives way. + assert.equal(clamp(500, 300), 180, 'available minus the editor floor'); + + // Shorter than both floors: the list keeps its own and the region scrolls + // rather than the list vanishing. + assert.equal(clamp(500, 150), 96); + assert.equal(clamp(10, 600), 96, 'a drag cannot take the list below its floor'); + + // No layout to measure yet: keep what was asked for rather than guessing. + assert.equal(clamp(250, 0), 250); + } finally { ctx.destroy(); } +}); From 15ebaf02898ca37d59742eba5a001e1755ae8e1a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 12:43:46 +0200 Subject: [PATCH 17/29] docs(changes): describe the master-detail layout and where a file link goes The user-facing page no longer describes a Back round trip that does not exist, and says what the divider does and which links open in the editor. The context doc carries the height model, the list and editor floors, the fact that switching rows is an exit like the others, and where the absolute-to- relative mapping lives and what it costs. --- .ai/contexts/changes-view.md | 14 ++++++++++++++ .ai/contexts/ipc-bridge.md | 3 ++- docs/changes-view.md | 9 ++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index eea43f61..7e45df5f 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -518,6 +518,20 @@ A **local** session's selected file is a live editor over the content pair from A **remote** session, and any file the main process refuses to open for editing (binary, over the cap, outside the repository), fall back to the unified-diff text from `git-changes-diff`: one `
` per line, classed by its `+`/`-`/`@@` prefix (`classifyDiffLine()`), set via `textContent` (no HTML injection risk from diff content, which can contain arbitrary user code). The bundled CodeMirror has no diff/patch language mode to colour that blob with, which is why the fallback is deliberately plain. The panel says which of the two it is in, in its notice line, and the refusal's `reason` is what that line reports. +### The list and the editor + +The list is never hidden. A selected file opens *below* it — summary, list, a drag handle, then the editor region — and the current row carries `.selected`. Reviewing a set of files is then click, read, click, which is the whole point of the layout; there is no navigation step to undo, so the editor's first button is **Close** (close the file, keep the list) rather than Back. + +The split uses `createSplitter` (`public/splitter.js`, shared with the panel's shell region) and the height model that region settled on: `changesListDesiredHeight` stores what the drag asked for, `clampChangesListHeight` narrows it only for display against the space actually available, and only the desired value is persisted (`localStorage.changesListHeight`). A transient shrink — a short panel, the shell open — therefore never ratchets the stored height down. The list has a floor of its own (`MIN_CHANGES_LIST_HEIGHT`, 96px, about four rows) so it cannot collapse to nothing, and the editor keeps `MIN_CHANGES_EDITOR_HEIGHT` (120px, the same floor the shell region uses for the content above it). Below that the list scrolls; nothing overlaps and nothing is clipped out of reach. + +Switching rows is an exit like Back, the tab toggle and the panel's close button: it asks `confirmDiscardChangesEdits` when the buffer is dirty and returns without touching anything if the answer is no. Clicking the row that is already open is not a switch and asks nothing. The row cap (`MAX_CHANGES_ROWS`) and the idle refresh are unchanged by the layout — a refresh rebuilds the list while the editor keeps its instance and its DOM node, which the editor-host MutationObserver test pins with the list now rebuilding alongside it. + +### File links + +`openFileInPanel` is the terminal's entry point (OSC 8 `file://` and the context menu) and it hands the renderer an **absolute** path. The renderer never turns that into a pathspec: `git-changes-locate` does, main-side, against the repo root `resolveRepoDirs` computes from the session's own cwd — the same root the read and the write use. It resolves the path on disk, requires containment, runs it through `resolveTargetInsideRepo` so a link cannot reach what a row cannot, and answers with `{relPath, changed, staged, untracked}`. + +"Changed" is one `git status --porcelain=v2 -uall -z -- `, scoped to that one path: **measured on a 20 000-file repository, 14–17 ms against 117–126 ms for the unscoped status the panel runs on open** — cheap enough per click that the renderer does not need to cache or consult its last status, and correct even when no Changes tab is open. An untracked file counts as changed (it is a legitimate row, and the editor handles an empty original). An unmodified file, a path outside the repository and a remote session all answer "not a row" and the link falls back to the plain `ViewerPanel`, which is also what happens if the IPC is missing or throws. + ### The render path is not a teardown The Changes tab re-renders on every busy→idle edge (see "Refresh triggers"), so a render that rebuilt its own DOM would destroy the editor under the user's cursor and discard unsaved edits once per turn the session finishes. Two rules prevent that: diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index b26b6a48..5161d382 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -93,6 +93,7 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing): | `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- `, or `git diff --no-index -- /dev/null ` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). | | `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. | | `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. | +| `git-changes-locate` | `(sessionId, filePath)` | `{ok:true, relPath, changed, staged, untracked} \| {ok:false, error, reason}` | The only Changes IPC that takes an **absolute** path, and it gives back a repo-relative row: a terminal file link maps to a Changes row here, never in the renderer. One `git status` scoped to that path decides `changed`. Local sessions only. | | `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, through `git-changes-watch.js`'s registry, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. A `rename` event re-arms the watch, since an atomic replacement otherwise silences it. | `filePath` is repo-relative in all four. No absolute path crosses this @@ -165,7 +166,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it | | `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused | | `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded | -| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | +| `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` / `git-changes-locate` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | | `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | | `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc | diff --git a/docs/changes-view.md b/docs/changes-view.md index ff2eabdd..2cb0a6d7 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -6,11 +6,14 @@ Click the **Changes** button in the terminal header, next to the stop button. Click it again to close. +A file link in the terminal opens here too, when it points at one of this session's changed files: the panel opens on that row, ready to edit against its diff. A link to a file the session has not touched, or to one outside its repository, opens in the plain viewer as before. + ## What it shows - A header line: `N files changed +A −B`, plus the current branch and how far it is ahead/behind its upstream. - One row per changed file: a state letter (`M` modified, `A` added, `D` deleted, `R`/`C` renamed/copied, `?` untracked), its path, and its own `+added −deleted` line counts. -- Clicking a row opens that file's diff, including an untracked one — a brand-new file shows up as an all-additions diff. A binary file shows a one-line note instead of its bytes. +- Clicking a row opens that file below the list, which stays on screen — the current row is highlighted, and clicking another row swaps the file without going back anywhere. An untracked file opens too, as an all-additions diff. A binary file shows a one-line note instead of its bytes. +- Drag the divider between the list and the file to give either one more room; the position is remembered. - A brand-new directory is listed file by file, not as a single folder row. - A **Refresh** button for a manual pull. @@ -42,7 +45,7 @@ have changed since. On a local session, the open file is a live editor, not a picture of a diff. Type on the right-hand side and the diff recomputes as you go. - **Save** with the Save button or `Ctrl/Cmd+S`. The file list refreshes on save, so the row's counts follow what you wrote. -- The button next to Back cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. +- The button next to **Close** cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. - The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. - These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, and a file over 2 MB. @@ -53,7 +56,7 @@ The session you are watching writes these files, so the panel assumes it is not - While the file is open it is watched. If the session writes it and **your buffer has no unsaved edits**, the editor reloads to what is now on disk. - If you **do** have unsaved edits, your buffer is left exactly as it is and the panel says the file changed on disk. **Reload** replaces it with the version on disk — it asks first, because that discards what you typed. - A save of a file that changed since you opened it is **refused**, not merged and not forced: the panel tells you to reload first, and the session's work stays on disk. Saving again after a reload writes normally. -- Back, closing the tab and closing the panel all ask before discarding unsaved edits. +- Switching to another row, **Close** (which closes the file and keeps the list), closing the tab and closing the panel all ask before discarding unsaved edits. - Whatever line ending the file uses is preserved — CRLF stays CRLF — so a save with no edits leaves git with nothing to report. A byte-order mark is kept too. - If the session opens a file or a diff of its own while you have unsaved edits, the panel switches away without asking, but your edits are kept: reopening **Changes** brings them back and says why. Answering yes to a discard prompt is the opposite instruction, and it is honoured — nothing comes back afterwards. From a97f2d9c0fc2b895569b0873d0177d5d361bbc8a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 12:49:00 +0200 Subject: [PATCH 18/29] test(changes): cover the link fallback when the locate IPC throws The commit that added the routing claims a link still opens the plain viewer when the main process cannot answer; the catch arm that does it had no test. --- test/dom-file-panel-changes.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index af8c769b..63fdd2a2 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -1663,6 +1663,18 @@ test('a link to an unmodified file, or one outside the repo, keeps the plain vie } }); +test('a link falls back to the plain viewer when the main process cannot answer at all', async () => { + const ctx = setupFilePanelDom({ locateImpl: () => { throw new Error('channel closed'); } }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openFileInPanel('s1', '/repo/src/a.js'); + await flush(); + + assert.deepEqual(ctx.calls.readFile, ['/repo/src/a.js'], 'a link still opens something'); + assert.equal(ctx.calls.file.length, 0); + } finally { ctx.destroy(); } +}); + test('a link while another file is open with unsaved edits asks before switching', async () => { const ctx = setupFilePanelDom({ confirmImpl: () => false, From 483512fe647feda9e36f2c8b165e6549dbb7363e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 13:24:16 +0200 Subject: [PATCH 19/29] fix(changes): keep the change a save just wrote, and let Save show when there is one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving an untracked file made its counts vanish and the header total drop back, while git still reported the file as changed. The counts are derived from the content when the row is opened, and the status refresh a save triggers has none of its own — so the save now re-applies them from the bytes it just wrote. The original side is untouched by any of this: a save writes the working tree, not the index or HEAD, so the diff after a save is the same diff. The Save button was always active, including with nothing to save. It now follows the buffer rather than the last render: the three editor factories take an onChange and install a CodeMirror updateListener, so typing, pasting, undo and a programmatic edit all reach it — a DOM input listener would miss the last two. The keyboard path does not consult the disabled attribute, so the handler keeps the clean and in-flight guards itself. Inline becomes the default for this panel. At the panel's 450px default a side-by-side merge view gives each side about 225px and clips code mid-token; one column gets the full width. The MCP diff tab keeps side-by-side under its own key, since it is not confined to this panel. Refresh is the icon the search bar already uses, through the toolbar's existing icon-button class. --- public/codemirror-setup.js | 18 ++- public/file-panel.js | 43 +++++- test/codemirror-merge-editing.test.js | 33 +++++ test/dom-file-panel-changes.test.js | 201 +++++++++++++++++++++++--- 4 files changed, 261 insertions(+), 34 deletions(-) diff --git a/public/codemirror-setup.js b/public/codemirror-setup.js index 65d01d06..a2963e7e 100644 --- a/public/codemirror-setup.js +++ b/public/codemirror-setup.js @@ -416,7 +416,16 @@ function createReadOnlyViewer(parent, content, filename) { // ── Editable File Viewer (for file panel) ─────────────────────────── -function createEditableViewer(parent, content, filename, { wrap = false } = {}) { +// A caller that needs to know the document changed (an enabled/disabled Save, +// say) gets it from CodeMirror rather than from DOM input events, which miss +// undo, paste and programmatic edits. +function docChangeListener(onChange) { + return typeof onChange === 'function' + ? EditorView.updateListener.of((update) => { if (update.docChanged) onChange(); }) + : []; +} + +function createEditableViewer(parent, content, filename, { wrap = false, onChange } = {}) { const langExt = getLanguageExt(filename); const wrapCompartment = new Compartment(); @@ -448,6 +457,7 @@ function createEditableViewer(parent, content, filename, { wrap = false } = {}) syntaxHighlighting(markdownExtras), appThemePatch, wrapCompartment.of(wrap ? EditorView.lineWrapping : []), + docChangeListener(onChange), ], }); @@ -458,7 +468,7 @@ function createEditableViewer(parent, content, filename, { wrap = false } = {}) // ── Diff / Merge Viewer ───────────────────────────────────────────── -function createMergeViewer(parent, originalContent, modifiedContent, filename) { +function createMergeViewer(parent, originalContent, modifiedContent, filename, { onChange } = {}) { const langExt = getLanguageExt(filename); const sharedExts = [ lineNumbers(), @@ -498,6 +508,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) { keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), cmGotoLineKeymap, cmSaveKeymap, + docChangeListener(onChange), ], }, gutter: true, @@ -506,7 +517,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) { }); } -function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename, { mergeControls = true } = {}) { +function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename, { mergeControls = true, onChange } = {}) { const langExt = getLanguageExt(filename); const state = EditorState.create({ doc: modifiedContent, @@ -529,6 +540,7 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file dracula, syntaxHighlighting(markdownExtras), appThemePatch, + docChangeListener(onChange), unifiedMergeView({ original: originalContent, gutter: true, diff --git a/public/file-panel.js b/public/file-panel.js index 3cc11df8..bbb926f7 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -68,9 +68,11 @@ let diffMode = localStorage.getItem(DIFF_MODE_KEY) || 'side-by-side'; const CHANGES_DIFF_MODE_KEY = 'changesDiffMode'; const CHANGES_DIFF_MODES = ['side-by-side', 'inline', 'plain']; const CHANGES_DIFF_MODE_LABELS = { 'side-by-side': 'Side-by-side', inline: 'Inline', plain: 'Plain' }; +// Inline by default: the panel is a column, and side-by-side halves it — see +// .ai/contexts/changes-view.md ("The list and the editor") let changesDiffMode = CHANGES_DIFF_MODES.includes(localStorage.getItem(CHANGES_DIFF_MODE_KEY)) ? localStorage.getItem(CHANGES_DIFF_MODE_KEY) - : 'side-by-side'; + : 'inline'; // ── Initialization ────────────────────────────────────────────────── @@ -188,8 +190,10 @@ function initFilePanel() { changesControls.className = 'viewer-toolbar-controls'; const changesRefreshBtn = document.createElement('button'); - changesRefreshBtn.className = 'fp-toolbar-btn'; - changesRefreshBtn.textContent = 'Refresh'; + changesRefreshBtn.className = 'fp-toolbar-btn fp-icon-btn'; + changesRefreshBtn.id = 'changes-refresh-btn'; + changesRefreshBtn.title = 'Refresh the file list'; + changesRefreshBtn.innerHTML = ''; changesRefreshBtn.addEventListener('click', () => { if (currentPanelSessionId) refreshChanges(currentPanelSessionId); }); @@ -1212,7 +1216,7 @@ function renderChangesDiff(sessionId, tab) { changesDiffModeBtn.textContent = CHANGES_DIFF_MODE_LABELS[changesDiffMode]; changesDiffModeBtn.title = 'Diff view mode — click to cycle'; changesDiffSaveBtn.style.display = tab.editable ? '' : 'none'; - changesDiffSaveBtn.disabled = !!tab.saving; + updateChangesSaveButton(sessionId, tab); changesDiffReloadBtn.style.display = tab.editable ? '' : 'none'; renderChangesNotice(tab); @@ -1241,6 +1245,14 @@ function renderChangesDiff(sessionId, tab) { changesDiffHostEl.appendChild(body); } +// Nothing to save, nothing to press. The keyboard path does not consult the +// attribute, so handleChangesSave keeps its own guard. +function updateChangesSaveButton(sessionId, tab) { + const state = filePanelState.get(sessionId); + if (!state || state.currentTab !== tab) return; + changesDiffSaveBtn.disabled = !!tab.saving || !isChangesBufferDirty(tab); +} + function renderChangesNotice(tab) { const notes = []; const alarming = !!(tab.saveError || tab.fileError || tab.error || tab.externalChange || tab.restoredEdits); @@ -1282,13 +1294,14 @@ function ensureChangesEditor(sessionId, tab) { if (!tab.selectedFile || !tab.editable) return; const filename = tab.selectedFile.path; + const onChange = () => updateChangesSaveButton(sessionId, tab); if (mode === 'plain') { - tab.editorView = window.createEditableViewer(changesDiffHostEl, tab.current, filename); + tab.editorView = window.createEditableViewer(changesDiffHostEl, tab.current, filename, { onChange }); } else if (mode === 'inline') { // mergeControls: false — this panel is not a git client, see .ai/contexts/changes-view.md - tab.editorView = window.createUnifiedMergeViewer(changesDiffHostEl, tab.original, tab.current, filename, { mergeControls: false }); + tab.editorView = window.createUnifiedMergeViewer(changesDiffHostEl, tab.original, tab.current, filename, { mergeControls: false, onChange }); } else { - tab.editorView = window.createMergeViewer(changesDiffHostEl, tab.original, tab.current, filename); + tab.editorView = window.createMergeViewer(changesDiffHostEl, tab.original, tab.current, filename, { onChange }); } tab.editorKey = key; tab.editorMode = mode; @@ -1364,6 +1377,7 @@ async function handleChangesSave(sessionId) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile || tab.saving) return; + if (!isChangesBufferDirty(tab)) return; const content = readChangesEditorContent(tab); if (content == null) return; @@ -1399,7 +1413,20 @@ async function handleChangesSave(sessionId) { tab.savedContent = content; if (result.version) tab.version = result.version; if (typeof window.flashButtonText === 'function') window.flashButtonText(changesDiffSaveBtn, 'Saved!'); - return refreshChanges(sessionId); + + await refreshChanges(sessionId); + + // The refresh re-points selectedFile at its new row, so the file is the same + // file by path, not by identity. + const afterState = filePanelState.get(sessionId); + if (!afterState || afterState.currentTab !== tab) return; + if (!tab.selectedFile || tab.selectedFile.path !== file.path) return; + // An untracked row's counts are click-derived, and this save is the click: + // the bytes just written are exactly what the count is of. + if (file.untracked) { + applyUntrackedCounts(tab, tab.data, file.path, countAddedLines(content), 0); + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + } } async function reloadChangesFile(sessionId) { diff --git a/test/codemirror-merge-editing.test.js b/test/codemirror-merge-editing.test.js index 1ab773e3..6b442049 100644 --- a/test/codemirror-merge-editing.test.js +++ b/test/codemirror-merge-editing.test.js @@ -185,3 +185,36 @@ test('real CodeMirror: the MCP diff tab keeps its per-chunk controls and still s host.remove(); } }); + +test('real CodeMirror: every editing mode reports a document change, including undo and a programmatic edit', async () => { + const window = await loadCodeMirror(); + const { undo } = require('@codemirror/commands'); + + for (const build of [ + (host, onChange) => window.createMergeViewer(host, 'old\n', 'new\n', 'a.js', { onChange }), + (host, onChange) => window.createUnifiedMergeViewer(host, 'old\n', 'new\n', 'a.js', { mergeControls: false, onChange }), + (host, onChange) => window.createEditableViewer(host, 'new\n', 'a.js', { onChange }), + ]) { + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + let changes = 0; + const view = build(host, () => { changes++; }); + const editable = view.b || view; + try { + assert.equal(changes, 0, 'building the view is not an edit'); + + editable.dispatch({ changes: { from: 0, insert: 'typed ' } }); + assert.equal(changes, 1, 'a document change is reported'); + + undo(editable); + assert.equal(changes, 2, 'and so is an undo, which no DOM input event would catch'); + + editable.dispatch({ selection: { anchor: 0 } }); + assert.equal(changes, 2, 'moving the cursor is not a document change'); + } finally { + view.destroy(); + host.remove(); + } + } + assertOnlyLayoutNoise(); +}); diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 63fdd2a2..e9bf2062 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -56,10 +56,16 @@ const DEFAULT_PAIR = { ok: true, original: 'old\n', current: 'new\n', version: ' // A stand-in for a CodeMirror view: it owns a DOM node, reports a document // the test can rewrite (typing), and records its own destruction — enough for // the reuse, dirty-buffer and save paths, none of the bundle. -function makeEditorStub(window, mode, doc, created) { +function makeEditorStub(window, mode, doc, created, onChange) { const dom = window.document.createElement('div'); dom.className = 'fake-editor fake-editor-' + mode; - const box = { text: doc, destroyed: false, mode }; + const box = { + destroyed: false, + mode, + _text: doc, + get text() { return this._text; }, + set text(value) { this._text = value; if (typeof onChange === 'function') onChange(); }, + }; const docSide = { state: { doc: { toString: () => box.text } } }; const view = { dom, @@ -134,21 +140,23 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm // file-panel.js defers every editor to the lazy bundle loader; the suite // stands in for both the loader and the factories it would provide. window.loadCodeMirrorBundle = () => Promise.resolve(); - window.createMergeViewer = (parent, original, modified, filename) => { - const view = makeEditorStub(window, 'side-by-side', modified, editors); + window.createMergeViewer = (parent, original, modified, filename, opts) => { + const view = makeEditorStub(window, 'side-by-side', modified, editors, opts && opts.onChange); + view.opts = opts; view.opened = { original, modified, filename }; parent.appendChild(view.dom); return view; }; window.createUnifiedMergeViewer = (parent, original, modified, filename, opts) => { - const view = makeEditorStub(window, 'inline', modified, editors); + const view = makeEditorStub(window, 'inline', modified, editors, opts && opts.onChange); view.opened = { original, modified, filename }; view.opts = opts; parent.appendChild(view.dom); return view; }; - window.createEditableViewer = (parent, content, filename) => { - const view = makeEditorStub(window, 'plain', content, editors); + window.createEditableViewer = (parent, content, filename, opts) => { + const view = makeEditorStub(window, 'plain', content, editors, opts && opts.onChange); + view.opts = opts; view.opened = { original: null, modified: content, filename }; parent.appendChild(view.dom); return view; @@ -492,8 +500,7 @@ test('the Refresh button re-invokes gitChangesStatus', async () => { await flush(); assert.equal(ctx.calls.status.length, 1); - const refreshBtn = Array.from(ctx.document.querySelectorAll('#file-panel-changes button')).find(b => b.textContent === 'Refresh'); - refreshBtn.click(); + ctx.document.getElementById('changes-refresh-btn').click(); await flush(); assert.equal(ctx.calls.status.length, 2); @@ -686,7 +693,7 @@ test('a local changed file opens in an editable diff over the content pair, with assert.equal(ctx.editors.length, 1); assert.deepEqual(ctx.editors[0].opened, { original: 'old\n', modified: 'new\n', filename: 'src/a.js' }); - assert.equal(ctx.editors[0].box.mode, 'side-by-side', 'the default mode'); + assert.equal(ctx.editors[0].box.mode, 'inline', 'the default at this panel width'); assert.ok(ctx.document.querySelector('#changes-diff-host .fake-editor'), 'the editor is mounted in the diff host'); assert.equal(ctx.document.querySelector('.changes-diff-body'), null, 'no inert diff text alongside the editor'); @@ -872,24 +879,23 @@ test('the mode toggle cycles side-by-side → inline → plain, persists under i ctx.editors[0].box.text = 'edited\n'; const modeBtn = ctx.document.getElementById('changes-diff-mode-btn'); - assert.equal(modeBtn.textContent, 'Side-by-side'); + assert.equal(modeBtn.textContent, 'Inline'); modeBtn.click(); await flush(); - assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'inline'); + assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'plain'); assert.equal(ctx.window.localStorage.getItem('filePanelDiffMode'), null, 'the MCP diff tab keeps its own key'); - assert.equal(ctx.editors[1].box.mode, 'inline'); + assert.equal(ctx.editors[1].box.mode, 'plain'); assert.equal(ctx.editors[1].opened.modified, 'edited\n', 'an unsaved edit is carried into the new view'); modeBtn.click(); await flush(); - assert.equal(ctx.editors[2].box.mode, 'plain'); - assert.equal(ctx.editors[2].opened.original, null, 'plain mode is the file, with no diff decoration'); + assert.equal(ctx.editors[2].box.mode, 'side-by-side'); modeBtn.click(); await flush(); - assert.equal(ctx.editors[3].box.mode, 'side-by-side'); - assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'side-by-side'); + assert.equal(ctx.editors[3].box.mode, 'inline'); + assert.equal(ctx.window.localStorage.getItem('changesDiffMode'), 'inline'); } finally { ctx.destroy(); } }); @@ -898,14 +904,17 @@ test('an inline editor is read back from the view itself, a side-by-side one fro try { await openFile(ctx, 's1', 'src/a.js'); - const sideBySide = ctx.editors[0]; + ctx.document.getElementById('changes-diff-mode-btn').click(); // inline -> plain + ctx.document.getElementById('changes-diff-mode-btn').click(); // plain -> side-by-side + await flush(); + const sideBySide = ctx.editors[ctx.editors.length - 1]; assert.ok(sideBySide.b, 'the side-by-side view edits its b side'); sideBySide.box.text = 'from the b side\n'; ctx.document.getElementById('changes-diff-save-btn').click(); await flush(); assert.equal(ctx.calls.save[0].content, 'from the b side\n'); - ctx.document.getElementById('changes-diff-mode-btn').click(); + ctx.document.getElementById('changes-diff-mode-btn').click(); // side-by-side -> inline await flush(); const inline = ctx.editors[ctx.editors.length - 1]; assert.equal(inline.b, undefined, 'the inline view has no b side'); @@ -1488,11 +1497,17 @@ test('two saves in a row issue one write (mutation target: the in-flight guard)' assert.equal(ctx.calls.save.length, 1, 'the second save lands while the first write is in flight'); + // Saved, so there is nothing to save: the button says so. const saveBtn = ctx.document.getElementById('changes-diff-save-btn'); + assert.equal(saveBtn.disabled, true); + + // The post-save re-read may have rebuilt the view; type into the live one. + ctx.editors[ctx.editors.length - 1].box.text = 'edited again\n'; + assert.equal(saveBtn.disabled, false, 'typing brings it back without a re-render'); saveBtn.click(); saveBtn.click(); await flush(); - assert.equal(ctx.calls.save.length, 2, 'and the button is disabled for the duration too'); + assert.equal(ctx.calls.save.length, 2, 'and the button is disabled for the duration of the write too'); } finally { ctx.destroy(); } }); @@ -1584,11 +1599,9 @@ test('inline mode asks for a merge view with no accept/reject controls — this const ctx = setupFilePanelDom(); try { await openFile(ctx, 's1', 'src/a.js'); - ctx.document.getElementById('changes-diff-mode-btn').click(); - await flush(); const inline = ctx.editors[ctx.editors.length - 1]; - assert.equal(inline.box.mode, 'inline'); + assert.equal(inline.box.mode, 'inline', 'the default at this panel width'); assert.equal(inline.opts.mergeControls, false, 'accept/reject chunk controls would revert working-tree changes'); } finally { ctx.destroy(); } }); @@ -1732,3 +1745,145 @@ test('the list height is clamped for display only, and never ratcheted down (mut assert.equal(clamp(250, 0), 250); } finally { ctx.destroy(); } }); + +// --- A save must not make the change it just wrote disappear -------------- + +// The status a session reports after the file has been written: an untracked +// file is still untracked and still has no numstat counts of its own. +function statusAfterUntrackedSave() { + return makeStatusResult({ + files: [ + { path: 'src/a.js', origPath: null, staged: true, unstaged: false, untracked: false, renamed: false, state: 'M', added: 1, deleted: 0 }, + { path: 'new.txt', origPath: null, staged: false, unstaged: false, untracked: true, renamed: false, state: '?', added: null, deleted: null }, + ], + totals: { files: 2, added: 1, deleted: 0 }, + }); +} + +test('saving an untracked file keeps its counts and the header total (mutation target: dropping the counts a save already has)', async () => { + const ctx = setupFilePanelDom({ + statusImpl: () => statusAfterUntrackedSave(), + fileImpl: (_s, _p) => ({ ok: true, original: '', current: 'one\ntwo\nthree\n', version: 'v1' }), + }); + try { + await openFile(ctx, 's1', 'new.txt'); + // The click derived them from the pair: three lines, all additions. + assert.equal(ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts').textContent, '+3−0'); + assert.match(ctx.document.getElementById('changes-summary').textContent, /\+4 −0/); + + ctx.editors[0].box.text = 'one\ntwo\nthree\nfour\n'; + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + assert.equal(ctx.calls.save.length, 1, 'the save happened'); + const counts = ctx.document.querySelector('.changes-file-row[data-path="new.txt"] .changes-file-counts'); + assert.ok(counts, 'the row must not lose the counts because the user saved'); + assert.equal(counts.textContent, '+4−0', 'and they follow the file as it now is'); + assert.match(ctx.document.getElementById('changes-summary').textContent, /\+5 −0/, + 'the header stays consistent with the rows'); + } finally { ctx.destroy(); } +}); + +test('saving does not collapse the diff: the original side stays the side git compares against (mutation target: re-reading the original from the working tree)', async () => { + let current = 'one\ntwo\n'; + const ctx = setupFilePanelDom({ + statusImpl: () => statusAfterUntrackedSave(), + fileImpl: () => ({ ok: true, original: '', current, version: 'v' + current.length }), + saveImpl: (_s, _p, content) => { current = content; return { ok: true, version: 'v' + content.length }; }, + }); + try { + await openFile(ctx, 's1', 'new.txt'); + assert.equal(ctx.editors[0].opened.original, '', 'an untracked file has no original side'); + + ctx.editors[0].box.text = 'one\ntwo\nthree\n'; + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + const editor = ctx.editors[ctx.editors.length - 1]; + assert.equal(editor.opened.original, '', 'still nothing on the left: the save wrote the working tree, not the index'); + assert.notEqual(editor.box.text, editor.opened.original, 'so there is still a difference on screen'); + } finally { ctx.destroy(); } +}); + +test('saving a tracked file keeps the diff against the index, with git\'s own counts', async () => { + let numstat = { added: 2, deleted: 1 }; + const ctx = setupFilePanelDom({ + statusImpl: () => makeStatusResult({ + files: [{ path: 'src/a.js', origPath: null, staged: false, unstaged: true, untracked: false, renamed: false, state: 'M', added: numstat.added, deleted: numstat.deleted }], + totals: { files: 1, added: numstat.added, deleted: numstat.deleted }, + }), + fileImpl: () => ({ ok: true, original: 'indexed\n', current: 'worktree\n', version: 'v1' }), + saveImpl: () => { numstat = { added: 3, deleted: 1 }; return { ok: true, version: 'v2' }; }, + }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'worktree edited\n'; + + ctx.document.getElementById('changes-diff-save-btn').click(); + await flush(); + + const editor = ctx.editors[ctx.editors.length - 1]; + assert.equal(editor.opened.original, 'indexed\n', 'the index is still what the diff is against'); + assert.equal(ctx.document.querySelector('.changes-file-row[data-path="src/a.js"] .changes-file-counts').textContent, '+3−1', + 'and the counts are git\'s, recomputed after the write'); + } finally { ctx.destroy(); } +}); + +// --- The panel and the sidebar draw from one set of values ---------------- + +test('the panel styles itself from the shared tokens rather than its own literals (mutation target: re-divergence)', () => { + const css = fs.readFileSync(path.join(PUBLIC_DIR, 'style.css'), 'utf8'); + const root = css.slice(0, css.indexOf('}')); + assert.match(root, /:root \{/, 'the shared set is defined in one place'); + for (const token of ['--surface-chrome', '--hairline', '--control-border', '--accent', '--text-muted']) { + assert.match(root, new RegExp(token + ':'), `${token} is defined`); + } + + // Every var() the stylesheet uses resolves against that set. + const defined = new Set([...root.matchAll(/(--[a-z-]+):/g)].map((m) => m[1])); + const used = new Set([...css.matchAll(/var\((--[a-z-]+)\)/g)].map((m) => m[1])); + assert.deepEqual([...used].filter((t) => !defined.has(t)), [], 'no token is used without being defined'); + + // The panel's own chrome, and the sidebar's, are the same value by reference. + const panelRule = css.slice(css.indexOf('#file-panel {'), css.indexOf('}', css.indexOf('#file-panel {'))); + assert.match(panelRule, /background: var\(--surface-chrome\)/, + 'the panel sits on the surface the sidebar sits on'); + const sidebarRule = css.slice(css.indexOf('#sidebar {'), css.indexOf('}', css.indexOf('#sidebar {'))); + assert.match(sidebarRule, /background: var\(--surface-chrome\)/); + + // And the toolbar buttons are not a second treatment of their own. + const btnRule = css.slice(css.indexOf('.fp-toolbar-btn {'), css.indexOf('}', css.indexOf('.fp-toolbar-btn {'))); + assert.match(btnRule, /border: 1px solid var\(--control-border\)/); + assert.match(btnRule, /color: var\(--text-muted\)/); +}); + +test('Ctrl/Cmd+S on a clean buffer saves nothing, since the keyboard path never sees the disabled button', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + assert.equal(ctx.document.getElementById('changes-diff-save-btn').disabled, true, + 'nothing to save, nothing to press'); + + ctx.editors[0].dom.dispatchEvent(new ctx.window.CustomEvent('cm-save', { bubbles: true })); + await flush(); + + assert.deepEqual(ctx.calls.save, [], 'and the keyboard path declines for the same reason'); + } finally { ctx.destroy(); } +}); + +test('the Refresh control is the icon this app already uses, not a word (mutation target: the icon)', async () => { + const ctx = setupFilePanelDom(); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + + const btn = ctx.document.getElementById('changes-refresh-btn'); + assert.ok(btn.classList.contains('fp-icon-btn'), 'it uses the toolbar\'s icon-button treatment'); + assert.equal(btn.textContent.trim(), '', 'no word'); + const svg = btn.querySelector('svg'); + assert.ok(svg, 'an icon'); + assert.equal(svg.getAttribute('viewBox'), '0 0 24 24', 'the same one the search bar reindex button draws'); + assert.match(btn.title, /refresh/i, 'and it says what it does on hover'); + } finally { ctx.destroy(); } +}); From a6612beb033edd0380ff66c0b15f000cc1e05a8f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 13:24:16 +0200 Subject: [PATCH 20/29] style(panel): draw the panel and the sidebar from one set of values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two carried the same surfaces, hairlines, accents and control borders as separate literals — 188 occurrences of eleven values — so the panel's chrome could drift from the sidebar's a rule at a time. They are tokens on :root now, and every substitution holds the literal it replaced, so nothing moves except the one deliberate change: the file panel sits on the sidebar's surface rather than a darker one of its own. The busy-spinner tint assertions matched the accent by spelling; they accept either form now, which is the property they were written to protect — that those rules reuse the shared violet rather than picking a colour of their own. --- public/style.css | 393 ++++++++++++++------------ test/sidebar-busy-agents-tint.test.js | 4 +- 2 files changed, 208 insertions(+), 189 deletions(-) diff --git a/public/style.css b/public/style.css index b91ddfbb..9b137a5e 100644 --- a/public/style.css +++ b/public/style.css @@ -1,9 +1,26 @@ +/* Shared surfaces, borders and accents. The sidebar and the file panel + draw from the same set rather than repeating the values. + see .ai/contexts/changes-view.md ("The list and the editor") */ +:root { + --surface-chrome: #18181f; + --surface-sunken: #111118; + --hairline: rgba(255,255,255,0.06); + --control-border: rgba(255,255,255,0.08); + --control-surface: rgba(255,255,255,0.04); + --accent: #8088ff; + --accent-border: rgba(120,130,255,0.3); + --accent-border-strong: rgba(120,130,255,0.4); + --accent-wash: rgba(120,130,255,0.06); + --accent-wash-strong: rgba(120,130,255,0.12); + --text-muted: #9090a8; +} + * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif; - background: #111118; + background: var(--surface-sunken); color: #e0e0e0; overflow: hidden; } @@ -37,9 +54,9 @@ body { display: flex; flex-direction: column; } } #update-toast.hidden { display: none; } #update-toast-msg { white-space: nowrap; line-height: 1.4; margin-right: 6px; } -.update-version { color: #8088ff; font-size: 11px; } +.update-version { color: var(--accent); font-size: 11px; } .update-notes-link { - color: #8088ff; + color: var(--accent); font-size: 11px; text-decoration: none; } @@ -53,7 +70,7 @@ body { display: flex; flex-direction: column; } line-height: 1.3; } #update-restart-btn { - background: #8088ff; + background: var(--accent); color: #fff; border: none; border-radius: 4px; @@ -96,7 +113,7 @@ body { display: flex; flex-direction: column; } } .restore-toast-msg { white-space: nowrap; line-height: 1.4; margin-right: 6px; } .restore-toast-restore { - background: #8088ff; + background: var(--accent); color: #fff; border: none; border-radius: 4px; @@ -120,7 +137,7 @@ body { display: flex; flex-direction: column; } height: 22px; min-height: 22px; background: #0e0e14; - border-top: 1px solid rgba(255,255,255,0.06); + border-top: 1px solid var(--hairline); display: flex; align-items: center; padding: 0 10px; @@ -138,7 +155,7 @@ body { display: flex; flex-direction: column; } #status-bar-activity { white-space: nowrap; - color: #8088ff; + color: var(--accent); } #status-bar-activity.status-done { @@ -202,7 +219,7 @@ body { display: flex; flex-direction: column; } width: 340px; min-width: 200px; max-width: 600px; - background: #18181f; + background: var(--surface-chrome); display: flex; flex-direction: column; height: 100vh; @@ -218,13 +235,13 @@ body { display: flex; flex-direction: column; } flex-shrink: 0; position: relative; z-index: 10; - border-right: 1px solid rgba(255,255,255,0.06); + border-right: 1px solid var(--hairline); transition: background 0.15s; } #sidebar-resize-handle:hover, #sidebar-resize-handle.dragging { - background: rgba(120,130,255,0.3); + background: var(--accent-border); } #sidebar-header { @@ -238,7 +255,7 @@ body { display: flex; flex-direction: column; } #sidebar-tabs { display: flex; gap: 0; - border-bottom: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid var(--hairline); align-items: center; } @@ -270,7 +287,7 @@ body { display: flex; flex-direction: column; } .sidebar-tab.active { color: #b0b0c4; - border-bottom-color: #8088ff; + border-bottom-color: var(--accent); } #session-filters { @@ -297,7 +314,7 @@ body { display: flex; flex-direction: column; } #star-toggle, #archive-toggle, #running-toggle, #today-toggle { background: transparent; - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); color: #7a7a90; font-size: 11px; padding: 0; @@ -315,7 +332,7 @@ body { display: flex; flex-direction: column; } } #star-toggle:hover, #archive-toggle:hover, #running-toggle:hover, #today-toggle:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); border-color: rgba(255,255,255,0.12); color: #888; } @@ -329,7 +346,7 @@ body { display: flex; flex-direction: column; } #archive-toggle.active { background: rgba(120,130,255,0.1); border-color: rgba(120,130,255,0.25); - color: #8088ff; + color: var(--accent); } #running-toggle.active { @@ -352,8 +369,8 @@ body { display: flex; flex-direction: column; } #search-input { width: 100%; - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.06); + background: var(--control-surface); + border: 1px solid var(--hairline); border-radius: 8px; padding: 8px 44px 8px 12px; font-size: 13px; @@ -418,8 +435,8 @@ body { display: flex; flex-direction: column; } #indexing-banner-dismiss:hover { color: #c0c0d8; } #search-input:focus { - background: rgba(255,255,255,0.06); - border-color: rgba(120,130,255,0.4); + background: var(--hairline); + border-color: var(--accent-border-strong); box-shadow: 0 0 0 3px rgba(120,130,255,0.08); } @@ -484,7 +501,7 @@ body { display: flex; flex-direction: column; } margin: 6px 8px; border-radius: 10px; background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); overflow: hidden; transition: background 0.15s, border-color 0.15s; } @@ -592,14 +609,14 @@ body { display: flex; flex-direction: column; } .project-archive-btn:hover { background: rgba(120,130,255,0.1); - color: #8088ff; + color: var(--accent); } .project-header .arrow { font-size: 9px; transition: transform 0.2s; display: inline-block; - color: #8088ff; + color: var(--accent); } .project-header.collapsed .arrow { @@ -750,7 +767,7 @@ body { display: flex; flex-direction: column; } } .sessions-more-toggle:hover { - color: #8088ff; + color: var(--accent); background: rgba(120,130,255,0.08); } @@ -769,7 +786,7 @@ body { display: flex; flex-direction: column; } } .subagents-more-toggle:hover { - color: #8088ff; + color: var(--accent); background: rgba(120,130,255,0.08); } @@ -790,7 +807,7 @@ body { display: flex; flex-direction: column; } border-left: none; } .slug-group-header:hover .slug-group-row { - background: rgba(255,255,255,0.06); + background: var(--hairline); } .slug-group:not(.collapsed) .slug-group-row { } @@ -804,7 +821,7 @@ body { display: flex; flex-direction: column; } } .slug-group-expand .arrow { font-size: 8px; - color: #8088ff; + color: var(--accent); transition: transform 0.2s; display: inline-block; } @@ -847,7 +864,7 @@ body { display: flex; flex-direction: column; } .slug-group-count { font-size: 10px; background: rgba(120,130,255,0.18); - color: #8088ff; + color: var(--accent); padding: 1px 6px; border-radius: 8px; font-weight: 600; @@ -876,7 +893,7 @@ body { display: flex; flex-direction: column; } .slug-group-archive-btn:hover { background: rgba(120,130,255,0.1); - color: #8088ff; + color: var(--accent); } .slug-group.collapsed .slug-group-sessions { display: none; } @@ -898,11 +915,11 @@ body { display: flex; flex-direction: column; } display: inline-block; } .slug-group-more:hover { - color: #8088ff; + color: var(--accent); background: rgba(120,130,255,0.08); } .slug-group-sessions { - border-left: 2px solid rgba(120,130,255,0.12); + border-left: 2px solid var(--accent-wash-strong); margin-left: 28px; padding-left: 0; } @@ -925,7 +942,7 @@ body { display: flex; flex-direction: column; } } .session-item:hover .session-row { - background: rgba(255,255,255,0.06); + background: var(--hairline); } .session-item.active .session-row { @@ -1042,7 +1059,7 @@ body { display: flex; flex-direction: column; } .session-icon--agents-busy::before { content: "\283F"; /* ⠿ — all dots lit: agents at work */ - color: #8088ff; + color: var(--accent); font-size: 11px; line-height: 6px; position: absolute; @@ -1053,7 +1070,7 @@ body { display: flex; flex-direction: column; } /* Busy session WITH live subagents: the same spinner, same cadence, violet hue — see .ai/contexts/session-state.md. See docs/subagents.md "Live status". */ .session-item.has-busy-agents .session-icon--busy::before { - color: #8088ff; + color: var(--accent); } /* ---- Session info ---- */ @@ -1079,8 +1096,8 @@ body { display: flex; flex-direction: column; } .session-rename-input { font-size: 13px; color: #d0d0e8; - background: rgba(255,255,255,0.06); - border: 1px solid rgba(120,130,255,0.4); + background: var(--hairline); + border: 1px solid var(--accent-border-strong); border-radius: 5px; padding: 2px 6px; width: 100%; @@ -1133,7 +1150,7 @@ body { display: flex; flex-direction: column; } position: absolute; right: 8px; top: 8px; - background: linear-gradient(90deg, transparent, #18181f 10%); + background: linear-gradient(90deg, transparent, var(--surface-chrome) 10%); padding-left: 8px; padding-right: 4px; z-index: 1; @@ -1205,7 +1222,7 @@ body { display: flex; flex-direction: column; } } .session-archive-btn:hover { - color: #8088ff; + color: var(--accent); background: rgba(120,130,255,0.1); } @@ -1281,7 +1298,7 @@ body { display: flex; flex-direction: column; } flex-direction: column; position: relative; min-height: 0; - background: #111118; + background: var(--surface-sunken); } #sidebar-collapse-btn { @@ -1299,7 +1316,7 @@ body { display: flex; flex-direction: column; } } #sidebar-collapse-btn:hover { color: #888; - background: rgba(255,255,255,0.04); + background: var(--control-surface); } #sidebar-expand-btn { @@ -1318,7 +1335,7 @@ body { display: flex; flex-direction: column; } } #sidebar-expand-btn:hover { color: #888; - background: rgba(255,255,255,0.04); + background: var(--control-surface); } #sidebar.collapsed { @@ -1357,8 +1374,8 @@ body { display: flex; flex-direction: column; } align-items: center; justify-content: space-between; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -1385,13 +1402,13 @@ body { display: flex; flex-direction: column; } #terminal-header-pty-title { font-size: 11px; - color: #9090a8; + color: var(--text-muted); font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-left: 6px; - border-left: 1px solid rgba(255,255,255,0.08); + border-left: 1px solid var(--control-border); } #terminal-header-id { @@ -1405,7 +1422,7 @@ body { display: flex; flex-direction: column; } #terminal-header-shell { font-size: 10px; color: #8a8aa0; - background: rgba(255,255,255,0.06); + background: var(--hairline); padding: 1px 6px; border-radius: 3px; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; @@ -1537,7 +1554,7 @@ body { display: flex; flex-direction: column; } } .terminal-container .xterm-viewport::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.08); + background: var(--control-border); border-radius: 4px; } @@ -1565,7 +1582,7 @@ body { display: flex; flex-direction: column; } box-shadow: 0 2px 8px rgba(0,0,0,0.4); } .terminal-search-input { - background: rgba(255,255,255,0.08); + background: var(--control-border); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; color: #cdd6f4; @@ -1609,8 +1626,8 @@ body { display: flex; flex-direction: column; } align-items: center; justify-content: space-between; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -1629,7 +1646,7 @@ body { display: flex; flex-direction: column; } #grid-group-toggle-btn { background: transparent; - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); color: #7a7a90; font-size: 11px; font-family: inherit; @@ -1642,7 +1659,7 @@ body { display: flex; flex-direction: column; } } #grid-group-toggle-btn:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); border-color: rgba(255,255,255,0.12); color: #888; } @@ -1650,7 +1667,7 @@ body { display: flex; flex-direction: column; } #grid-group-toggle-btn.active { background: rgba(128,136,255,0.1); border-color: rgba(128,136,255,0.3); - color: #8088ff; + color: var(--accent); } .grid-card-header { @@ -1658,8 +1675,8 @@ body { display: flex; flex-direction: column; } align-items: center; gap: 8px; padding: 6px 10px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.04); + background: var(--surface-chrome); + border-bottom: 1px solid var(--control-surface); cursor: default; flex-shrink: 0; } @@ -1754,7 +1771,7 @@ body { display: flex; flex-direction: column; } padding: 8px 10px; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; letter-spacing: 0.3px; - background: rgba(255,255,255,0.06); + background: var(--hairline); border-radius: 6px; margin-top: 20px; } @@ -1767,7 +1784,7 @@ body { display: flex; flex-direction: column; } display: flex; flex-direction: column; height: 450px; - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); border-radius: 8px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; @@ -1797,8 +1814,8 @@ body { display: flex; flex-direction: column; } padding: 6px 10px; font-size: 10px; color: #777790; - background: #18181f; - border-top: 1px solid rgba(255,255,255,0.04); + background: var(--surface-chrome); + border-top: 1px solid var(--control-surface); flex-shrink: 0; } @@ -1935,7 +1952,7 @@ body { display: flex; flex-direction: column; } gap: 4px; padding: 4px 10px; background: rgba(255,255,255,0.02); - border-top: 1px solid rgba(255,255,255,0.04); + border-top: 1px solid var(--control-surface); flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; @@ -1968,7 +1985,7 @@ body { display: flex; flex-direction: column; } /* ========== SCROLLBAR ========== */ #sidebar-content::-webkit-scrollbar { width: 5px; } #sidebar-content::-webkit-scrollbar-track { background: transparent; } -#sidebar-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#sidebar-content::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } #sidebar-content::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.1); } .plans-empty { @@ -1984,8 +2001,8 @@ body { display: flex; flex-direction: column; } align-items: center; justify-content: space-between; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -2042,7 +2059,7 @@ body { display: flex; flex-direction: column; } } .viewer-toolbar-copy-path:hover { - color: #8088ff; + color: var(--accent); background: rgba(120,130,255,0.1); } @@ -2062,7 +2079,7 @@ body { display: flex; flex-direction: column; } inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } #stats-viewer-header { @@ -2070,8 +2087,8 @@ body { display: flex; flex-direction: column; } align-items: center; gap: 10px; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -2089,7 +2106,7 @@ body { display: flex; flex-direction: column; } #stats-viewer-body::-webkit-scrollbar { width: 5px; } #stats-viewer-body::-webkit-scrollbar-track { background: transparent; } -#stats-viewer-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#stats-viewer-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } /* Heatmap */ .heatmap-container { @@ -2152,7 +2169,7 @@ body { display: flex; flex-direction: column; } outline-offset: -1px; } -.heatmap-level-0 { background: rgba(255,255,255,0.04); } +.heatmap-level-0 { background: var(--control-surface); } .heatmap-level-1 { background: rgba(128,136,255,0.25); } .heatmap-level-2 { background: rgba(128,136,255,0.45); } .heatmap-level-3 { background: rgba(128,136,255,0.65); } @@ -2281,7 +2298,7 @@ body { display: flex; flex-direction: column; } margin-top: 24px; padding: 10px 14px; background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); border-radius: 8px; display: flex; align-items: center; @@ -2289,7 +2306,7 @@ body { display: flex; flex-direction: column; } .stats-notice code { background: rgba(128,136,255,0.15); - color: #8088ff; + color: var(--accent); padding: 1px 5px; border-radius: 4px; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; @@ -2309,7 +2326,7 @@ body { display: flex; flex-direction: column; } width: 16px; height: 16px; border: 2px solid rgba(128,136,255,0.3); - border-top-color: #8088ff; + border-top-color: var(--accent); border-radius: 50%; animation: stats-spin 0.8s linear infinite; } @@ -2335,7 +2352,7 @@ body { display: flex; flex-direction: column; } } .usage-card { background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); border-radius: 10px; padding: 14px 16px; } @@ -2358,7 +2375,7 @@ body { display: flex; flex-direction: column; } } .usage-track { height: 6px; - background: rgba(255,255,255,0.06); + background: var(--hairline); border-radius: 3px; overflow: hidden; } @@ -2431,7 +2448,7 @@ body { display: flex; flex-direction: column; } .stat-card { background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); border-radius: 10px; padding: 16px 18px; display: flex; @@ -2460,7 +2477,7 @@ body { display: flex; flex-direction: column; } inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } /* Memory CodeMirror editor */ @@ -2476,7 +2493,7 @@ body { display: flex; flex-direction: column; } font-size: 14px; line-height: 1.7; scrollbar-width: thin; - scrollbar-color: rgba(255,255,255,0.08) transparent; + scrollbar-color: var(--control-border) transparent; } .markdown-preview h1, .markdown-preview h2, .markdown-preview h3, @@ -2485,8 +2502,8 @@ body { display: flex; flex-direction: column; } margin: 1.2em 0 0.5em; font-weight: 600; } -.markdown-preview h1 { font-size: 1.6em; border-bottom: 1px solid rgba(255,255,255,0.06); padding-bottom: 0.3em; } -.markdown-preview h2 { font-size: 1.35em; border-bottom: 1px solid rgba(255,255,255,0.04); padding-bottom: 0.25em; } +.markdown-preview h1 { font-size: 1.6em; border-bottom: 1px solid var(--hairline); padding-bottom: 0.3em; } +.markdown-preview h2 { font-size: 1.35em; border-bottom: 1px solid var(--control-surface); padding-bottom: 0.25em; } .markdown-preview h3 { font-size: 1.15em; } .markdown-preview p { margin: 0.6em 0; } @@ -2504,7 +2521,7 @@ body { display: flex; flex-direction: column; } .markdown-preview pre { background: #282a36; - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); border-radius: 6px; padding: 14px 18px; overflow-x: auto; @@ -2526,13 +2543,13 @@ body { display: flex; flex-direction: column; } .markdown-preview table { border-collapse: collapse; width: 100%; margin: 1em 0; } .markdown-preview th, .markdown-preview td { - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); padding: 6px 12px; text-align: left; } -.markdown-preview th { background: rgba(255,255,255,0.04); color: #bd93f9; } +.markdown-preview th { background: var(--control-surface); color: #bd93f9; } -.markdown-preview hr { border: none; border-top: 1px solid rgba(255,255,255,0.08); margin: 1.5em 0; } +.markdown-preview hr { border: none; border-top: 1px solid var(--control-border); margin: 1.5em 0; } .markdown-preview img { max-width: 100%; border-radius: 6px; } @@ -2544,7 +2561,7 @@ body { display: flex; flex-direction: column; } inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } #jsonl-viewer-header { @@ -2552,8 +2569,8 @@ body { display: flex; flex-direction: column; } align-items: center; gap: 10px; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -2581,7 +2598,7 @@ body { display: flex; flex-direction: column; } #jsonl-viewer-body::-webkit-scrollbar { width: 5px; } #jsonl-viewer-body::-webkit-scrollbar-track { background: transparent; } -#jsonl-viewer-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#jsonl-viewer-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } .jsonl-entry { margin-bottom: 2px; @@ -2591,8 +2608,8 @@ body { display: flex; flex-direction: column; } } .jsonl-user { - background: rgba(255,255,255,0.08); - border-top: 1px solid rgba(255,255,255,0.06); + background: var(--control-border); + border-top: 1px solid var(--hairline); margin-top: 8px; padding-top: 10px; } @@ -2628,15 +2645,15 @@ body { display: flex; flex-direction: column; } } .jsonl-text h1:first-child, .jsonl-text h2:first-child, .jsonl-text h3:first-child { margin-top: 0; } .jsonl-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 12px; } -.jsonl-text th, .jsonl-text td { border: 1px solid rgba(255,255,255,0.08); padding: 4px 8px; text-align: left; } -.jsonl-text th { background: rgba(255,255,255,0.04); color: #b0b0d0; font-weight: 600; } +.jsonl-text th, .jsonl-text td { border: 1px solid var(--control-border); padding: 4px 8px; text-align: left; } +.jsonl-text th { background: var(--control-surface); color: #b0b0d0; font-weight: 600; } .jsonl-text td { color: #a0a0b8; } .jsonl-text ul, .jsonl-text ol { margin: 0.3em 0; padding-left: 1.5em; } .jsonl-text li { margin: 0.1em 0; } -.jsonl-text hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 0.5em 0; } +.jsonl-text hr { border: none; border-top: 1px solid var(--hairline); margin: 0.5em 0; } .jsonl-text pre { background: rgba(0,0,0,0.3); border-radius: 4px; padding: 8px 10px; overflow-x: auto; font-size: 12px; line-height: 1.4; } .jsonl-text code { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 12px; } -.jsonl-text :not(pre) > code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 4px; color: #c8b0e0; } +.jsonl-text :not(pre) > code { background: var(--hairline); padding: 1px 5px; border-radius: 4px; color: #c8b0e0; } .jsonl-text a { color: #6cb6ff; } .jsonl-text a:hover { color: #8ccbff; } @@ -2653,7 +2670,7 @@ body { display: flex; flex-direction: column; } } .jsonl-inline-code { - background: rgba(255,255,255,0.06); + background: var(--hairline); padding: 1px 5px; border-radius: 4px; font-family: 'SF Mono', 'Fira Code', Menlo, monospace; @@ -2673,7 +2690,7 @@ body { display: flex; flex-direction: column; } } .jsonl-tool-result { - border: 1px solid rgba(120,130,255,0.12); + border: 1px solid var(--accent-wash-strong); } /* Tool block rendering (bullet + indented content) */ @@ -2712,7 +2729,7 @@ body { display: flex; flex-direction: column; } font-family: 'SF Mono', 'Fira Code', Menlo, monospace; font-size: 11px; color: #909098; - background: rgba(255,255,255,0.04); + background: var(--control-surface); padding: 1px 5px; border-radius: 3px; } @@ -2772,7 +2789,7 @@ body { display: flex; flex-direction: column; } max-height: 400px; border-radius: 4px; margin-top: 6px; - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); cursor: pointer; } @@ -2805,7 +2822,7 @@ body { display: flex; flex-direction: column; } .jsonl-tool-content::-webkit-scrollbar-track { background: transparent; } .jsonl-tool-cmd-block::-webkit-scrollbar-thumb, .jsonl-tool-diff::-webkit-scrollbar-thumb, -.jsonl-tool-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 2px; } +.jsonl-tool-content::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 2px; } .jsonl-agent-expandable { cursor: pointer; @@ -2897,12 +2914,12 @@ body { display: flex; flex-direction: column; } } .jsonl-tool-result .jsonl-toggle { - background: rgba(120,130,255,0.06); - color: #8088ff; + background: var(--accent-wash); + color: var(--accent); } .jsonl-tool-result .jsonl-toggle:hover { - background: rgba(120,130,255,0.12); + background: var(--accent-wash-strong); } .jsonl-tool-body { @@ -2921,14 +2938,14 @@ body { display: flex; flex-direction: column; } .jsonl-tool-body::-webkit-scrollbar { width: 4px; } .jsonl-tool-body::-webkit-scrollbar-track { background: transparent; } -.jsonl-tool-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 2px; } +.jsonl-tool-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 2px; } /* Thinking blocks */ .jsonl-thinking { margin: 4px 0; border-radius: 4px; overflow: hidden; - border: 1px solid rgba(255,255,255,0.06); + border: 1px solid var(--hairline); } .jsonl-thinking .jsonl-toggle { @@ -2937,7 +2954,7 @@ body { display: flex; flex-direction: column; } } .jsonl-thinking .jsonl-toggle:hover { - background: rgba(255,255,255,0.06); + background: var(--hairline); } .jsonl-thinking .jsonl-tool-body { @@ -2948,7 +2965,7 @@ body { display: flex; flex-direction: column; } /* Meta entries (system, custom-title, progress) */ .jsonl-meta-entry { background: rgba(255,255,255,0.02); - border-left-color: rgba(255,255,255,0.08); + border-left-color: var(--control-border); padding: 4px 14px; font-size: 11px; color: #606078; @@ -2978,7 +2995,7 @@ body { display: flex; flex-direction: column; } width: 20px; height: 20px; border-radius: 4px; - background: rgba(255,255,255,0.04); + background: var(--control-surface); font-size: 10px; color: #7a7a90; flex-shrink: 0; @@ -3022,7 +3039,7 @@ body { display: flex; flex-direction: column; } #memory-content::-webkit-scrollbar { width: 5px; } #memory-content::-webkit-scrollbar-track { background: transparent; } -#memory-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#memory-content::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } /* Stats sidebar content */ #stats-content { @@ -3124,7 +3141,7 @@ body { display: flex; flex-direction: column; } .memory-source-badge.source-claude-home { background: rgba(128,136,255,0.15); - color: #8088ff; + color: var(--accent); } .memory-source-badge.source-project { @@ -3150,14 +3167,14 @@ body { display: flex; flex-direction: column; } #work-files-content::-webkit-scrollbar { width: 5px; } #work-files-content::-webkit-scrollbar-track { background: transparent; } -#work-files-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#work-files-content::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } #work-files-viewer { position: absolute; inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } .work-file-icon { @@ -3181,7 +3198,7 @@ body { display: flex; flex-direction: column; } #resort-btn, #add-project-btn { background: transparent; - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); color: #7a7a90; font-size: 11px; padding: 0; @@ -3205,7 +3222,7 @@ body { display: flex; flex-direction: column; } #grid-toggle-btn:hover, #resort-btn:hover, #add-project-btn:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); border-color: rgba(255,255,255,0.12); color: #888; } @@ -3213,7 +3230,7 @@ body { display: flex; flex-direction: column; } #grid-toggle-btn.active { background: rgba(128,136,255,0.1); border-color: rgba(128,136,255,0.3); - color: #8088ff; + color: var(--accent); } /* ========== ADD PROJECT DIALOG ========== */ @@ -3270,7 +3287,7 @@ body { display: flex; flex-direction: column; } } .add-project-dialog .folder-input-row input:focus { - border-color: rgba(120,130,255,0.4); + border-color: var(--accent-border-strong); } .add-project-dialog .folder-input-row input::placeholder { @@ -3278,7 +3295,7 @@ body { display: flex; flex-direction: column; } } .add-project-browse-btn { - background: rgba(255,255,255,0.06); + background: var(--hairline); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; padding: 8px 12px; @@ -3321,8 +3338,8 @@ body { display: flex; flex-direction: column; } .add-project-add-btn { background: rgba(120,130,255,0.15); - color: #8088ff; - border-color: rgba(120,130,255,0.3) !important; + color: var(--accent); + border-color: var(--accent-border) !important; } .add-project-add-btn:hover { @@ -3354,7 +3371,7 @@ body { display: flex; flex-direction: column; } } #global-settings-btn:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); border-color: rgba(255,255,255,0.12); color: #888; } @@ -3362,7 +3379,7 @@ body { display: flex; flex-direction: column; } #global-settings-btn.active { background: rgba(120,130,255,0.1); border-color: rgba(120,130,255,0.25); - color: #8088ff; + color: var(--accent); } /* Project header schedule button */ @@ -3425,7 +3442,7 @@ body { display: flex; flex-direction: column; } } .project-settings-btn:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); border-color: rgba(255,255,255,0.12); color: #888; } @@ -3436,7 +3453,7 @@ body { display: flex; flex-direction: column; } inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } #settings-viewer-header { @@ -3444,8 +3461,8 @@ body { display: flex; flex-direction: column; } align-items: center; gap: 10px; padding: 8px 16px; - background: #18181f; - border-bottom: 1px solid rgba(255,255,255,0.06); + background: var(--surface-chrome); + border-bottom: 1px solid var(--hairline); flex-shrink: 0; } @@ -3465,7 +3482,7 @@ body { display: flex; flex-direction: column; } #settings-viewer-body::-webkit-scrollbar { width: 5px; } #settings-viewer-body::-webkit-scrollbar-track { background: transparent; } -#settings-viewer-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; } +#settings-viewer-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; } .settings-form { width: 100%; @@ -3552,7 +3569,7 @@ body { display: flex; flex-direction: column; } font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #c8c8e0; - background: rgba(255,255,255,0.06); + background: var(--hairline); border: 1px solid rgba(255,255,255,0.14); border-radius: 6px; cursor: pointer; @@ -3561,13 +3578,13 @@ body { display: flex; flex-direction: column; } } .settings-shortcut-btn:hover { - border-color: #8088ff; + border-color: var(--accent); background: rgba(128,136,255,0.12); } .settings-shortcut-btn.capturing { - color: #8088ff; - border-color: #8088ff; + color: var(--accent); + border-color: var(--accent); border-style: dashed; background: rgba(128,136,255,0.10); } @@ -3593,7 +3610,7 @@ body { display: flex; flex-direction: column; } } .settings-use-global input { - accent-color: #8088ff; + accent-color: var(--accent); } /* --- Toggle switch --- */ @@ -3649,8 +3666,8 @@ body { display: flex; flex-direction: column; } /* --- Inputs & selects (compact, right-aligned) --- */ .settings-input { width: 100%; - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.08); + background: var(--control-surface); + border: 1px solid var(--control-border); border-radius: 8px; padding: 7px 12px; font-size: 13px; @@ -3672,8 +3689,8 @@ body { display: flex; flex-direction: column; } } .settings-input:focus { - background: rgba(255,255,255,0.06); - border-color: rgba(120,130,255,0.4); + background: var(--hairline); + border-color: var(--accent-border-strong); box-shadow: 0 0 0 3px rgba(120,130,255,0.08); } @@ -3683,8 +3700,8 @@ body { display: flex; flex-direction: column; } } .settings-select { - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.08); + background: var(--control-surface); + border: 1px solid var(--control-border); border-radius: 8px; padding: 7px 32px 7px 12px; font-size: 13px; @@ -3739,7 +3756,7 @@ body { display: flex; flex-direction: column; } .settings-cancel-btn { background: transparent; border: 1px solid rgba(255,255,255,0.12); - color: #9090a8; + color: var(--text-muted); font-size: 13px; padding: 8px 24px; border-radius: 8px; @@ -3750,15 +3767,15 @@ body { display: flex; flex-direction: column; } } .settings-cancel-btn:hover { - background: rgba(255,255,255,0.06); + background: var(--hairline); border-color: rgba(255,255,255,0.2); color: #c0c0d0; } .settings-save-btn { - background: rgba(120,130,255,0.12); - border: 1px solid rgba(120,130,255,0.3); - color: #8088ff; + background: var(--accent-wash-strong); + border: 1px solid var(--accent-border); + color: var(--accent); font-size: 13px; padding: 8px 24px; border-radius: 8px; @@ -3818,7 +3835,7 @@ body { display: flex; flex-direction: column; } font-size: 12.5px; } .settings-check-updates-btn { - background: rgba(255,255,255,0.06); + background: var(--hairline); border: 1px solid rgba(255,255,255,0.1); color: #b0b0c4; font-size: 12px; @@ -3896,8 +3913,8 @@ body { display: flex; flex-direction: column; } } .permission-option { - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.08); + background: var(--control-surface); + border: 1px solid var(--control-border); border-radius: 6px; padding: 10px 12px; font-size: 12px; @@ -3924,15 +3941,15 @@ body { display: flex; flex-direction: column; } } .permission-option:hover { - background: rgba(255,255,255,0.06); + background: var(--hairline); border-color: rgba(255,255,255,0.15); color: #b0b0c4; } .permission-option.selected { - background: rgba(120,130,255,0.12); - border-color: rgba(120,130,255,0.4); - color: #8088ff; + background: var(--accent-wash-strong); + border-color: var(--accent-border-strong); + color: var(--accent); } .permission-option.selected .perm-desc { @@ -3958,8 +3975,8 @@ body { display: flex; flex-direction: column; } .new-session-cancel-btn { background: transparent; - border: 1px solid rgba(255,255,255,0.08); - color: #9090a8; + border: 1px solid var(--control-border); + color: var(--text-muted); font-size: 13px; padding: 8px 20px; border-radius: 6px; @@ -3970,7 +3987,7 @@ body { display: flex; flex-direction: column; } } .new-session-cancel-btn:hover { - background: rgba(255,255,255,0.04); + background: var(--control-surface); color: #8888a0; } @@ -4017,7 +4034,7 @@ body { display: flex; flex-direction: column; } border-radius: 8px; padding: 10px 14px; font-size: 12px; - color: #9090a8; + color: var(--text-muted); min-height: 36px; margin-bottom: 4px; } @@ -4087,7 +4104,7 @@ body { display: flex; flex-direction: column; } .popover-separator { height: 1px; - background: rgba(255,255,255,0.08); + background: var(--control-border); margin: 4px 6px; } @@ -4110,13 +4127,13 @@ body { display: flex; flex-direction: column; } } .popover-option:hover { - background: rgba(255,255,255,0.06); + background: var(--hairline); color: #d0d0e8; } .popover-option-icon { font-size: 11px; - color: #8088ff; + color: var(--accent); width: 18px; height: 18px; text-align: center; @@ -4219,7 +4236,7 @@ body { display: flex; flex-direction: column; } .remote-host-refresh-btn:hover { opacity: 1; - background: rgba(255,255,255,0.04); + background: var(--control-surface); color: #b0b0c4; } @@ -4267,13 +4284,13 @@ body { display: flex; flex-direction: column; } z-index: 10; display: none; align-self: stretch; - border-left: 1px solid rgba(255,255,255,0.06); + border-left: 1px solid var(--hairline); transition: background 0.15s; } #file-panel-resize-handle:hover, #file-panel-resize-handle.dragging { - background: rgba(120,130,255,0.3); + background: var(--accent-border); } #file-panel { @@ -4282,20 +4299,20 @@ body { display: flex; flex-direction: column; } overflow: hidden; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-chrome); position: relative; } #file-panel.open { min-width: 280px; - border-left: 1px solid rgba(255,255,255,0.06); + border-left: 1px solid var(--hairline); } .fp-toolbar-btn { background: transparent; - border: 1px solid rgba(255,255,255,0.08); - color: #9090a8; + border: 1px solid var(--control-border); + color: var(--text-muted); font-size: 11px; padding: 3px 10px; border-radius: 5px; @@ -4306,15 +4323,15 @@ body { display: flex; flex-direction: column; } } .fp-toolbar-btn:hover { - border-color: rgba(120,130,255,0.3); - color: #8088ff; - background: rgba(120,130,255,0.06); + border-color: var(--accent-border); + color: var(--accent); + background: var(--accent-wash); } .fp-toolbar-btn.active { - border-color: rgba(120,130,255,0.4); - color: #8088ff; - background: rgba(120,130,255,0.12); + border-color: var(--accent-border-strong); + color: var(--accent); + background: var(--accent-wash-strong); } .fp-icon-btn { @@ -4370,7 +4387,7 @@ body { display: flex; flex-direction: column; } margin-top: auto; cursor: row-resize; background: transparent; - border-top: 1px solid rgba(255,255,255,0.06); + border-top: 1px solid var(--hairline); transition: background 0.15s; } @@ -4380,7 +4397,7 @@ body { display: flex; flex-direction: column; } #panel-terminal-handle:hover, #panel-terminal-handle.dragging { - background: rgba(120,130,255,0.3); + background: var(--accent-border); } #panel-terminal-region { @@ -4402,7 +4419,7 @@ body { display: flex; flex-direction: column; } #panel-terminal-message { padding: 10px 12px; font-size: 12px; - color: #9090a8; + color: var(--text-muted); } #panel-terminal-toggle-btn.active { @@ -4461,7 +4478,7 @@ body { display: flex; flex-direction: column; } padding: 6px 12px; background: rgba(24, 24, 31, 0.85); backdrop-filter: blur(6px); - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); border-radius: 8px; position: absolute; bottom: 12px; @@ -4485,7 +4502,7 @@ body { display: flex; flex-direction: column; } font-weight: 500; cursor: pointer; font-family: inherit; - border: 1px solid rgba(255,255,255,0.08); + border: 1px solid var(--control-border); transition: all 0.15s; } @@ -4517,7 +4534,7 @@ body { display: flex; flex-direction: column; } } .mcp-toggle.enabled { - color: #8088ff; + color: var(--accent); } .mcp-toggle.enabled::before { @@ -4526,7 +4543,7 @@ body { display: flex; flex-direction: column; } width: 6px; height: 6px; border-radius: 50%; - background: #8088ff; + background: var(--accent); margin-right: 5px; vertical-align: middle; } @@ -4543,7 +4560,7 @@ body { display: flex; flex-direction: column; } padding: 8px 12px; font-size: 12px; color: #c0c0d0; - border-bottom: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid var(--hairline); } #changes-list { @@ -4563,7 +4580,7 @@ body { display: flex; flex-direction: column; } } .changes-file-row:hover { - background: rgba(120,130,255,0.06); + background: var(--accent-wash); } .changes-file-state { @@ -4572,15 +4589,15 @@ body { display: flex; flex-direction: column; } text-align: center; font-weight: 700; font-family: monospace; - color: #9090a8; + color: var(--text-muted); } .changes-state-a { color: #3ecf5a; } .changes-state-m { color: #e0a030; } .changes-state-d { color: #e05070; } .changes-state-r, -.changes-state-c { color: #8088ff; } -.changes-state-\? { color: #9090a8; } +.changes-state-c { color: var(--accent); } +.changes-state-\? { color: var(--text-muted); } .changes-file-path { flex: 1; @@ -4617,7 +4634,7 @@ body { display: flex; flex-direction: column; } .changes-more-note { padding: 6px 12px; font-size: 11px; - color: #9090a8; + color: var(--text-muted); } /* The list keeps its explicit height while the editor is open; it is the @@ -4632,12 +4649,12 @@ body { display: flex; flex-direction: column; } flex-shrink: 0; cursor: row-resize; background: transparent; - border-top: 1px solid rgba(255,255,255,0.06); + border-top: 1px solid var(--hairline); } #changes-list-splitter:hover, #changes-list-splitter.dragging { - background: rgba(120,130,255,0.3); + background: var(--accent-border); } .changes-file-row.selected { @@ -4681,25 +4698,25 @@ body { display: flex; flex-direction: column; } } .changes-diff-hunk { - color: #8088ff; + color: var(--accent); } .changes-diff-file-header { - color: #9090a8; + color: var(--text-muted); } .changes-diff-truncated { padding: 6px 12px; font-size: 11px; - color: #9090a8; - border-top: 1px solid rgba(255,255,255,0.06); + color: var(--text-muted); + border-top: 1px solid var(--hairline); } #changes-diff-notice { padding: 6px 12px; font-size: 11px; - color: #9090a8; - border-bottom: 1px solid rgba(255,255,255,0.06); + color: var(--text-muted); + border-bottom: 1px solid var(--hairline); } #changes-diff-notice.changes-error { @@ -4721,7 +4738,7 @@ body { display: flex; flex-direction: column; } inset: 0; display: flex; flex-direction: column; - background: #111118; + background: var(--surface-sunken); } .activity-trace-list { diff --git a/test/sidebar-busy-agents-tint.test.js b/test/sidebar-busy-agents-tint.test.js index b9388227..722c49bd 100644 --- a/test/sidebar-busy-agents-tint.test.js +++ b/test/sidebar-busy-agents-tint.test.js @@ -10,7 +10,9 @@ const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'style.css'), 'utf8'); const BLUE = '#4fc3f7'; -const VIOLET = '#8088ff'; +// The violet is the shared accent token; either spelling is the same colour, +// and what these rules must not do is pick a different one. +const VIOLET = '(?:#8088ff|var\\(--accent\\))'; function projectWithLiveSubagent() { return makeSampleProject({ From 6b37990de5723108051bf5aaa7656b637fa08af3 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 13:24:16 +0200 Subject: [PATCH 21/29] docs(changes): say when untracked counts reset, and which view opens first The page described a save as a refresh that resets an untracked row's counts, which is what made the disappearing diff read as by design. It now names the exception and keeps the rule for the Refresh button and the idle refresh. It also records inline as the panel's default and why, and that Save is inactive until there is something to save. --- .ai/contexts/changes-view.md | 6 +++++- docs/changes-view.md | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 7e45df5f..d708db78 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -550,7 +550,11 @@ A refresh — from a busy→idle edge, the Refresh button, or the watcher — al A file that stops being readable (deleted, or refused) and a status refresh that fails are both reported in that same notice line while a file is open; the file list's own error branch is not reachable from the diff view. -Saving is `gitChangesSave(sessionId, path, content, version)` from the Save button or from the `cm-save` event the bundle dispatches for `Cmd/Ctrl+S`; on success it refreshes the status so the row's counts follow the write. A save is refused while another is in flight (`tab.saving`, which also disables the button), so a double press cannot put two writes in the air with the filesystem deciding the order. A refusal for `reason: 'stale'` keeps the buffer and turns the notice into "reload before saving"; **Reload** re-reads the file, after a confirm when there are unsaved edits. +The Save button is disabled while the buffer is clean and while a write is in flight. It follows the buffer rather than the render: the three editor factories take an `onChange` and install a CodeMirror `updateListener`, so typing, pasting, undo and a programmatic edit all reach it, which a DOM `input` listener would not. The keyboard path does not consult the `disabled` attribute, so `handleChangesSave` keeps the same two guards itself. + +The default mode is **inline**. The panel is a narrow column — at its 450px default a side-by-side merge view gives each side about 225px, which clips code mid-token; inline gives the full width to one column. The MCP diff tab keeps `side-by-side` under its own key, because it is not confined to this panel. + +Saving is `gitChangesSave(sessionId, path, content, version)` from the Save button or from the `cm-save` event the bundle dispatches for `Cmd/Ctrl+S`; on success it refreshes the status so the row's counts follow the write. A save does not change what the diff is against: the original side stays the index (or `HEAD`), because the write touched the working tree and neither of those. The status refresh that follows drops an untracked row's counts — they are click-derived, and a fresh status has none — so the save re-applies them from the bytes it just wrote, against the status payload current at that moment. Otherwise the user watches a number they obtained by opening the file disappear as a result of their own save, while git still reports the file as changed. A save is refused while another is in flight (`tab.saving`, which also disables the button), so a double press cannot put two writes in the air with the filesystem deciding the order. A refusal for `reason: 'stale'` keeps the buffer and turns the notice into "reload before saving"; **Reload** re-reads the file, after a confirm when there are unsaved edits. The buffer is read back from `view.b.state.doc` for side-by-side and from `view.state.doc` for inline and plain — the same asymmetry the MCP diff tab navigates. diff --git a/docs/changes-view.md b/docs/changes-view.md index 2cb0a6d7..f51d1f02 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -37,15 +37,17 @@ row once: its diff is fetched, the row gets its `+added −0`, and the header total grows by the same amount. This is deliberate — counting every new file up front would mean running one extra git command per untracked file on every refresh (and one ssh round-trip each, for a remote session), which a repo with a -large untracked tree would feel. Refreshing resets them, since the files may -have changed since. +large untracked tree would feel. A refresh resets them, since the file may have +changed since — with one exception: **saving the file you are editing keeps its +counts**, because the save is itself the measurement. The Refresh button, and a +refresh triggered by the session finishing a turn, reset them as before. ## Editing a file On a local session, the open file is a live editor, not a picture of a diff. Type on the right-hand side and the diff recomputes as you go. -- **Save** with the Save button or `Ctrl/Cmd+S`. The file list refreshes on save, so the row's counts follow what you wrote. -- The button next to **Close** cycles three views: **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right), **Inline** (one column, changes marked in place) and **Plain** (just the file, no diff decoration). The choice is remembered. +- **Save** with the Save button or `Ctrl/Cmd+S`. The button is inactive until you change something. The file list refreshes on save, so the row's counts follow what you wrote. +- The button next to **Close** cycles three views: **Inline** (one column, changes marked in place — the default, because the panel is a narrow column and side-by-side halves it), **Plain** (just the file, no diff decoration) and **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right). The choice is remembered. - The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. - These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, and a file over 2 MB. From 9742996ee2db94f88cc8d0b7f38231745408c479 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 13:53:55 +0200 Subject: [PATCH 22/29] test(changes): pin the three guards that were holding without being asked to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git-changes-locate was the one Changes handler whose local-only guard had no source assertion; all four are asserted together now, so a fifth handler added without one is visible. Plain mode's change listener was wired but unpinned, where inline and side-by-side were — the point of the listener is that the three modes behave alike, so the third is worth the same test. And the rev-operand guard is now pinned on the one input only it can refuse: a file literally named `1:f.txt`, which containment has no objection to and which `:` would read as git's conflict-stage syntax. --- test/dom-file-panel-changes.test.js | 22 ++++++++++++++ test/git-changes-file-real-git.test.js | 20 +++++++++++++ test/git-changes-file.test.js | 41 ++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index e9bf2062..5823c2f6 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -1887,3 +1887,25 @@ test('the Refresh control is the icon this app already uses, not a word (mutatio assert.match(btn.title, /refresh/i, 'and it says what it does on hover'); } finally { ctx.destroy(); } }); + +test('Save follows the buffer in every mode, plain included (mutation target: the onChange wiring per mode)', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + const saveBtn = ctx.document.getElementById('changes-diff-save-btn'); + const modeBtn = ctx.document.getElementById('changes-diff-mode-btn'); + + // inline (the default), then plain, then side-by-side. + for (const expected of ['inline', 'plain', 'side-by-side']) { + const editor = ctx.editors[ctx.editors.length - 1]; + assert.equal(editor.box.mode, expected); + assert.equal(saveBtn.disabled, true, `${expected}: nothing typed yet`); + + editor.box.text = 'typed in ' + expected + '\n'; + assert.equal(saveBtn.disabled, false, `${expected}: typing must reach the button`); + + modeBtn.click(); + await flush(); + } + } finally { ctx.destroy(); } +}); diff --git a/test/git-changes-file-real-git.test.js b/test/git-changes-file-real-git.test.js index 4b9920d4..78da238e 100644 --- a/test/git-changes-file-real-git.test.js +++ b/test/git-changes-file-real-git.test.js @@ -1033,3 +1033,23 @@ test('real git: a git directory that is not called .git is not reachable through assert.equal(result.reason, 'git-dir', 'no segment rule can see this one; containment can'); } finally { cleanup(tmp); } }); + +test('real git: a file whose name is git\'s conflict-stage syntax is refused by the operand guard, not by containment', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + // A perfectly ordinary filename that `:` would read as `::`. + const staged = path.join(repoDir, '1:f.txt'); + fs.writeFileSync(staged, 'ordinary\n'); + + // Containment has no objection: the file is inside the repository. + const contained = resolveTargetInsideRepo(repoDir, '1:f.txt', {}); + assert.equal(contained.ok, true, 'nothing about its location is wrong'); + + const result = await read(repoDir, '1:f.txt', false); + assert.equal(result.ok, false, 'only the operand guard can refuse this one'); + assert.equal(result.reason, 'invalid-path'); + assert.equal(fs.readFileSync(staged, 'utf8'), 'ordinary\n'); + } finally { cleanup(tmp); } +}); diff --git a/test/git-changes-file.test.js b/test/git-changes-file.test.js index 6cc372d1..db316af1 100644 --- a/test/git-changes-file.test.js +++ b/test/git-changes-file.test.js @@ -7,6 +7,24 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +// main.js cannot be required from a test, so the wiring that keeps a remote +// session out of a local-only handler is asserted against its source, with +// commented-out lines dropped first — the same instrument, and the same +// limitation, as test/git-changes-watch.test.js. +function mainSource() { + return fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8').replace(/^[ \t]*\/\/.*$/gm, ''); +} + +function handlerBody(source, channel) { + const start = source.indexOf(`ipcMain.handle('${channel}'`); + assert.notEqual(start, -1, `${channel} must be handled`); + return source.slice(start, source.indexOf('\n});', start)); +} const { isSafeRepoRelativePath, @@ -98,3 +116,26 @@ test('buildBlobRev names the index for the unstaged view and HEAD for the staged assert.equal(buildBlobRev('src/a.js', false), ':src/a.js'); assert.equal(buildBlobRev('src/a.js', true), 'HEAD:src/a.js'); }); + +// --- The local-only handlers, all four of them --------------------------- + +test('every Changes handler that touches the filesystem refuses a remote session (mutation target: the wiring)', () => { + const main = mainSource(); + for (const channel of ['git-changes-file', 'git-changes-save', 'git-changes-watch', 'git-changes-locate']) { + const body = handlerBody(main, channel); + assert.match(body, /requireLocalTarget\(resolveGitChangesTarget\(sessionId\)\)/, + `${channel} must resolve the session through the local-only guard`); + assert.match(body, /if \(!target\.ok\) return target;/, + `${channel} must hand back the guard's own refusal`); + } +}); + +test('git-changes-locate is the one handler that takes an absolute path, and it maps it main-side', () => { + const main = mainSource(); + const body = handlerBody(main, 'git-changes-locate'); + assert.match(body, /locateChangesFile\(\{ cwd: target\.cwd, absolutePath: filePath \}\)/, + 'the mapping runs against the session\'s own resolved cwd'); + + const preload = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); + assert.match(preload, /gitChangesLocate: \(sessionId, filePath\) => ipcRenderer\.invoke\('git-changes-locate', sessionId, filePath\)/); +}); From 68277b1690aabb55c4e2fe2dc66553f3d88a7f9a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 13:53:55 +0200 Subject: [PATCH 23/29] docs(changes): state what a link to a symlink resolves to, and bound the status measurement Opening the row `innerlink` is refused; a link to it opens the row of the file it points at. Both are deliberate and they are not the same operation, so the doc says which is which rather than leaving the next reader to file the difference as a bug. The scoped-status figure is a measurement on one repository shape, not a property of scoping: with untracked files present a scoped run can be slower than the unscoped one, worst case around 60 ms. The conclusion is unchanged. --- .ai/contexts/changes-view.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index d708db78..f315bb0a 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -530,7 +530,9 @@ Switching rows is an exit like Back, the tab toggle and the panel's close button `openFileInPanel` is the terminal's entry point (OSC 8 `file://` and the context menu) and it hands the renderer an **absolute** path. The renderer never turns that into a pathspec: `git-changes-locate` does, main-side, against the repo root `resolveRepoDirs` computes from the session's own cwd — the same root the read and the write use. It resolves the path on disk, requires containment, runs it through `resolveTargetInsideRepo` so a link cannot reach what a row cannot, and answers with `{relPath, changed, staged, untracked}`. -"Changed" is one `git status --porcelain=v2 -uall -z -- `, scoped to that one path: **measured on a 20 000-file repository, 14–17 ms against 117–126 ms for the unscoped status the panel runs on open** — cheap enough per click that the renderer does not need to cache or consult its last status, and correct even when no Changes tab is open. An untracked file counts as changed (it is a legitimate row, and the editor handles an empty original). An unmodified file, a path outside the repository and a remote session all answer "not a row" and the link falls back to the plain `ViewerPanel`, which is also what happens if the IPC is missing or throws. +A symlink is where locate and the row guard deliberately differ. Opening the **row** `innerlink` is refused (`reason: 'symlink'`): a symlink's content in a working tree is its target string, which the editor cannot represent, and a save would land on a file the row does not name. A **link** to `innerlink` is not that case — it resolves to `src/a.js` and locate answers with *that* row. Nothing is smuggled in: the answer is the target's own repo-relative path, so the title, the save, the watch and the version token all name the same file, and every file reachable this way is already reachable by clicking its own row. The asymmetry is between "edit the link" (refused) and "follow the link to a file" (an ordinary row), not between two spellings of the same operation. + +"Changed" is one `git status --porcelain=v2 -uall -z -- `, scoped to that one path: **measured on a 20 000-file repository with no untracked files, 14–17 ms against 117–126 ms for the unscoped status the panel runs on open**. Scoping is not universally cheaper: with untracked files present it can be slower than the unscoped run, and the worst case measured is around 60 ms. Either way it is cheap enough per click that the renderer does not need to cache or consult its last status, and correct even when no Changes tab is open. An untracked file counts as changed (it is a legitimate row, and the editor handles an empty original). An unmodified file, a path outside the repository and a remote session all answer "not a row" and the link falls back to the plain `ViewerPanel`, which is also what happens if the IPC is missing or throws. ### The render path is not a teardown From 874d644016d5fe463a6e19299ce90f37ee81e825 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 14:15:29 +0200 Subject: [PATCH 24/29] fix(changes): refuse a hard link, and a string the write cannot represent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard link is the one escape resolving on disk cannot see: a second name for the same inode, whose real path is the in-repo name, so containment has nothing to object to while a write through it changes the file outside as well. The shared guard refuses any target whose link count is not one, on the read as well as the write, so the panel says so when the file is opened rather than when the save fails. Measured before choosing, because refusing a legitimate file would be its own defect: 0 of 38561 git-tracked files across 12 real repositories have a link count above one, and the hard links package managers create live in node_modules, which is ignored and therefore never a row. The rule is on the link rather than on where the other name is, so a link between two files inside the repository is refused too — "which of these two names did the user mean" has no answer this panel can defend. The write also took a JavaScript string on trust. An unpaired surrogate — reachable by paste or by any future caller of the IPC — would have been written as U+FFFD, the exact substitution the read side exists to prevent. It is refused now with the same reason, before any bytes are produced. --- git-changes-file.js | 20 +++---- public/file-panel.js | 26 +++------ test/git-changes-file-real-git.test.js | 77 +++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/git-changes-file.js b/git-changes-file.js index f49aafc1..d2b67783 100644 --- a/git-changes-file.js +++ b/git-changes-file.js @@ -116,8 +116,7 @@ function resolveTargetInsideRepo(repo, relPath, deps) { const real = resolveOnDisk(joined); if (!real) return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; if (!isInsideDir(real, realRoot)) return { ok: false, error: 'path resolves outside the repository', reason: 'outside' }; - // Every check that matters runs on the resolved path: a symlinked directory - // component defeats one that reads the string the renderer sent. + // see .ai/contexts/changes-view.md ("Containment, and which path the write runs on") if (hasGitSegment(path.relative(realRoot, real))) { return { ok: false, error: 'the git directory is not editable', reason: 'git-dir' }; } @@ -135,6 +134,7 @@ function resolveTargetInsideRepo(repo, relPath, deps) { return { ok: false, error: 'file is not in the working tree', reason: 'missing' }; } if (!stat.isFile()) return { ok: false, error: 'not a regular file', reason: 'not-a-file' }; + if (stat.nlink !== 1) return { ok: false, error: 'this file is a hard link', reason: 'hardlink' }; return { ok: true, path: real, size: stat.size, repoRoot: realRoot }; } @@ -164,8 +164,7 @@ function soleEol(text) { return kinds.length === 1 ? kinds[0] : null; } -// CodeMirror folds CRLF *and* a lone CR to LF, so both have to fold here too, -// or the buffer never compares equal to what was read. +// see .ai/contexts/changes-view.md ("Caps, line endings and encoding") function toLf(text) { return text.replace(/\r\n?/g, '\n'); } @@ -175,12 +174,15 @@ function applyEol(text, eol) { return eol === '\n' ? lf : lf.replace(/\n/g, eol); } +// see .ai/contexts/changes-view.md ("Caps, line endings and encoding") +function hasLoneSurrogate(text) { + return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const outside = path.join(tmp, 'outside-secret.txt'); + fs.writeFileSync(outside, OUTSIDE_SECRET); + // Same inode, two names, one of them inside the repo: realpath cannot tell + // the difference, because the in-repo name IS the real path. + fs.linkSync(outside, path.join(repoDir, 'planted.txt')); + assert.equal(fs.statSync(path.join(repoDir, 'planted.txt')).nlink, 2, 'the link is what it looks like'); + + const readResult = await read(repoDir, 'planted.txt', false); + assert.equal(readResult.ok, false); + assert.equal(readResult.reason, 'hardlink'); + + const writeResult = await save(repoDir, 'planted.txt', 'pwned\n'); + assert.equal(writeResult.ok, false); + assert.equal(writeResult.reason, 'hardlink'); + assert.equal(fs.readFileSync(outside, 'utf8'), OUTSIDE_SECRET, 'the file outside the repository is untouched'); + + // A link to a file elsewhere in the same repo is refused by the same rule: + // the check is on the link, not on where the other name happens to be. + fs.linkSync(path.join(repoDir, 'f.txt'), path.join(repoDir, 'inner-link.txt')); + const inner = await read(repoDir, 'inner-link.txt', false); + assert.equal(inner.ok, false); + assert.equal(inner.reason, 'hardlink'); + } finally { cleanup(tmp); } +}); + +test('real git: an ordinary file is not mistaken for a hard link', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + assert.equal(fs.statSync(path.join(repoDir, 'f.txt')).nlink, 1); + const result = await read(repoDir, 'f.txt', false); + assert.equal(result.ok, true, result.error); + } finally { cleanup(tmp); } +}); + +// --- The write refuses what the read refuses ------------------------------ + +test('real git: an unpaired surrogate in the content is refused rather than written as U+FFFD (mutation target: the write-side encoding check)', async () => { + const tmp = mkTmp(); + try { + const repoDir = path.join(tmp, 'repo'); + initRepo(repoDir); + const target = path.join(repoDir, 'f.txt'); + const before = fs.readFileSync(target); + + for (const content of ['line1\n\uD800line2\n', 'lone low \uDC00\n', 'pair \uD83D\uDE00 then lone \uD83D\n']) { + const result = await save(repoDir, 'f.txt', content); + assert.equal(result.ok, false, `must refuse ${JSON.stringify(content)}`); + assert.equal(result.reason, 'encoding'); + } + assert.deepEqual(fs.readFileSync(target), before, 'and nothing is written'); + + // A well-formed pair is ordinary text and still saves. + const ok = await save(repoDir, 'f.txt', 'emoji \uD83D\uDE00 fine\n'); + assert.equal(ok.ok, true, ok.error); + assert.equal(fs.readFileSync(target, 'utf8'), 'emoji \uD83D\uDE00 fine\n'); + } finally { cleanup(tmp); } +}); From 43602a4cc968a13a39d86fbd54dd07680dbd8fc3 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 14:15:29 +0200 Subject: [PATCH 25/29] test(changes): let the scratch repo outlive a git child that is still dying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI failed to remove the temp repo for the one case that trips the maxBuffer cap: EBUSY on rmdir. The cap SIGTERMs the overrunning git child, and execFile's callback runs before that child is reaped — measured, exitCode null and killed true at callback time. On Windows a live process holds a handle on its working directory, so removing the scratch repo races a process that is still exiting. Linux unlinks by name and never noticed. The product behaviour is right: an overrunning child must be killed, and production must not block waiting for it to die. So the cleanup tolerates the window instead, with the retries rmSync provides for exactly this. Both real-git suites get it — only one case trips the cap today, but the race belongs to the shape, not to that test. --- test/git-changes-runner-real-git.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/git-changes-runner-real-git.test.js b/test/git-changes-runner-real-git.test.js index 9c19d378..a97cf85b 100644 --- a/test/git-changes-runner-real-git.test.js +++ b/test/git-changes-runner-real-git.test.js @@ -42,8 +42,11 @@ function mkTmp() { return fs.realpathSync.native(dir); } +// Same race as test/git-changes-file-real-git.test.js: a git child killed by a +// cap is still terminating when the assertion returns, and on Windows it holds +// its working directory until it dies. function cleanup(dir) { - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } // Scratch repo only: drop the caller's GIT_* env (set when this suite runs under a hook) and its hooks. From 66c5f415adf255856f1ab6acab1397f2ff0f6d63 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 14:15:29 +0200 Subject: [PATCH 26/29] docs(changes): state the hard-link rule and its cost, and the write's encoding check The Containment section named every cheap vector except this one. It now says what a hard link defeats, what refusing it costs (measured), and why the rule is on the link rather than on where its other name lives. The encoding paragraph gains the write side, and both user-facing lists gain the case. --- .ai/contexts/changes-view.md | 19 ++++++++++++++++++- .ai/contexts/ipc-bridge.md | 2 +- docs/changes-view.md | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index f315bb0a..d7667931 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -404,6 +404,19 @@ the already-resolved cwd — it is never a renderer-supplied string. a **symbolic link** outright (`reason: 'symlink'`): a symlink's content in a git working tree is its target string, so the pair would be the link text against the target's content, and a save would land on a file the row does not name. +A **hard link** is the one escape realpath cannot see: a second name for the +same inode, whose real path *is* the in-repo name, so containment has nothing +to object to and a write through it changes the file outside as well. The +shared guard therefore refuses any target with `stat.nlink !== 1` +(`reason: 'hardlink'`), on the read as well as the write, so the panel says so +when the file is opened rather than when the save fails. Measured cost before +choosing: **0 of 38 561 git-tracked files across 12 real repositories** have a +link count above one, and the hard links package managers create live in +`node_modules`, which is ignored and so never a row. The rule is on the link, +not on where the other name is: a link between two files inside the repository +is refused too, because "which of the two names did the user mean" has no +answer the panel can defend. + It then resolves both the root and the joined path **on disk** (`resolveOnDisk`) and runs **every remaining check against the resolved path**: containment in the repository root (which catches an escape through a symlinked @@ -487,7 +500,11 @@ editor cannot preserve them: contain: decoded as UTF-8 it becomes U+FFFD and would be written back as those replacement bytes, irreversibly. Both sides are therefore decoded strictly (`TextDecoder` with `fatal: true`) and a file that is not valid UTF-8 - is refused with `reason: 'encoding'`, not repaired. The same decoder is given + is refused with `reason: 'encoding'`, not repaired. The write refuses the same + thing from the other direction: a JavaScript string may hold an **unpaired + surrogate**, which `Buffer.from(…, 'utf8')` would silently write as U+FFFD — + the very substitution the read exists to prevent — so `hasLoneSurrogate` + rejects it with the same reason before any bytes are produced. The same decoder is given `ignoreBOM: true`, because its default is to **consume** a leading U+FEFF: the BOM is stripped from what the editor sees, so it cannot be typed over or counted as a diff, and re-applied on write when the bytes on disk carried diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 5161d382..c0b69498 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -91,7 +91,7 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing): |---|---|---|---| | `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. | | `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- `, or `git diff --no-index -- /dev/null ` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). | -| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. | +| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :` (or `HEAD:` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `hardlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. | | `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. | | `git-changes-locate` | `(sessionId, filePath)` | `{ok:true, relPath, changed, staged, untracked} \| {ok:false, error, reason}` | The only Changes IPC that takes an **absolute** path, and it gives back a repo-relative row: a terminal file link maps to a Changes row here, never in the renderer. One `git status` scoped to that path decides `changed`. Local sessions only. | | `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, through `git-changes-watch.js`'s registry, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. A `rename` event re-arms the watch, since an atomic replacement otherwise silences it. | diff --git a/docs/changes-view.md b/docs/changes-view.md index f51d1f02..7fd181a2 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -49,7 +49,7 @@ On a local session, the open file is a live editor, not a picture of a diff. Typ - **Save** with the Save button or `Ctrl/Cmd+S`. The button is inactive until you change something. The file list refreshes on save, so the row's counts follow what you wrote. - The button next to **Close** cycles three views: **Inline** (one column, changes marked in place — the default, because the panel is a narrow column and side-by-side halves it), **Plain** (just the file, no diff decoration) and **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right). The choice is remembered. - The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report. -- These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, and a file over 2 MB. +- These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, a hard link (two names for the same bytes, and only one of them is in this repository), and a file over 2 MB. ### When the session writes the same file From afca6c017f6f85588ed71572c782f17417ff46e4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 14:15:36 +0200 Subject: [PATCH 27/29] refactor(changes): keep the watcher's comment to a pointer The rename re-arm carried its reasoning in the source; it lives in the context doc, which is where the rule about atomic replacement already is. --- git-changes-watch.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/git-changes-watch.js b/git-changes-watch.js index 9b06074e..f7551983 100644 --- a/git-changes-watch.js +++ b/git-changes-watch.js @@ -41,8 +41,7 @@ function createChangesWatchRegistry(deps) { entry.armed = false; } - // A rename replaces the inode the watch is bound to, so the watch is re-armed - // on the same path once the replacement has settled. + // see .ai/contexts/changes-view.md ("Saving over a file that moved") function onEvent(entry, eventType) { if (eventType === 'rename') entry.needsRearm = true; if (entry.timer) unschedule(entry.timer); From bbd6e5cd5a59d657e0012fa627cf7af9e5e184ad Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 16:11:24 +0200 Subject: [PATCH 28/29] refactor(changes): keep the doc-change listener's comment to a pointer The reason an updateListener is used instead of a DOM input listener was spelled out in the source; it belongs in the viewer-panel context doc, beside the rest of the editable-viewer contract. --- .ai/contexts/viewer-panel.md | 7 +++++++ public/codemirror-setup.js | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 321feb32..4843410c 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -128,6 +128,13 @@ the Changes panel turns them off. `test/codemirror-merge-editing.test.js` drives all of this against the real CodeMirror under jsdom — a stub that dispatches `cm-save` itself proves nothing about the keymap. +All three factories take an `onChange` callback, and `docChangeListener` in +`public/codemirror-setup.js` delivers it from a CodeMirror `updateListener` on +`docChanged` rather than from a DOM `input` listener on the editor: it fires +for typing, paste, undo/redo and programmatic dispatches alike, where a DOM +`input` event reports only the first two. A Save button whose enabled state is +computed from that callback is therefore still right after an undo. + ## Gotchas - **CodeMirror state holds DOM references** — calling `destroy()` then immediately `open()` on the SAME container works because `_createEditor` rebuilds it, but if you reorder this, the editor can dangle. diff --git a/public/codemirror-setup.js b/public/codemirror-setup.js index a2963e7e..285f01e4 100644 --- a/public/codemirror-setup.js +++ b/public/codemirror-setup.js @@ -416,9 +416,7 @@ function createReadOnlyViewer(parent, content, filename) { // ── Editable File Viewer (for file panel) ─────────────────────────── -// A caller that needs to know the document changed (an enabled/disabled Save, -// say) gets it from CodeMirror rather than from DOM input events, which miss -// undo, paste and programmatic edits. +// see .ai/contexts/viewer-panel.md ("Changes mode") function docChangeListener(onChange) { return typeof onChange === 'function' ? EditorView.updateListener.of((update) => { if (update.docChanged) onChange(); }) From 6768b820f501f405a623f04c1b8203a93b6f9469 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 16:11:35 +0200 Subject: [PATCH 29/29] test(panel): cover the panel X over a dirty Changes buffer with a shell open The close path asks before discarding unsaved edits, but only the clean-buffer case was covered, so removing that question from handleClose stayed green. --- test/panel-terminal.test.js | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/panel-terminal.test.js b/test/panel-terminal.test.js index b74c8c67..5b3c3151 100644 --- a/test/panel-terminal.test.js +++ b/test/panel-terminal.test.js @@ -32,6 +32,27 @@ async function microtasks(n = 4) { for (let i = 0; i < n; i++) await Promise.resolve(); } +// Stand-ins for the lazy CodeMirror bundle the Changes tab builds its editor +// from: a DOM node, and a document the test can rewrite to dirty the buffer. +function stubChangesEditors(window) { + const make = (parent, text) => { + const dom = window.document.createElement('div'); + parent.appendChild(dom); + const view = { + dom, + text, + destroy() { if (dom.parentNode) dom.parentNode.removeChild(dom); }, + }; + view.state = { doc: { toString: () => view.text } }; + view.b = { state: view.state }; + return view; + }; + window.loadCodeMirrorBundle = () => Promise.resolve(); + window.createMergeViewer = (parent, original, modified) => make(parent, modified); + window.createUnifiedMergeViewer = (parent, original, modified) => make(parent, modified); + window.createEditableViewer = (parent, content) => make(parent, content); +} + function mouse(window, type, clientY) { return new window.MouseEvent(type, { clientY, bubbles: true, cancelable: true }); } @@ -663,6 +684,52 @@ test('closing the tab with the panel X leaves no stale tab content above the she } finally { ctx.destroy(); } }); +test('the panel X over a dirty buffer asks first, and a refusal keeps both the edits and the shell', async () => { + const status = { + ok: true, + kind: 'local', + branch: { head: 'main', ahead: 0, behind: 0 }, + files: [{ path: 'src/a.js', origPath: null, staged: false, unstaged: true, untracked: false, renamed: false, state: 'M', added: 1, deleted: 0 }], + totals: { files: 1, added: 1, deleted: 0 }, + }; + const ctx = setupPanel({ + api: { + gitChangesStatus: () => Promise.resolve(status), + gitChangesFile: () => Promise.resolve({ ok: true, original: 'old\n', current: 'new\n', version: 'v1' }), + }, + }); + try { + const { window, document } = ctx; + const asked = []; + window.confirm = (message) => { asked.push(message); return asked.length > 1; }; + stubChangesEditors(window); + + window.switchPanel('owner'); + await window.openChangesTab('owner'); + await microtasks(12); + document.querySelector('.changes-file-row[data-path="src/a.js"]') + .dispatchEvent(new window.Event('click', { bubbles: true })); + await microtasks(12); + await window.togglePanelTerminal('owner'); + ctx.inCtx("filePanelState.get('owner').currentTab").editorView.text = 'edited\n'; + + window.handleClose(); // the X on the tab toolbar, first answer: keep the edits + + assert.equal(asked.length, 1, 'unsaved edits are never dropped without asking'); + assert.equal(document.getElementById('file-panel-changes').style.display, 'flex', + 'a refused discard leaves the tab exactly where it was'); + assert.ok(ctx.inCtx("filePanelState.get('owner').currentTab"), 'and the buffer is still there to save'); + assert.ok(window.openSessions.has('panel:owner'), 'the shell below it is untouched'); + + window.handleClose(); // second answer: discard + + assert.equal(asked.length, 2); + assert.equal(document.getElementById('file-panel-changes').style.display, 'none'); + assert.equal(ctx.inCtx("filePanelState.get('owner').currentTab"), null); + assert.ok(window.openSessions.has('panel:owner'), 'closing the tab never closes the shell'); + } finally { ctx.destroy(); } +}); + // --- 10. A panel shell is a PTY, not a session ------------------------ test('a spawn that fails after the user closed it leaves nothing behind', async () => {