Skip to content

(changes): edit a changed file in place, with the list beside it and the diff live - #302

Merged
jbr-sekoia merged 29 commits into
mainfrom
feat/changes-editable
Sep 18, 2026
Merged

jbr-sekoia merged 29 commits into
mainfrom
feat/changes-editable

Conversation

@jbr-sekoia

@jbr-sekoia jbr-sekoia commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Why

The Changes panel shows you what a session did to your working tree, and then stops. Every correction — a typo in a generated comment, a stray debug line, a line to delete before committing — meant leaving the panel, finding the file elsewhere, editing it there, coming back and refreshing. The panel is where you review a session's work; reviewing and fixing are the same activity.

What

A changed file is edited in place, with the diff recomputed as you type, and the file list stays on screen while you do it.

Click a row and the editor opens below the list; click another and it swaps. There is no navigation step to undo, so there is no Back — the editor has a Close. Three modes, persisted: inline (the default here), side-by-side and plain. @codemirror/merge was already bundled and already driving the MCP diff tab, so the editor and the live diff are assembly, not invention — mergeControls: false keeps the Accept/Reject chunk buttons out, because this panel is still not a git client.

A file link in the terminal opens the diff rather than the plain viewer when the file is one of the session's changed files.

The work was everywhere CodeMirror does not reach: the filesystem, git, and the panel's own state machine.

Keeping the resolution boundary

The 'changes' tab was the only tab type that never carried an absolute path — git-changes-target.js resolves the session's cwd inside the main process and neither IPC returned it. That boundary is kept rather than worked around.

  • git-changes-file returns the content pair. The original side is what git diff itself compares against — git cat-file blob :<path> unstaged, HEAD:<path> staged. cat-file blob over git show because git show is content-type-polymorphic: measured, git show ':/SECRETWORD' exits 0 printing a commit and git show 'HEAD:' exits 0 printing a tree, while cat-file blob refuses all three. A guard bug then degrades to a refusal instead of putting a commit object in the editor as "the original".
  • git-changes-save writes the working-tree file. git-changes-watch/unwatch carry no absolute path. git-changes-locate is the only one that takes an absolute path — a terminal link hands the renderer one — and it maps it to a repo-relative row main-side, against the root the session's own cwd resolves to. The renderer never sees that root.
  • All four resolve through requireLocalTarget(resolveGitChangesTarget(...)), pinned by a test that loops over the four rather than asserting each once, so a fifth handler added without a guard fails by construction.
  • Remote sessions are refused in main, not only in the renderer. There is no file-write path to a remote host anywhere in this app, and inventing one here would be a much larger feature wearing a small one's clothes.

<rev>:<path> is a revision, not a pathspec: --literal-pathspecs does not reach it and -- cannot separate it, so it carries its own guard.

Not overwriting what the session wrote

The panel sits next to a session actively writing those same files, so an unconditional writeFileSync is a data-loss device. Two layers:

  • A version token (sha1 + length of the bytes read) is returned with the pair and required back on save; main re-reads and refuses on mismatch with reason: 'stale'. A save with no token is refused, so it fails closed.
  • A watcher reloads a clean buffer and warns on a dirty one. It re-arms on rename, which is how git checkout, sed -i and atomic saves replace a file — fs.watch otherwise goes deaf after the first one.

An idle refresh never replaces an open editor's content, and the render path stops being a teardown: renderChangesDiff() used to innerHTML = '' on every call, which would have destroyed the editor under the cursor every time the session finished a turn.

Five exits ask the same question and all five honour the answer: switching rows, the tab's own close button, the panel toggle, Back and Reload. Each carries the gate independently rather than funnelling through one place, and each has its own test — independence is what protects the buffer, since a single funnel is one refactor away from being bypassed. When the session takes the panel over (an MCP-driven open, which fires while you edit), the dirty buffer, its pair and its token are stashed and restored with a notice; the token travels, so a restore can never become a clobber.

Content fidelity

Reading bytes and writing a string back is where a file editor quietly corrupts things:

  • Line endings — LF, CRLF, lone CR, no trailing newline, BOM+LF, BOM+CRLF and empty all round-trip byte-identically, with git clean after a no-op save. A file that genuinely mixes endings is refused rather than silently normalised to the dominant one, which would manufacture a whole-file diff on a file nobody touched.
  • Encoding — strict UTF-8 decode; a latin-1 file is refused, not turned into replacement characters and written back.
  • BOM — hidden from the editor, re-applied from the disk bytes; a file without one can never acquire one.
  • Oversized and binary files are refused, never truncated — a truncated buffer that can be saved is a data-loss device.

Containment

The guard resolves on disk first and checks the resolved path, because a literal renderer-supplied string is defeated by a symlinked directory component. Root and git directories come from one git rev-parse --show-toplevel --absolute-git-dir --git-common-dir; the target must stay inside the root; isSensitivePath applies on top; the write runs on the path the guard returned.

Nothing under .git/.git/config is a core.pager/alias command-execution primitive. Holds against gitlink/config, a 2-hop symlink chain, a link into .git/hooks, and the git directories of separate-git-dir, linked-worktree and submodule layouts, without over-blocking .gitignore, .github/workflows/ci.yml, a.git/x.txt or dotgit.md. .. and .git are matched as path segments, so schema..v2.sql is editable.

A row for an in-repo symlink is refused: a symlink's content is its target string, and a save would land on a file the row does not name. A link to that symlink resolves to the target's row, where title, save, watch and token all name the same file. The asymmetry is between editing a link and following one.

A hard link is refused too, on read as well as write. realpath resolves a symlink and a hard link is not one, so a file inside the repo hard-linked to a file outside passes containment and a save would change the outside file with it. The rule is on the link itself, not on where the other name sits — a link between two in-repo files is refused as well, because "which of these two names did the user mean" has no answer the panel can defend. Measured before choosing: 12 repositories, 38 561 tracked regular files, none with a link count above 1, and package-manager hard links live in node_modules, which is ignored and never a row. Refusing at open rather than only at save means the user is told before typing, not after.

An unpaired surrogate in the content to be written is refused with reason: 'encoding', symmetric with the read side — otherwise Buffer.from(…, 'utf8') would write the U+FFFD substitution the read side exists to refuse.

Found by running it

The panel was driven in a live instance — a real session editing real files — and four things came out of it that no unit test could see:

  • Side-by-side is wrong at this width. In a 450 px panel it renders two 225 px columns and clips code mid-token; inline gives one 449 px column. Inline is now the panel's default under its own key; the MCP tab keeps side-by-side under filePanelDiffMode, and a stored preference is untouched.
  • Save had no disabled state. It is now inactive when the buffer is clean or a save is in flight. The first attempt set it at render time, which would have stuck disabled because typing does not re-render — the three editor factories now take an onChange backed by a CodeMirror updateListener, which catches typing, paste, undo and programmatic edits where a DOM input listener misses the last two. The clean and in-flight guards stay in the handler, because Ctrl+S bypasses disabled.
  • Refresh is the existing search-bar icon, not the word.
  • The diff appeared to vanish after a save. The original side was never the problem — it is re-derived from cat-file blob, verified before anything changed. What vanished were the untracked row's counts and the header total, which for an untracked row are the visible diff: those counts are click-derived and the status refresh a save triggers carries none. The save now re-applies them from the bytes it wrote. Fixing it surfaced a second bug: the post-save guard compared the selected file by identity while repointSelectedFile had just swapped that object; it compares by path there, and the five row-switch guards stay on identity.

The panel also moved onto the sidebar's surface. Eleven values both carried as literals are :root tokens now — 188 substitutions, every one value-preserving, verified by resolving every var() back to its literal and diffing: five structural lines and exactly one value change, #file-panel's background. Two assertions in test/sidebar-busy-agents-tint.test.js matched the accent by spelling and now accept either form; they still go red on a different colour, a one-digit near-miss, a different token and removal.

Not in scope

  • Staging, committing, reverting, hunk-level accept/reject.
  • Editing on remote sessions; creating or deleting a file; editing the path of a renamed entry.

Verification

Six adversarial review rounds, 28 findings, all closed by execution. 72 mutations, all red, including: the containment check, the .git rule under a resolved segment and under containment alone, the version token and its requirement, both watcher halves, the EOL and BOM round trips, the remote refusal in main and in the renderer, editor reuse, the non-teardown render, each of the four panel exits pinned independently, and onChange in each of the three modes.

The cat-file blobgit show mutation is red, so that decision is pinned by the module's own argv rather than by a test asserting git's behaviour.

npx eslint . → 0 errors, 331 warnings (the main baseline). npm test → stage 1 tests 1806 / pass 1803 / fail 1 / skipped 2, stage 2 120 / 119 / 0 / 1. The single failure is test/ipc-path-validator.test.js "allows files under ~/.claude/", pre-existing and environmental: that machine's ~/.claude/CLAUDE.md is a symlink out of ~/.claude and the validator resolves on disk. This branch touches neither that test nor ipc-path-validator.js.

Patch coverage 98.7 %.

One Windows-only test failure was fixed here rather than retried blindly: the maxBuffer cap SIGTERMs the overrunning git cat-file, and execFile's callback runs before that child is reaped (measured: exitCode === null, signalCode === 'SIGTERM', killed === true). On Windows a live process holds a handle on its working directory, so removing the scratch repo raced a process still exiting — which is why only the over-the-cap case tripped it. The product behaviour is right (an overrunning child must be killed, and production must not block waiting for it), so the test cleanup tolerates the window, in both real-git suites rather than only the one that trips it today.

What a human still has to judge, as opposed to measure: whether the panel reads as an editor in use, whether its new surface and accent read as one system with the sidebar, whether inline-by-default is right at your width, and whether the icon-only Refresh is self-evident without its tooltip.

Follow-ups

#301 (three fs.watch registries, two released on window close — this branch wires the one it adds), #303 (a session's panel ignores the worktrees its subagents work in), #304 (keep the journeys that only a running app can check).

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 `:/<text>` 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.

`<rev>:<path>` 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 `:<n>:<path>` 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.
… as you type

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.
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.
…fers

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.
… you can type in

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.
…e 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.
…t cover

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.
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.
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.
…d the stash

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.
… out of three

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.
…down

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.
…t 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.
…tion

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.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 75bb4c0 against main 65d8aca by exercising readChangesFile / writeChangesFile / resolveTargetInsideRepo with main.js's exact argument shape against real scratch repos — Windows junctions, a 2-hop chain, a git worktree add linked worktree, a hardlink — plus EOL/BOM/encoding round trips checked with Buffer.compare and git status, four of the 46 mutations replayed on a scratch copy, CI read on the head.

Holds, confirmed: junction out of the repo → outside; junction into .git and into .git/hooks (existing hook file too) → git-dir; 2-hop chain → outside; in a linked worktree, the gitlink wt/.git, ../repo/.git/config, .GIT/HEAD and .git/worktrees/wt/HEADinvalid-path before any git or fs call (plain fs.realpathSync does resolve junctions here). <rev>:<path> operand guard blocks leading -/:, .. segments and the n:path stage syntax; argv pinned to cat-file blob (the git show mutation is red). LF / CRLF / CR / no trailing newline / BOM+LF / BOM+CRLF / empty round-trip byte-identical and git-clean; mixed → mixed-eol; latin-1, overlong UTF-8 and on-disk CESU-8 surrogates → encoding; maxBytes accepted, maxBytes+1too-large. Remote refused in all three handlers via requireLocalTarget; watcher validated main-side, closeAll on window close, renderer unwatches before re-arming. Mutations: containment check, .git segment rule, token requirement, cat-fileshow — all four red. 15 commits, no trailers; CI green on 75bb4c0 including windows-2022.

Two things on the write path itself:

  1. Hardlink (git-changes-file.js:96-138). A file inside the repo hard-linked to a file outside passes containment (its realpath is itself) and writeChangesFile succeeds — the outside file changes with it. Every other cheap vector is closed and named in the "Containment" section; this one is neither. fs.statSync(real) is already there (line 130): refuse when stat.nlink !== 1, or state the accepted gap in .ai/contexts/changes-view.md. The threat model here is a session with shell access on the same tree, so a planted link is not theoretical.

  2. Unpaired surrogate in content (git-changes-file.js:265). Buffer.from(prefix + applyEol(stripBom(content), eol), 'utf8') takes the renderer string as-is: 'line1\n\uD800line2\n' with a valid token returns { ok: true } and writes ef bf bd — the U+FFFD substitution the read side refuses by design. Whether the editor can produce such a string is unverified without Electron; the main-process function has no defence if it arrives (paste, IPC). Re-decode with a fatal TextDecoder (or a surrogate regex) and refuse with reason: 'encoding', symmetric with the read side.

Smaller:

  • test/git-changes-file-real-git.test.js:112-122 pins /not in the index/; git 2.24 here says Not a valid object name :new.txt, so the file fails locally (CI's git is fine). The exit code 128 is already asserted; drop the wording match or list it as a second environmental gap — the body only lists one.
  • Comment ceiling: git-changes-file.js:117-119, 165-166, 180-181, git-changes-watch.js:11-15, 44-45, public/file-panel.js:401-402, 583-584, 1335-1336 carry two/three-line rationale without a doc pointer.

Unverified here, as in the body: the three product questions (mixed-EOL refusal, notice visibility, scroll across refresh), real fs.watch rename ordering on Windows/macOS, and anything on screen.

@jbr-sekoia
jbr-sekoia force-pushed the feat/changes-editable branch from 75bb4c0 to fea54fb Compare September 18, 2026 10:11
@devsuitup

Copy link
Copy Markdown
Owner

fea54fb is the rebase onto main after #300 plus a docs/changes-view.md paragraph on the panel shell; git-changes-file.js and the tests are byte-identical to 75bb4c0. The two write-path items (hardlink nlink, unpaired surrogate on save) and the two minor ones stand as posted; I will re-run the checks on the next head that touches them.

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.
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.
…k 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.
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.
…en there is one

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.
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.
…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.
…sked to

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
`:<path>` would read as git's conflict-stage syntax.
…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.
@jbr-sekoia jbr-sekoia changed the title (changes): edit a changed file in place, with the diff recomputed as you type (changes): edit a changed file in place, with the list beside it and the diff live Sep 18, 2026
…sent

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.
… dying

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.
… 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.
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.
@jbr-sekoia

Copy link
Copy Markdown
Collaborator Author

Head is now afca6c0. The five items are answered; here is what each got.

1. Hard link. Refused, and on the read as well as the write (874d644) — the panel says so when the file is opened rather than when the save fails. The rule is on the link itself, not on where the other name sits, 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.

Refusing a legitimate file would be its own defect, so the cost was measured before choosing: 0 of 38 561 git-tracked regular 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.

2. Unpaired surrogate on save. Refused with reason: 'encoding', before any bytes are produced (874d644) — symmetric with the read side, which exists to refuse exactly the U+FFFD substitution this would have written.

3. /not in the index/. Gone. The test now spawns git cat-file blob :new.txt and asserts the exit code and empty stdout only, with the reason stated in place: the code is what the module reads, the message varies by git version.

4. Comment ceiling. All eight sites are one-line pointers to .ai/contexts/changes-view.md now, the watcher's rename re-arm included (afca6c0) — its reasoning lives in the context doc, next to the rule about atomic replacement that it follows from.

5. Windows EBUSY. Diagnosed rather than retried (43602a4). The maxBuffer cap SIGTERMs the overrunning git cat-file, and execFile's callback runs before that child is reaped — measured at callback time: exitCode === null, signalCode === 'SIGTERM', killed === true. On Windows a live process holds a handle on its working directory, so removing the scratch repo raced a process still exiting; Linux unlinks by name and never noticed. That is why only the over-the-cap case tripped.

The product behaviour is right — an overrunning child must be killed, and production must not block waiting for it to die — so the test cleanup tolerates the window, in both real-git suites rather than only the one that trips it today.

Also since 75bb4c0: the hard-link rule and its measured cost are stated in the Containment section, which named every cheap vector except that one (66c5f41); the encoding paragraph gains the write side; and git-changes-locate's local-only guard, plain mode's change listener and the rev-operand guard are pinned (9742996) — three guards that were holding without a test asking them to.

CI is green on afca6c0 across both node versions on Linux and Windows.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed afca6c0 (base now the #300 merge, 0018635; git diff origin/main HEAD is 19 files / +4940 −277, exactly the PR's numbers — the panel-shell files are rebase noise, not content). Both write-path items are closed and mutation-tested:

  • Hardlink: git-changes-file.js:137 refuses nlink !== 1; fs.linkSync on NTFS here reports nlink === 2 so the guard is live on Windows, not only in CI; read and write both refuse (reason:'hardlink'), including a link to a file inside the repo. Guard removed → the test is red.
  • Unpaired surrogate: hasLoneSurrogate at 177-180, applied at 308; lone high, lone low and pair+lone all refused (reason:'encoding'); a real astral pair still round-trips byte-identical. Guard removed → red.
  • /not in the index/ dropped, exit 128 kept plus stdout === '', with the reason stated.
  • The six comment sites collapsed to pointers.

New layout (list kept mounted, editor below it behind a splitter): 500-row cap and note intact; a MutationObserver on the diff host records zero mutations across an idle rebuild and the editor survives; confirmDiscardChangesEdits gates all five exits (panel close, toggle, row switch, back, reload) and each has a test — the body's "the question lives in one place" undercounts, harmless. hidePanel() keeping the panel open for the shell runs after the discard gate in handleClose, so no bypass. sidebar-busy-agents-tint now accepts var(--accent) = #8088ff, same hue via the token refactor, no weakening. CSS source tests 11/11; 180/180 on the five suites; eslint 0 errors, the 16 warnings identical at 75bb4c0; CI green on afca6c0 including windows-2022.

Two small things, non-blocking: public/codemirror-setup.js:419-421 carries three lines of rationale above docChangeListener — same ceiling as last round, a pointer to .ai/contexts/viewer-panel.md and the prose there; and the combination dirty buffer × open shell × panel close is traced safe but has no test of its own (panel-terminal.test.js:646-664 covers the clean-buffer case).

Human at the keyboard: the master-detail stack (list, splitter, editor, optional shell) at the panel's minimum width and on a short screen; the three product questions from the body stand.

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.
…ll 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.
@jbr-sekoia
jbr-sekoia merged commit 0d58014 into main Sep 18, 2026
10 checks passed
@jbr-sekoia
jbr-sekoia deleted the feat/changes-editable branch September 18, 2026 14:18
@jbr-sekoia jbr-sekoia mentioned this pull request Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants