PROTOTYPE: full-file code viewer for code review (design doc phase 1) - #1372
Draft
backnotprop wants to merge 2 commits into
Draft
PROTOTYPE: full-file code viewer for code review (design doc phase 1)#1372backnotprop wants to merge 2 commits into
backnotprop wants to merge 2 commits into
Conversation
PROTOTYPE — not for merge. Implements phase 1 of DESIGN_full-file-code-review.md: open a whole file inside code review, annotate any line range in it, and have that annotation round-trip into normal review feedback labelled as outside the diff. Server (both runtimes, per the code-nav pattern): - New packages/shared/repo-file.ts: one hardened resolver for full-file reads. Realpath containment against the review root plus a 5 MiB cap (aligned with MAX_REVIEW_FILE_CONTENT_BYTES). The review side's only traversal defense was a lexical ".."/leading-slash check, which a symlink walks straight past; containment is decided by realpath, not by string shape. Vendored to Pi via vendor.sh. - GET /api/review-file in packages/server/review.ts and mirrored in apps/pi-extension/server/serverReview.ts. Serves the live working tree with no snapshot guard, by design. - Retrofit: /api/code-nav/file now goes through the same guard. It previously had NO size cap at all and the same lexical-only check. Client (packages/review-editor): - ReviewFullFilePanel: one Pierre CodeViewFileItem, virtualized, sharing the worker pool and resolveSyntaxTheme. One reused panel retargeted per file, like the diff panel. Content-hash cacheKey (Pierre 1.3.2 compares nothing else). - Entry points: an "Open whole file" affordance and context-menu item on file tree AND git-status rows, plus an "Open file" action on the code-nav peek, which the design doc calls a dead end today. - Focus arbitration: the diff panel yields isFocused while the full-file panel holds the same path, so the two surfaces cannot corrupt each other's ToolbarHost drafts (design doc risk 1). Annotation round-trip: - Snippet fallback (the doc's one named integration fix): resolveAnnotationSnippet prefers the patch and falls back to file content, so lines outside every hunk stop yielding an empty snippet. Only the new side falls back — the working tree is not the old side. - CodeAnnotation.outsideDiff, stamped at creation where the patch is known, and exported as an "Outside diff" label plus the fenced lines, so an agent stops hunting for line 500 in a diff whose only hunk is at line 3. In-diff export behavior is unchanged. Tests: 41 endpoint/guard cases across both runtimes (traversal, escaping file and directory symlinks, inside symlinks still resolving, dangling symlinks, cap boundary, code-nav retrofit) and 11 round-trip cases. AI-assisted (Claude) under maintainer direction.
Matches the shape DESIGN_full-file-code-review.md open question 1 proposes
("[Outside diff] src/foo.ts lines 40-52") instead of an em-dash suffix.
AI-assisted (Claude) under maintainer direction.
backnotprop
pushed a commit
that referenced
this pull request
Aug 21, 2026
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Opens a whole file inside code review, annotates any line range in it, and
round-trips that annotation into normal review feedback labelled as outside the
diff. Everything else in the doc (repo-wide tree browsing, tree-sitter nav,
open-in-editor) is deliberately out of scope.
What was built, against the doc
packages/shared/repo-file.ts(new, vendored)GET /api/review-file, both runtimesFileViewer+REVIEW_FILEpanelReviewFullFilePanel/api/code-nav/file24 files, +1695 / -60.
The endpoint and the guard
The design doc's central security point is correct and worth restating: the
review side's entire traversal defense was
validateFilePath, which rejects..substrings and a leading/. That is a check on the shape of a string,and a symlink is not a shape. A repo containing
link -> /etcpasses it andthen reads
link/passwdstraight off the filesystem. Separately,/api/code-nav/filehad no size cap at all, so one request against a largefile in the tree was a memory bomb.
packages/shared/repo-file.tsreplaces both with realpath containment:..segments, no NUL, no Windowsdrive prefix). This is a cheap pre-filter, deliberately not the boundary. It
also fixes a real false positive:
foo..bar.tsis a legal filename that thesubstring check rejected.
candidate to live under the canonical root. Because realpath decides,
symlink escapes fail by construction. Realpathing the root also matters on
macOS, where
/tmpis itself a symlink.statbeforeread, so an oversized file is refused before it is residentin memory rather than after.
Containment uses a trailing-separator comparison, so
/repo-evil/xis notinside
/repo, which is the bug a naivestartsWithhas.Failure reasons map to one shared status table, so both runtimes answer alike:
invalid-path400,outside-root403,not-found404,not-a-file400,too-large413. A dangling symlink reportsnot-found, neveroutside-root,so the response never confirms what exists outside the repo.
GET /api/review-file?path=returns{ filePath, content, size }from the liveworking tree, with no snapshot guard. That is deliberate, per the doc: a file panel
should show the file as it is now. The gate (no committed GitButler views, local
access required, PR checkouts must actually be warm) is shared with
/api/code-nav/filethrough one helper rather than copied, since the two hadalready begun to drift between Bun and Pi.
Client
ReviewFullFilePanelrenders one PierreCodeViewFileItem, which is what buysvirtualization, the shared worker pool and line annotations for free. Confirmed
in the live run: a 907-line file keeps ~55 line elements in the DOM.
One reused panel retargeted per file, matching the diff panel (doc open
question 2's recommendation). Content-hash
cacheKey, since Pierre 1.3.2compares nothing else.
Focus arbitration landed with phase 1 as the doc insists:
ToolbarHostdraftslive in module-level maps keyed by file path, so the diff panel now yields
isFocusedwhile the full-file panel holds the same path.Annotation round-trip
Two pieces, matching the doc's option 2:
Snippet fallback.
resolveAnnotationSnippetprefers the patch and fallsback to file content. Only the new side falls back, because the working tree
is not the old side; an old-side range with no hunk coverage correctly yields
nothing.
The label.
CodeAnnotation.outsideDiffis stamped at creation, where thepatch is known, the way plan-diff annotations stamp
diffContext. Export addsan
[Outside diff]marker, a one-line note, and the fenced lines.In-diff export behavior is unchanged.
originalCodestays suggestion-only forordinary annotations (it renders as
Replaces:); the selected lines travel as aseparate trailing argument and are attached only when the annotation is outside
the diff. An out-of-diff comment with no suggestion prints under
Code at these lines:rather thanReplaces:, because nothing is beingreplaced.
Test evidence
New: 41 endpoint/guard cases (every one runs against both Bun and Pi) and 11
round-trip cases. All green.
Guard coverage is the interesting part, since these are the cases the code it
replaces gets wrong:
../, embedded../, and absolute paths..)..anywhere/api/code-nav/fileanswering 403 and 413 where it previously answered 200Typecheck (
bun run typecheck) passes.apps/review/tsconfig.jsonis not aclean gate on either side: pristine
origin/mainreports 96 errors there andthis branch reports 95, with no new error in any touched file.
Full suite: this environment fails ~500 tests on pristine
origin/main(happy-dom
window.Headers, socket exhaustion, ECONNREFUSED under a 387-filesingle-process run), so the raw number is meaningless. A clean baseline measured
in a separate pristine worktree fails 502 uniquely-named tests. Diffing failing test names against it, the ONLY difference on this branch
is my own 20 endpoint tests, which fail in that run for the same environmental
reason and pass everywhere else:
bun test packages/server/runs 777 testsacross 52 files with 0 failures, mine included. No pre-existing test newly
fails on this branch.
End-to-end proof
Compiled a binary, ran a real review session against a scratch repo, and drove
it with headless Playwright. The fixture is built so the demonstration cannot be
accidental:
src/alpha.tsis 906 lines whose only hunk is at lines 1-3, soan annotation at line 497 is unambiguously outside the diff.
1. Review session open. Three changed files, git-status view.
2. Full file opened from a tree row. Own dock tab, header reads
src/alpha.ts/Full file/907 lines, syntax highlighting via the shared theme.3. Scrolled deep into the file. Window at lines 467-532 of 907, still ~66
line elements in the DOM. Virtualization holds.
4. Annotating lines 497-500, hundreds of lines below anything in the diff.
5. Annotation created. Range highlighted, annotation badge on
src/alpha.ts, Send Feedback now offered.6. Feedback submitted. The captured
/api/feedbackpayload(full JSON):
{ "filePath": "src/alpha.ts", "lineStart": 497, "lineEnd": 500, "side": "new", "outsideDiff": true, "text": "This whole helper is dead code and should be deleted.", "originalCode": "export function alphaPart99(n: number): number {\n // untouched region of alpha.ts, far below the diff hunk\n return n + 99;\n}" }and the feedback the agent actually receives:
export function alphaPart99(n: number): number {
// untouched region of alpha.ts, far below the diff hunk
return n + 99;
}
The snippet is byte-exact against lines 497-500 of the file on disk.
Deviations from the doc, and why
The server does not return
language. The doc's response shape includes anoptional
language.packages/review-editor/utils/detectLanguage.tsalreadymaps extensions client-side, and duplicating that map into a vendored shared
module would create two copies to drift. The client derives it.
No Pi files-array change. The doc pairs vendor.sh with "the Pi files array".
apps/pi-extension/package.jsonalready shipsgenerated/andserver/aswhole directories, so the vendored module and the new handler are included with
no manifest edit. Only the vendor.sh module list needed the entry.
No new
@sourceentry. The repo rule applies to new directories containing.tsx. The panel lives indock/panels/, already covered by the recursive./dock/**/*.tsxglob. Adding a redundant entry would be noise.The affordance also went on git-status rows. The doc says "file tree rows".
The git-status sections panel is the default view of a code review and renders
its own rows, so tree-only would have made the feature invisible on open.
Panel component is
ReviewFullFilePanel, notFileViewer. It follows theReview*Panelnaming every other dock panel uses.Open questions the prototype surfaced that the doc missed
Pierre's drag gesture does not produce a line range on a
CodeViewfileitem. This is the biggest surprise. The doc assumes full-file line
selection comes free from Pierre, citing
CodeFilePopout. In practiceneither a gutter drag nor an in-code drag fired
onSelectedLinesChangehere,with controlled or uncontrolled selection. Multi-line selection only works
because I ported
CodeFilePopout's browser-text-selection mapping (read theshadow-root
Selection, map anchor/focus nodes to[data-line]ancestors).Any real implementation needs that mapping, or an answer from Pierre. It is
not a free win, and phase 1 estimates should account for it.
A single-line click and a drag race each other. The drag ends with a
click on the release line, which silently collapses the range to one line.
CodeFilePopoutguards this with a suppression window; the doc nevermentions it, and it is invisible in code review: you just get the wrong
annotation. Ported here.
CodeViewdoes not scroll unless the host says so. Withoutoverflow-y-autoand containment classes on the CodeView root, the virtualwindow never advances past the first screen and the file silently appears
truncated. The doc treats virtualization as automatic.
originalCodeis overloaded. It means "the lines a suggestion replaces",not "the annotated lines". It is only populated when a suggestion exists,
and the exporter renders it as
Replaces:. The doc's "fence the selectedlines" therefore cannot just reuse it without either changing in-diff export
behavior for every existing comment or threading a separate value. I chose
the latter; the maintainer may prefer a dedicated field.
Where does "outside the diff" get decided? I stamp it at creation because
only the app holds the patch. That means an annotation's label is fixed at
authoring time even if the diff later changes underneath it (agent edits
mid-review). Recomputing at export would need the patch plumbed into the
exporter. Worth an explicit decision.
The doc's open question 1 answers itself once you see the output. The
per-annotation
[Outside diff]label reads naturally inside the existingper-file grouping; a separate trailing section would have split one file's
comments across two places in the same document.
Focus arbitration needs a notion of "active surface", not just
"focused file".
focusedFilePathalone cannot express "this path, but theother panel owns it". I added a second field. A general fix probably wants
one owner value rather than a growing set of exclusions.
Not done (out of scope)
Repo-wide tree browsing (phase 2), tree-sitter nav (phase 3), open-in-editor
(phase 4), old-side viewing, share-link semantics.
The guide-viewer manifest was checked and is unchanged, so it was not
regenerated.
AI-assisted (Claude) under maintainer direction.