Skip to content

Commit e8d9b5e

Browse files
ymansurozerclaude
andauthored
refactor: sweep of small review fixes — batched reset, LRU comment cache, drag side, port-safe smokes (#62)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a98745 commit e8d9b5e

11 files changed

Lines changed: 283 additions & 24 deletions

scripts/perf-smoke.mjs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,46 @@ import { tmpdir } from "node:os";
1717
import path from "node:path";
1818
import assert from "node:assert/strict";
1919

20-
const PORT = 6798,
21-
ID = "perf-smoke";
20+
const ID = "perf-smoke";
2221
const CLI = path.join(process.cwd(), "dist", "cli.js");
2322
const UI_BUNDLE = path.join(process.cwd(), "dist", "ui.js");
24-
const BASE = `http://127.0.0.1:${PORT}`;
23+
// Bound lazily from the desk's startup line once it launches — see waitForDeskUrl. The desk
24+
// silently falls back to a random port when a fixed one is taken, so assuming a port here made
25+
// every fetch fail confusingly on a busy machine; we read the port it actually bound instead.
26+
let BASE;
2527
const FILE_COUNT = 1000;
2628
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29+
30+
// The desk prints `Galley <session>: http://127.0.0.1:<port>/` to stderr once it's listening.
31+
// Read that line (accumulating chunks — it can arrive split) and return the origin without the
32+
// trailing slash, so BASE + "/api/..." is well-formed.
33+
const waitForDeskUrl = (child) =>
34+
new Promise((resolve, reject) => {
35+
let buf = "";
36+
const timer = setTimeout(() => {
37+
cleanupListeners();
38+
reject(new Error("timed out waiting for the desk to print its URL"));
39+
}, 30_000);
40+
const onData = (d) => {
41+
buf += d;
42+
const m = buf.match(/http:\/\/127\.0\.0\.1:\d+/);
43+
if (m) {
44+
cleanupListeners();
45+
resolve(m[0]);
46+
}
47+
};
48+
const onExit = () => {
49+
cleanupListeners();
50+
reject(new Error("desk exited before printing its URL"));
51+
};
52+
const cleanupListeners = () => {
53+
clearTimeout(timer);
54+
child.stderr.off("data", onData);
55+
child.off("exit", onExit);
56+
};
57+
child.stderr.on("data", onData);
58+
child.once("exit", onExit);
59+
});
2760
const cli = (...args) => execFileSync("node", [CLI, ...args], { encoding: "utf8" }).trim();
2861
const getText = async (p) => (await fetch(BASE + p)).text();
2962

@@ -101,10 +134,12 @@ try {
101134
writeFileSync(path.join(tmp, "generated-bundle.txt"), bigLines("edited"));
102135

103136
const startedAt = Date.now();
104-
desk = spawn("node", [CLI, "--repo", tmp, "--session", ID, "--port", String(PORT), "--no-open"], {
105-
stdio: "ignore",
137+
desk = spawn("node", [CLI, "--repo", tmp, "--session", ID, "--port", "0", "--no-open"], {
138+
// stderr piped so we can read the bound URL; stdout ignored.
139+
stdio: ["ignore", "ignore", "pipe"],
106140
env: { ...process.env, GALLEY_NO_UPDATE_CHECK: "1" },
107141
});
142+
BASE = await waitForDeskUrl(desk);
108143
let up = false;
109144
for (let i = 0; i < 150; i++) {
110145
try {

scripts/smoke.mjs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,44 @@ import { tmpdir } from "node:os";
99
import path from "node:path";
1010
import assert from "node:assert/strict";
1111

12-
const PORT = 6799,
13-
ID = "smoke";
12+
const ID = "smoke";
1413
const CLI = path.join(process.cwd(), "dist", "cli.js");
15-
const BASE = `http://127.0.0.1:${PORT}`;
14+
// Bound lazily from the desk's startup line once it launches — see waitForDeskUrl. The desk
15+
// silently falls back to a random port when a fixed one is taken, so assuming a port here made
16+
// every fetch fail confusingly on a busy machine; we read the port it actually bound instead.
17+
let BASE;
1618
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19+
20+
// The desk prints `Galley <session>: http://127.0.0.1:<port>/` to stderr once it's listening.
21+
// Read that line (accumulating chunks — it can arrive split) and return the origin without the
22+
// trailing slash, so BASE + "/api/..." is well-formed.
23+
const waitForDeskUrl = (child) =>
24+
new Promise((resolve, reject) => {
25+
let buf = "";
26+
const timer = setTimeout(() => {
27+
cleanupListeners();
28+
reject(new Error("timed out waiting for the desk to print its URL"));
29+
}, 30_000);
30+
const onData = (d) => {
31+
buf += d;
32+
const m = buf.match(/http:\/\/127\.0\.0\.1:\d+/);
33+
if (m) {
34+
cleanupListeners();
35+
resolve(m[0]);
36+
}
37+
};
38+
const onExit = () => {
39+
cleanupListeners();
40+
reject(new Error("desk exited before printing its URL"));
41+
};
42+
const cleanupListeners = () => {
43+
clearTimeout(timer);
44+
child.stderr.off("data", onData);
45+
child.off("exit", onExit);
46+
};
47+
child.stderr.on("data", onData);
48+
child.once("exit", onExit);
49+
});
1750
const cli = (...args) => execFileSync("node", [CLI, ...args], { encoding: "utf8" }).trim();
1851
const getJson = async (p, init) => (await fetch(BASE + p, init)).json();
1952
const post = (p, body) =>
@@ -58,11 +91,13 @@ try {
5891
writeFileSync(path.join(tmp, "huge-approved.txt"), bigLines("edited"));
5992
writeFileSync(path.join(tmp, "huge-rejected.txt"), bigLines("edited"));
6093

61-
desk = spawn("node", [CLI, "--repo", tmp, "--session", ID, "--port", String(PORT), "--no-open"], {
62-
stdio: "ignore",
94+
desk = spawn("node", [CLI, "--repo", tmp, "--session", ID, "--port", "0", "--no-open"], {
95+
// stderr piped so we can read the bound URL; stdout ignored.
96+
stdio: ["ignore", "ignore", "pipe"],
6397
// Keep the smoke hermetic: no update-check network call at desk start.
6498
env: { ...process.env, GALLEY_NO_UPDATE_CHECK: "1" },
6599
});
100+
BASE = await waitForDeskUrl(desk);
66101
for (let i = 0; i < 60; i++) {
67102
try {
68103
await fetch(BASE + "/api/state");

src/server.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,121 @@ test("a throwing wrapped route settles the mutex without poisoning the chain (is
804804
});
805805
});
806806

807+
test("/api/reset unstages every reviewed file in one batched restore and clears the review (issue 13)", async () => {
808+
const root = await mkdtemp(path.join(tmpdir(), "galley-reset-"));
809+
const oldHome = process.env.HOME;
810+
process.env.HOME = root;
811+
const g = (args: string[]) => execFileSync("git", args, { cwd: root }).toString();
812+
g(["init", "-q"]);
813+
g(["config", "user.email", "t@t.co"]);
814+
g(["config", "user.name", "tester"]);
815+
await writeFile(path.join(root, "a.ts"), "one\n");
816+
await writeFile(path.join(root, "b.ts"), "two\n");
817+
g(["add", "."]);
818+
g(["commit", "-qm", "init"]);
819+
// Change both files and stage them, so the index carries two paths for the reset to restore.
820+
await writeFile(path.join(root, "a.ts"), "one CHANGED\n");
821+
await writeFile(path.join(root, "b.ts"), "two CHANGED\n");
822+
const st = await buildReviewState(root, { session: "s" });
823+
assert.ok(st, "built a review state for the working diff");
824+
g(["add", "."]);
825+
assert.deepEqual(
826+
g(["diff", "--cached", "--name-only"]).trim().split("\n").sort(),
827+
["a.ts", "b.ts"],
828+
"both files staged before reset",
829+
);
830+
st!.comments.push({
831+
id: "c1",
832+
path: "a.ts",
833+
side: "additions",
834+
lineNumber: 1,
835+
body: "x",
836+
createdAt: "t",
837+
updatedAt: "t",
838+
status: "open",
839+
role: "user",
840+
});
841+
st!.decisions = [
842+
{
843+
key: "a.ts:k",
844+
status: "accepted",
845+
path: "a.ts",
846+
lineNumber: 1,
847+
side: "additions",
848+
title: "t",
849+
},
850+
];
851+
const handle = await startServer({ state: st!, open: false, idleTimeoutMs: 0 });
852+
try {
853+
const res = await fetch(`${handle.url}api/reset`, { method: "POST" });
854+
assert.equal(res.status, 200);
855+
// A single batched `git restore --staged -- a.ts b.ts` cleared the index for BOTH files.
856+
assert.equal(
857+
g(["diff", "--cached", "--name-only"]).trim(),
858+
"",
859+
"index restored for every file",
860+
);
861+
// …and the reviewer-owned slice is wiped.
862+
assert.deepEqual(st!.comments, []);
863+
assert.deepEqual(st!.decisions, []);
864+
} finally {
865+
handle.server.close();
866+
process.env.HOME = oldHome;
867+
await rm(root, { recursive: true, force: true });
868+
}
869+
});
870+
871+
test("/api/reset in pr mode clears the review without touching the git index (issue 13)", async () => {
872+
const root = await mkdtemp(path.join(tmpdir(), "galley-reset-pr-"));
873+
const oldHome = process.env.HOME;
874+
process.env.HOME = root;
875+
const g = (args: string[]) => execFileSync("git", args, { cwd: root }).toString();
876+
g(["init", "-q"]);
877+
g(["config", "user.email", "t@t.co"]);
878+
g(["config", "user.name", "tester"]);
879+
await writeFile(path.join(root, "a.ts"), "one\n");
880+
g(["add", "."]);
881+
g(["commit", "-qm", "init"]);
882+
// A staged change sitting in the index — if reset spawned git in pr mode, it would vanish.
883+
await writeFile(path.join(root, "a.ts"), "one CHANGED\n");
884+
g(["add", "."]);
885+
const st: ReviewState = {
886+
...state(root),
887+
mode: "pr",
888+
comments: [
889+
{
890+
id: "c1",
891+
path: "a.ts",
892+
side: "additions",
893+
lineNumber: 1,
894+
body: "x",
895+
createdAt: "t",
896+
updatedAt: "t",
897+
status: "open",
898+
role: "user",
899+
},
900+
],
901+
};
902+
const handle = await startServer({ state: st, open: false, idleTimeoutMs: 0 });
903+
try {
904+
const res = await fetch(`${handle.url}api/reset`, { method: "POST" });
905+
assert.equal(res.status, 200);
906+
// PR mode has no working-tree index to restore — the staged change is left exactly as it was
907+
// (staging is disabled in pr mode), proving the route never spawned git here.
908+
assert.equal(
909+
g(["diff", "--cached", "--name-only"]).trim(),
910+
"a.ts",
911+
"index untouched in pr mode",
912+
);
913+
// The reviewer-owned slice is still cleared, git or no git.
914+
assert.deepEqual(st.comments, []);
915+
} finally {
916+
handle.server.close();
917+
process.env.HOME = oldHome;
918+
await rm(root, { recursive: true, force: true });
919+
}
920+
});
921+
807922
test("settings API round-trips editorCommand", async () => {
808923
await withServer(async (handle) => {
809924
await fetch(`${handle.url}api/settings`, {

src/server.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -745,9 +745,13 @@ export async function startServer(options: ServerOptions): Promise<ServerHandle>
745745
}
746746
if (req.method === "POST" && url.pathname === "/api/reset") {
747747
return await serialize(async () => {
748-
for (const file of state.files) {
749-
await git(["restore", "--staged", "--", file.path], state.root).catch(async () =>
750-
git(["reset", "HEAD", "--", file.path], state.root),
748+
// Unstage everything the review touched in one spawn — `git restore --staged` takes many
749+
// pathspecs, so we don't fork per file. PR mode has no working-tree index to restore
750+
// (staging is disabled there), so skip the git work entirely and only clear review state.
751+
if (state.mode !== "pr" && state.files.length) {
752+
const paths = state.files.map((file) => file.path);
753+
await git(["restore", "--staged", "--", ...paths], state.root).catch(async () =>
754+
git(["reset", "HEAD", "--", ...paths], state.root),
751755
);
752756
}
753757
state.comments = [];

src/state.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,11 +1098,16 @@ export async function mergeReviewState(base: ReviewState, saved: ReviewState | n
10981098
// file-level unanchored strip rather than crashing the whole reload.
10991099
const openPaths = new Set(comments.filter((c) => c.status === "open").map((c) => c.path));
11001100
const contentsByPath = new Map<string, FileContents>();
1101-
for (const f of base.files)
1102-
if (openPaths.has(f.path)) {
1103-
const resolved = await readFileContents(base, f).catch(() => undefined);
1104-
if (resolved) contentsByPath.set(f.path, resolved);
1105-
}
1101+
// Read every commented file concurrently — each is an independent `git show` spawn, so awaiting
1102+
// them one at a time serialized the whole set behind the slowest read on every reload.
1103+
await Promise.all(
1104+
base.files
1105+
.filter((f) => openPaths.has(f.path))
1106+
.map(async (f) => {
1107+
const resolved = await readFileContents(base, f).catch(() => undefined);
1108+
if (resolved) contentsByPath.set(f.path, resolved);
1109+
}),
1110+
);
11061111
return {
11071112
...base,
11081113
id: saved.id,

src/ui/decisions.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { S, D, toast, api, persist } from "./store";
2-
import { currentFile, applyDecisionToDiff, fileObjections, flowIndex } from "./changes";
1+
import { S, toast, api, persist } from "./store";
2+
import { currentFile, fileObjections, flowIndex } from "./changes";
33
import { render, deferRender } from "./render";
44
import { nextUnreviewedFileIndex, guideProgress } from "./guide";
55
import type { ChangeState, Decision } from "./types";
@@ -137,7 +137,9 @@ export async function acceptChange(id: string, status: Decision["status"]) {
137137
recordDecision(change, status);
138138
S.state.decisionFiles = S.state.decisionFiles || [];
139139
if (!S.state.decisionFiles.includes(change.path)) S.state.decisionFiles.push(change.path);
140-
if (D.fileDiff) D.fileDiff = applyDecisionToDiff(D.fileDiff, change, status);
140+
// No need to apply the decision to D.fileDiff here: renderCenter unconditionally rebuilds
141+
// D.fileDiff from the raw diff (parse → ensureChanges → replayDecisions) every render, so a
142+
// pre-render mutation is thrown away — the replay below picks the new status up from the record.
141143
toast(status === "rejected" ? "Rejected" : "Accepted");
142144
render();
143145
persist();

src/ui/markdown.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ let md: MarkdownIt | null = null;
2828
// markdown keeps the standard soft-break behavior via `md`, so hard-wrapped prose isn't shredded.
2929
let mdComment: MarkdownIt | null = null;
3030
let hl: Awaited<ReturnType<typeof createHighlighterCore>> | null = null;
31+
// Comment bodies re-render on every poll tick, and each edit mints a fresh id:updatedAt key —
32+
// so the cache would grow without bound (an orphaned entry per edit) if left uncapped. An LRU
33+
// keeps it bounded like the other UI caches (contents.ts, render.ts both cap at 30).
34+
const COMMENT_CACHE_CAP = 30;
3135
const cache = new Map<string, string>();
3236

3337
// Stamp each commentable block-open token with its 1-based source line (1-based
@@ -101,9 +105,18 @@ export function renderMarkdownInline(text: string): string {
101105
export function renderCommentBody(c: ReviewComment): string {
102106
const key = `${c.id}:${c.updatedAt}`;
103107
const cached = cache.get(key);
104-
if (cached !== undefined) return cached;
108+
if (cached !== undefined) {
109+
cache.delete(key); // re-insert → most-recently-used
110+
cache.set(key, cached);
111+
return cached;
112+
}
105113
if (!mdComment) return `<p>${esc(c.body)}</p>`; // not ready yet — don't cache the fallback
106114
const html = DOMPurify.sanitize(mdComment.render(c.body || ""));
107115
cache.set(key, html);
116+
while (cache.size > COMMENT_CACHE_CAP) {
117+
const oldest = cache.keys().next().value;
118+
if (oldest === undefined) break;
119+
cache.delete(oldest);
120+
}
108121
return html;
109122
}

src/ui/render.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ const RENDER_INDICATOR_MIN_LINES = 400;
6969
// otherwise a fast cached re-open skips the badge to avoid an appear-then-vanish flash.
7070
export function deferRender(forceIfBig = false) {
7171
const f = currentFile();
72-
const lc = (s?: string) => (s?.match(/\n/g)?.length ?? 0) + 1;
72+
// Count newlines with a loop rather than s.match(/\n/g): match allocates a full array of every
73+
// newline over what can be a multi-MB file, on every file switch — the count is all we need.
74+
const lc = (s?: string) => {
75+
if (!s) return 1;
76+
let n = 1;
77+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
78+
return n;
79+
};
7380
// Contents now arrive via a per-file fetch (see contents.ts). When they aren't warm yet the
7481
// upcoming render() gates on that fetch, so show the indicator; when they are, decide on size
7582
// as before. peekContents never fetches, so this stays synchronous.

src/ui/selection-derive.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { sideFromLineType } from "./selection-derive.js";
4+
5+
test("a drag ending on a deletion row (unified view) tags the selection deletions", () => {
6+
// Stacked/unified view carries both sides in one column, so geometry can't tell the side —
7+
// @pierre's data-line-type on the row does. A deletion row → "deletions".
8+
assert.equal(sideFromLineType("deletion"), "deletions");
9+
assert.equal(sideFromLineType("change-deletion"), "deletions");
10+
});
11+
12+
test("an addition row tags the selection additions", () => {
13+
assert.equal(sideFromLineType("addition"), "additions");
14+
assert.equal(sideFromLineType("change-addition"), "additions");
15+
});
16+
17+
test("a context or unknown row yields null so the caller can fall back to geometry", () => {
18+
assert.equal(sideFromLineType("context"), null);
19+
assert.equal(sideFromLineType(""), null);
20+
assert.equal(sideFromLineType(null), null);
21+
assert.equal(sideFromLineType(undefined), null);
22+
});

src/ui/selection-derive.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Side } from "./types";
2+
3+
// @pierre tags each rendered row (both its gutter cell and its code cell) with a data-line-type
4+
// — "addition", "deletion", "change-addition", "context", etc. In Stacked (unified) view one
5+
// column carries both sides, so horizontal pointer geometry can't tell which side a dragged row
6+
// belongs to; the row's own type can. Returns the side a data-line-type names, or null when it
7+
// names neither (context / unknown) so the caller can fall back to geometry.
8+
export function sideFromLineType(lineType: string | null | undefined): Side | null {
9+
if (!lineType) return null;
10+
if (lineType.includes("deletion")) return "deletions";
11+
if (lineType.includes("addition")) return "additions";
12+
return null;
13+
}

0 commit comments

Comments
 (0)