feat(ui): browse and check every file in the anonymize preview (#950)

* feat(ui): browse and check every file in the anonymize preview

The "What reviewers will see" window now redacts the README GitHub actually
shows and lets authors open any file of the commit.

- README: resolved from the file list like GitHub (.github/, root, docs/,
  any case, Markdown first) and only that path is tested against the
  "Leave files unredacted" rules. Before the list loads, only root names are
  assumed, so leaving docs/ unredacted no longer unredacts the root README.
  The file bar shows the README's real name.
- New GET /api/repo/:owner/:repo/file?commit=&path= (same access checks as
  /tree): raw text, no-store, path traversal rejected, 1 MB cap
  (file_too_big, 413), binary files reported as { binary: true }.
  /tree also lists binary files (binaryFiles) for the preview's tree.
- Tree rows open files (keyboard accessible, explorer active rail). Text is
  redacted through /api/anonymize-preview by the same batcher as the README;
  files left unredacted are sent without terms and tagged "Left unredacted"
  (opens the dialog). Markdown renders like the README, code is highlighted
  with Prism, images/PDF/binary/too-large files get a short note. Loading
  and error (Retry) states reuse rp-state.
- File and folder names are redacted through /api/anonymize-preview.
- Caption counts the open file; "<name> left unredacted" when preserved.
- Phones: the Files button opens the tree as a sheet.
- dev-mock: a jsmith/robust-repair source with tree, file and
  anonymize-preview endpoints and fixtures carrying the mock's terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): reuse the explorer in the anonymize preview

Share the explorer file renderer and tree with the author preview. Apply
preserved-file rules on the server and keep the reviewer rendering and
redaction slots consistent across both views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the reviewer page unchanged by the preview reuse

The preview reuses tree.js and the shared file viewer; its differences now
apply only inside the anonymize preview.

- The narrow-pane rule that hid the file meta and Symbols applied to the
  reviewer page too (visible with a widened sidebar); it is scoped to
  .reviewer-preview. The token capture moves from body to the page that
  holds the preview.
- tree.js: aria-expanded/aria-current and tree-only scrolling apply only
  when the tree is embedded (selectedPath set); the reviewer page keeps
  scrollIntoView and its original markup.
- file-viewer: the extra menu-closing click listener, the #L hash writes and
  the Ctrl+F/G and Alt+Z shortcuts are limited to the preview (shortcuts
  only while its code has focus), so the form keeps the browser's Find.
- Preview: the tree's file list is cached between renders, name batches are
  chunked under the JSON body limit, /api/anonymize-preview accepts up to
  3 MB so files up to the 1 MB preview limit can be redacted, and on phones
  the caption's "<name> left unredacted" opens the dialog (the header tag
  squeezed the path out).
- dev-mock: long and nested names for the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): restore the redaction number on the right and level the slots

The redesign centered the term number in every redaction slot; it sits on
the right edge again, in code, Markdown, the tree and the breadcrumb. Tree,
breadcrumb and paragraph slots also sat about 2px below the text; they now
center on the line's letters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: address preview navigation and path review findings

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thomas DurieuxandClaude Opus 5.5 authored and GitHub committed 2026-11-13 14:27:13 +01:00
1 parent d508ab1754
commit 476f25e264
20 files changed
+1794 -941

No files matched your search

+99 -1
View File
@@ -278,7 +278,7 @@ app.get("/api/user/anonymized_repositories", (req, res) => res.json(repositories
app.get("/api/user/anonymized_pull_requests", (req, res) => res.json(pullRequests));
app.get("/api/user/anonymized_gists", (req, res) => res.json(gists));
app.get("/api/user/default", (req, res) =>
res.json({ terms: ["Jane Smith", "MIT CSAIL"], options: { link: true, image: true, pdf: true, notebook: true, loc: true, page: false, update: false, mode: "GitHubStream", expirationMode: "remove" } })
res.json({ terms: ["Jane Smith", "MIT CSAIL", "jsmith"], options: { link: true, image: true, pdf: true, notebook: true, loc: true, page: false, update: false, mode: "GitHubStream", expirationMode: "remove" } })
);
app.post("/api/user/default", (req, res) => res.json({}));
// Conference review packages (billing enabled), mirroring src/core/billing/config.ts.
@@ -425,6 +425,104 @@ app.get(`/api/repo/${GALLERY_ID}/file/{*rest}`, (req, res) => {
res.status(404).json({ error: "file_not_found" });
});
// Anonymize workspace source: https://github.com/jsmith/robust-repair. The
// files carry the mock's default terms (Jane Smith, MIT CSAIL, jsmith) so the
// preview's redactions, its file viewer and the "Leave files unredacted"
// cases (a docs/ README, a bibliography) can be checked without GitHub.
// Same endpoints as src/server/routes/repository-private.ts.
const SOURCE = { owner: "jsmith", repo: "robust-repair", commit: "5be2a7c1d0e94f3b8a6c2d1e0f9a8b7c6d5e4f3a" };
const sourceText = {
"README.md": "# Robust Repair\n\nCode and data for *Learning Robust Repair Strategies from Developer Histories* by Jane Smith (MIT CSAIL).\n\n![Overview](figures/overview.png)\n\n## Getting started\n\n```bash\ngit clone https://github.com/jsmith/robust-repair\npip install -r requirements.txt\npython src/train.py --config configs/base.yaml\n```\n\nThe full documentation is in [docs/](docs/README.md). Questions: open an issue or write to Jane Smith.\n\n## Layout\n\n| Folder | Content |\n| --- | --- |\n| `src/` | Training and evaluation code |\n| `paper/` | LaTeX sources of the paper |\n| `configs/` | Experiment configurations |\n",
"docs/README.md": "# Documentation\n\nMaintained by Jane Smith at MIT CSAIL. See [usage](usage.md) for the command line.\n",
"docs/usage.md": "# Usage\n\n```bash\npython src/train.py --config configs/base.yaml --seed 1\n```\n\nRuns from the jsmith cluster write to `out/`.\n",
"src/train.py": "# Copyright 2026 Jane Smith, MIT CSAIL\n\"\"\"Train the repair model (contact: jsmith).\"\"\"\nimport argparse\n\nimport yaml\n\nfrom jsmith_utils import load_dataset\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Train the model\")\n parser.add_argument(\"--config\", required=True)\n parser.add_argument(\"--seed\", type=int, default=1)\n args = parser.parse_args()\n with open(args.config) as f:\n config = yaml.safe_load(f)\n dataset = load_dataset(config[\"data\"])\n print(f\"Loaded {len(dataset)} examples for Jane Smith's run\")\n\n\nif __name__ == \"__main__\":\n main()\n",
"src/jsmith-utils/io.js": "// Helpers shared by the evaluation scripts. Author: Jane Smith <jane.smith@csail.mit.edu>\nconst fs = require(\"fs\");\n\n/** Reads one JSON object per line. */\nfunction readJsonLines(path) {\n return fs.readFileSync(path, \"utf8\").split(\"\\n\").filter(Boolean).map((line) => JSON.parse(line));\n}\n\nmodule.exports = { readJsonLines, lab: \"MIT CSAIL\" };\n",
"paper/main.tex": "\\documentclass{article}\n\\title{Learning Robust Repair Strategies from Developer Histories}\n\\author{Jane Smith \\\\ MIT CSAIL}\n\n\\begin{document}\n\\maketitle\n\n\\section{Introduction}\nWe build on our earlier work~\\cite{smith2024repair}, done at MIT CSAIL.\n\n\\bibliographystyle{plain}\n\\bibliography{refs}\n\\end{document}\n",
"paper/refs.bib": "@inproceedings{smith2024repair,\n title = {Repairing Programs with Developer Histories},\n author = {Jane Smith and Ana Lopez},\n booktitle = {Proceedings of ICSE},\n year = {2024},\n note = {MIT CSAIL}\n}\n",
"configs/base.yaml": "# Base configuration (Jane Smith)\ndata: data/train.jsonl\nmodel: transformer-small\nseed: 1\noutput: /home/jsmith/out\n",
"requirements.txt": "torch>=2.3\nnumpy\npyyaml\n",
LICENSE: "MIT License\n\nCopyright (c) 2026 Jane Smith\n",
// Long and nested names for the tree (wrapping, middle truncation, slots).
"experiments/jsmith-ablations/2026-03/robust_repair_baseline_with_pretraining_and_augmentation_seed_1_jsmith_final.yaml": "seed: 1\nowner: jsmith\n",
"experiments/jsmith-ablations/2026-03/notes-for-Jane Smith.md": "# Notes\n\nAblations run by Jane Smith.\n",
};
// figures/overview.png reuses the gallery image; data/train.csv is over the 1 MB preview limit.
const sourceBinary = ["figures/overview.png"];
const sourceTooBig = ["data/train.csv"];
const sourceAccess = (req, res) => {
if (/^[0-9a-f]{6,40}$/i.test(String(req.query.commit || ""))) return true;
res.status(400).json({ error: "invalid_commit_format" });
return false;
};
const SOURCE_BASE = `/api/repo/${SOURCE.owner}/${SOURCE.repo}`;
app.get(SOURCE_BASE + "/", (req, res) => res.json({
id: "mock-source-1", externalId: "gh_910001", repo: SOURCE.repo, owner: SOURCE.owner,
hasPage: false, fullName: `${SOURCE.owner}/${SOURCE.repo}`, defaultBranch: "main", size: 2048,
}));
app.get(SOURCE_BASE + "/branches", (req, res) => res.json([
{ name: "main", commit: SOURCE.commit, readme: sourceText["README.md"] },
{ name: "camera-ready", commit: "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b" },
]));
app.get(SOURCE_BASE + "/readme", (req, res) => res.type("text/plain").send(sourceText["README.md"]));
app.get(SOURCE_BASE + "/tree", (req, res) => {
if (!sourceAccess(req, res)) return;
res.set("Cache-Control", "private, no-store");
res.json({ files: [...Object.keys(sourceText), ...sourceTooBig].sort(), binaryFiles: sourceBinary, truncated: false });
});
app.get(SOURCE_BASE + "/file", (req, res) => {
if (!sourceAccess(req, res)) return;
const file = String(req.query.path || "");
if (!file || file.split("/").some((part) => !part || part === "." || part === "..") || /[\\\0]/.test(file)) {
return res.status(400).json({ error: "invalid_path" });
}
res.set("Cache-Control", "private, no-store");
// MOCK_FILE_DELAY=ms holds the response so the loading state can be captured.
global.setTimeout(() => {
if (sourceTooBig.includes(file)) return res.status(413).json({ error: "file_too_big" });
if (sourceBinary.includes(file)) return res.json({ binary: true, size: galleryPng.length });
if (!(file in sourceText)) return res.status(404).json({ error: "file_not_found" });
res.type("text/plain; charset=utf-8").send(sourceText[file]);
}, Number(process.env.MOCK_FILE_DELAY || 150));
});
// Use the production anonymizer for both sides of the visual comparison.
require("ts-node/register/transpile-only");
const { ContentAnonimizer } = require("../src/core/anonymize-utils");
const { isPreservedPath } = require("../src/core/preserve-files");
const fixtureOptions = { terms: ["Jane Smith", "MIT CSAIL", "jsmith"], image: true, link: true, repoName: "jsmith/robust-repair", branchName: "main", repoId: "robust-repair-preview" };
function anonymizeFixture(text, opt) {
return new ContentAnonimizer({ ...opt, terms: isPreservedPath(opt.filePath, opt.preserveFiles) ? [] : opt.terms }).anonymize(text);
}
app.post("/api/anonymize-preview", express.json({ limit: "4mb" }), (req, res) => {
const body = req.body || {}, opt = body.options || {};
res.set("Cache-Control", "private, no-store");
if (typeof body.content === "string") return res.json({ content: anonymizeFixture(body.content, opt) });
if (Array.isArray(body.contents) && body.contents.every(c => typeof c === "string")) return res.json({ contents: body.contents.map(text => anonymizeFixture(text, opt)) });
res.status(400).json({ error: "missing_content" });
});
// A reviewer repository generated from the exact source fixtures above.
const fixturePath = path => anonymizeFixture(path, { terms: fixtureOptions.terms });
const fixturePaths = [...Object.keys(sourceText), ...sourceBinary, ...sourceTooBig];
app.get("/api/repo/robust-repair-preview/options", (req, res) => res.json({ download: true, truncatedFolders: [], hasSubmodules: false }));
app.get("/api/repo/robust-repair-preview/files/counts", (req, res) => res.json({ "": fixturePaths.length }));
app.get("/api/repo/robust-repair-preview/files", (req, res) => {
const dir = String(req.query.path || ""), entries = new Map();
for (const sourcePath of fixturePaths) {
const name = fixturePath(sourcePath);
if (dir && !name.startsWith(dir + "/")) continue;
const rest = dir ? name.slice(dir.length + 1) : name;
const [first, ...nested] = rest.split("/");
entries.set(first, nested.length ? { name: first, path: dir } : { name: first, path: dir, size: Buffer.byteLength(sourceText[sourcePath] || ""), sha: "fixture" });
}
res.json([...entries.values()]);
});
app.get("/api/repo/robust-repair-preview/file/{*rest}", (req, res) => {
const sourcePath = fixturePaths.find(file => fixturePath(file) === req.params.rest.join("/"));
if (sourceTooBig.includes(sourcePath)) return res.status(413).json({ error: "file_too_big" });
if (sourceBinary.includes(sourcePath)) return res.type("png").send(galleryPng);
if (!sourcePath) return res.status(404).json({ error: "file_not_found" });
res.type("text/plain").send(anonymizeFixture(sourceText[sourcePath], { ...fixtureOptions, filePath: sourcePath }));
});
app.get("/api/repo/:id", (req, res) => {
const r = repositories.find((x) => x.repoId === req.params.id) || repositories[0];
res.json(r);