diff --git a/packages/cli/drizzle/0003_file_view.sql b/packages/cli/drizzle/0003_file_view.sql new file mode 100644 index 0000000..15be640 --- /dev/null +++ b/packages/cli/drizzle/0003_file_view.sql @@ -0,0 +1,23 @@ +CREATE TABLE `chapter_file_view` ( + `id` text PRIMARY KEY NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + `userId` text DEFAULT 'local' NOT NULL, + `chapterId` text NOT NULL, + `filePath` text NOT NULL, + FOREIGN KEY (`chapterId`) REFERENCES `chapter`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `chapter_file_view_chapter_id_idx` ON `chapter_file_view` (`chapterId`);--> statement-breakpoint +CREATE UNIQUE INDEX `chapter_file_view_user_chapter_path_unique` ON `chapter_file_view` (`userId`,`chapterId`,`filePath`);--> statement-breakpoint +CREATE TABLE `file_view` ( + `id` text PRIMARY KEY NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + `userId` text DEFAULT 'local' NOT NULL, + `runId` text NOT NULL, + `filePath` text NOT NULL, + FOREIGN KEY (`runId`) REFERENCES `chapter_run`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `file_view_user_run_path_unique` ON `file_view` (`userId`,`runId`,`filePath`); \ No newline at end of file diff --git a/packages/cli/drizzle/meta/0003_snapshot.json b/packages/cli/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..59b55ba --- /dev/null +++ b/packages/cli/drizzle/meta/0003_snapshot.json @@ -0,0 +1,605 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "536350ff-7be0-4c61-829c-54a834aaba84", + "prevId": "298158d7-c4df-4a0c-a849-4b8b25cdfb25", + "tables": { + "chapter": { + "name": "chapter", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterIndex": { + "name": "chapterIndex", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hunkRefs": { + "name": "hunkRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyChanges": { + "name": "keyChanges", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "chapter_run_idx_unique": { + "name": "chapter_run_idx_unique", + "columns": [ + "runId", + "chapterIndex" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_runId_chapter_run_id_fk": { + "name": "chapter_runId_chapter_run_id_fk", + "tableFrom": "chapter", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_file_view": { + "name": "chapter_file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_file_view_chapter_id_idx": { + "name": "chapter_file_view_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + }, + "chapter_file_view_user_chapter_path_unique": { + "name": "chapter_file_view_user_chapter_path_unique", + "columns": [ + "userId", + "chapterId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_file_view_chapterId_chapter_id_fk": { + "name": "chapter_file_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_file_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_run": { + "name": "chapter_run", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repoRoot": { + "name": "repoRoot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originUrl": { + "name": "originUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopeKind": { + "name": "scopeKind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workingTreeRef": { + "name": "workingTreeRef", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "baseSha": { + "name": "baseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headSha": { + "name": "headSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mergeBaseSha": { + "name": "mergeBaseSha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generatedAt": { + "name": "generatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_run_created_at_idx": { + "name": "chapter_run_created_at_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chapter_view": { + "name": "chapter_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chapter_view_user_chapter_unique": { + "name": "chapter_view_user_chapter_unique", + "columns": [ + "userId", + "chapterId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "chapter_view_chapterId_chapter_id_fk": { + "name": "chapter_view_chapterId_chapter_id_fk", + "tableFrom": "chapter_view", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_view": { + "name": "file_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "file_view_user_run_path_unique": { + "name": "file_view_user_run_path_unique", + "columns": [ + "userId", + "runId", + "filePath" + ], + "isUnique": true + } + }, + "foreignKeys": { + "file_view_runId_chapter_run_id_fk": { + "name": "file_view_runId_chapter_run_id_fk", + "tableFrom": "file_view", + "tableTo": "chapter_run", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change": { + "name": "key_change", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapterId": { + "name": "chapterId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lineRefs": { + "name": "lineRefs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "key_change_chapter_id_idx": { + "name": "key_change_chapter_id_idx", + "columns": [ + "chapterId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "key_change_chapterId_chapter_id_fk": { + "name": "key_change_chapterId_chapter_id_fk", + "tableFrom": "key_change", + "tableTo": "chapter", + "columnsFrom": [ + "chapterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "key_change_view": { + "name": "key_change_view", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "keyChangeId": { + "name": "keyChangeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "key_change_view_key_change_id_idx": { + "name": "key_change_view_key_change_id_idx", + "columns": [ + "keyChangeId" + ], + "isUnique": false + }, + "key_change_view_user_key_change_unique": { + "name": "key_change_view_user_key_change_unique", + "columns": [ + "userId", + "keyChangeId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "key_change_view_keyChangeId_key_change_id_fk": { + "name": "key_change_view_keyChangeId_key_change_id_fk", + "tableFrom": "key_change_view", + "tableTo": "key_change", + "columnsFrom": [ + "keyChangeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/cli/drizzle/meta/_journal.json b/packages/cli/drizzle/meta/_journal.json index 6c3d864..b8e5421 100644 --- a/packages/cli/drizzle/meta/_journal.json +++ b/packages/cli/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777793684990, "tag": "0002_grey_genesis", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777829566446, + "tag": "0003_file_view", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/cli/src/__tests__/view-state.routes.test.ts b/packages/cli/src/__tests__/view-state.routes.test.ts index 8499948..a3d4dff 100644 --- a/packages/cli/src/__tests__/view-state.routes.test.ts +++ b/packages/cli/src/__tests__/view-state.routes.test.ts @@ -5,7 +5,15 @@ import path from "node:path"; import { eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { closeDb, getDb } from "../db/client.js"; -import { chapter, chapterView, keyChange, keyChangeView } from "../db/schema/index.js"; +import { + chapter, + chapterFileView, + chapterRun, + chapterView, + fileView, + keyChange, + keyChangeView, +} from "../db/schema/index.js"; import { runRoutes } from "../routes/runs.js"; import { viewStateRoutes } from "../routes/view-state.js"; import { insertChaptersFile } from "../runs/import-chapters.js"; @@ -71,6 +79,44 @@ function request(port: number, method: string, requestPath: string): Promise { + const payload = body === undefined ? "" : JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: LOOPBACK_HOST, + port, + method, + path: requestPath, + agent: false, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload).toString(), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + resolve({ + status: res.statusCode ?? 0, + body: text ? JSON.parse(text) : null, + }); + }); + }, + ); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + function seedRun(): { runId: string; chapterUuid: string; @@ -144,6 +190,301 @@ describe("view-state API", () => { expect(second.status).toBe(200); }); + it("POST /api/chapter-view/:chapterId cascades file_view rows for each path in the chapter's hunkRefs", async () => { + // Seed a chapter that touches two files so the cascade has more than one path. + const db = getDb({ dbPath }); + const fixture = makeFixture({ + chapters: [ + { + id: "chapter-multi", + order: 1, + title: "Multi-file chapter", + summary: "Touches two files.", + hunkRefs: [ + { filePath: "src/foo.ts", oldStart: 1 }, + { filePath: "src/foo.ts", oldStart: 50 }, // duplicate path → still one row + { filePath: "src/bar.ts", oldStart: 1 }, + ], + keyChanges: [], + }, + ], + }); + insertChaptersFile(db, fixture, makeRepoContext()); + const [chapterRow] = db.select().from(chapter).limit(1).all(); + if (!chapterRow) throw new Error("seed: missing chapter"); + + const { port } = await startWithRoutes(); + const res = await request(port, "POST", `/api/chapter-view/${chapterRow.id}`); + expect(res.status).toBe(200); + + const fileRows = db.select().from(fileView).where(eq(fileView.runId, chapterRow.runId)).all(); + expect(fileRows.map((r) => r.filePath).sort()).toEqual(["src/bar.ts", "src/foo.ts"]); + + // And the cascade is reflected in GET view-state. + const view = await request(port, "GET", `/api/runs/${chapterRow.runId}/view-state`); + expect((view.body as { filePaths: string[] }).filePaths.sort()).toEqual([ + "src/bar.ts", + "src/foo.ts", + ]); + }); + + it("DELETE /api/chapter-view/:chapterId cascades the unmark to the chapter's files", async () => { + const { chapterUuid, runId } = seedRun(); + const { port } = await startWithRoutes(); + + await request(port, "POST", `/api/chapter-view/${chapterUuid}`); + const db = getDb({ dbPath }); + expect( + db.select().from(fileView).where(eq(fileView.runId, runId)).all().length, + ).toBeGreaterThan(0); + expect( + db.select().from(chapterFileView).where(eq(chapterFileView.chapterId, chapterUuid)).all() + .length, + ).toBeGreaterThan(0); + + await request(port, "DELETE", `/api/chapter-view/${chapterUuid}`); + // Symmetric with POST: unmarking the chapter clears chapter_view, the + // chapter's chapter_file_view rows, and (per rule 4) the global file_view + // rows for every path the unmarked chapter touched. + expect(db.select().from(chapterView).all()).toHaveLength(0); + expect( + db.select().from(chapterFileView).where(eq(chapterFileView.chapterId, chapterUuid)).all(), + ).toHaveLength(0); + expect(db.select().from(fileView).where(eq(fileView.runId, runId)).all()).toHaveLength(0); + }); + + it("DELETE /api/chapter-view/:chapterId scopes the cascade to that chapter's hunkRef paths", async () => { + // File X belongs to chapter A, file Y belongs to chapter B. Marking A + // then unmarking A should clear X but leave Y untouched, even though Y + // was independently file-view'd. + const db = getDb({ dbPath }); + const fixture = makeFixture({ + chapters: [ + { + id: "ch-a", + order: 1, + title: "A", + summary: "", + hunkRefs: [{ filePath: "x.ts", oldStart: 1 }], + keyChanges: [], + }, + { + id: "ch-b", + order: 2, + title: "B", + summary: "", + hunkRefs: [{ filePath: "y.ts", oldStart: 1 }], + keyChanges: [], + }, + ], + }); + insertChaptersFile(db, fixture, makeRepoContext()); + const chapters = db.select().from(chapter).all(); + const chapterA = chapters.find((c) => c.chapterIndex === 1); + const chapterB = chapters.find((c) => c.chapterIndex === 2); + if (!chapterA || !chapterB) throw new Error("seed: missing chapters"); + + const { port } = await startWithRoutes(); + await request(port, "POST", `/api/chapter-view/${chapterA.id}`); + await request(port, "POST", `/api/chapter-view/${chapterB.id}`); + await request(port, "DELETE", `/api/chapter-view/${chapterA.id}`); + + const remaining = db + .select({ filePath: fileView.filePath }) + .from(fileView) + .where(eq(fileView.runId, chapterA.runId)) + .all(); + expect(remaining.map((r) => r.filePath)).toEqual(["y.ts"]); + }); + + it("POST /api/chapter-view/:chapterId only promotes file_view once every chapter containing the file is marked", async () => { + // Two chapters share file `shared.ts`. Marking the first should leave file_view + // empty for it (only one of two containing chapters covered); marking the second + // promotes it. Mirrors hosted's "all chapters covered → file viewed" rule. + const db = getDb({ dbPath }); + const fixture = makeFixture({ + chapters: [ + { + id: "ch-a", + order: 1, + title: "A", + summary: "", + hunkRefs: [ + { filePath: "shared.ts", oldStart: 1 }, + { filePath: "only-a.ts", oldStart: 1 }, + ], + keyChanges: [], + }, + { + id: "ch-b", + order: 2, + title: "B", + summary: "", + hunkRefs: [{ filePath: "shared.ts", oldStart: 50 }], + keyChanges: [], + }, + ], + }); + insertChaptersFile(db, fixture, makeRepoContext()); + const chapters = db.select().from(chapter).all(); + const chapterA = chapters.find((c) => c.chapterIndex === 1); + const chapterB = chapters.find((c) => c.chapterIndex === 2); + if (!chapterA || !chapterB) throw new Error("seed: missing chapters"); + + const { port } = await startWithRoutes(); + + // After marking only A: only-a.ts is fully covered (only A contains it), + // shared.ts is NOT (B still hasn't marked it). + await request(port, "POST", `/api/chapter-view/${chapterA.id}`); + const afterA = db + .select({ filePath: fileView.filePath }) + .from(fileView) + .where(eq(fileView.runId, chapterA.runId)) + .all() + .map((r) => r.filePath) + .sort(); + expect(afterA).toEqual(["only-a.ts"]); + + // chapter_file_view records both chapter A's hunkRef paths even though + // shared.ts hasn't been promoted globally. + const cfvAfterA = db + .select({ chapterId: chapterFileView.chapterId, filePath: chapterFileView.filePath }) + .from(chapterFileView) + .where(eq(chapterFileView.chapterId, chapterA.id)) + .all(); + expect(cfvAfterA.map((r) => r.filePath).sort()).toEqual(["only-a.ts", "shared.ts"]); + + // Marking B closes the coverage for shared.ts → it gets promoted. + await request(port, "POST", `/api/chapter-view/${chapterB.id}`); + const afterB = db + .select({ filePath: fileView.filePath }) + .from(fileView) + .where(eq(fileView.runId, chapterA.runId)) + .all() + .map((r) => r.filePath) + .sort(); + expect(afterB).toEqual(["only-a.ts", "shared.ts"]); + }); + + it("DELETE /api/chapter-view/:chapterId clears file_view for the chapter's files even when other chapters still cover them", async () => { + // File `shared.ts` covered by both chapters. After marking both, + // unmarking either chapter must drop file_view for shared.ts (rule 4), + // even though the other chapter's chapter_file_view row survives so a + // future re-mark can re-promote. + const db = getDb({ dbPath }); + const fixture = makeFixture({ + chapters: [ + { + id: "ch-a", + order: 1, + title: "A", + summary: "", + hunkRefs: [{ filePath: "shared.ts", oldStart: 1 }], + keyChanges: [], + }, + { + id: "ch-b", + order: 2, + title: "B", + summary: "", + hunkRefs: [{ filePath: "shared.ts", oldStart: 50 }], + keyChanges: [], + }, + ], + }); + insertChaptersFile(db, fixture, makeRepoContext()); + const chapters = db.select().from(chapter).all(); + const chapterA = chapters.find((c) => c.chapterIndex === 1); + const chapterB = chapters.find((c) => c.chapterIndex === 2); + if (!chapterA || !chapterB) throw new Error("seed: missing chapters"); + + const { port } = await startWithRoutes(); + await request(port, "POST", `/api/chapter-view/${chapterA.id}`); + await request(port, "POST", `/api/chapter-view/${chapterB.id}`); + // Sanity: shared.ts is fully covered. + expect(db.select().from(fileView).where(eq(fileView.runId, chapterA.runId)).all()).toHaveLength( + 1, + ); + + await request(port, "DELETE", `/api/chapter-view/${chapterA.id}`); + + // file_view cleared for shared.ts even though B's chapter_file_view row stays, + // because rule 4 unmarks every file the unmarked chapter touches unconditionally. + expect(db.select().from(fileView).where(eq(fileView.runId, chapterA.runId)).all()).toHaveLength( + 0, + ); + // B's per-chapter mark for shared.ts survives, and so does B's chapter_view. + const cfvB = db + .select() + .from(chapterFileView) + .where(eq(chapterFileView.chapterId, chapterB.id)) + .all(); + expect(cfvB).toHaveLength(1); + expect(cfvB[0]?.filePath).toBe("shared.ts"); + + // Re-marking A re-promotes shared.ts because A's chapter_file_view row + // returns and B's row is still there → coverage is complete again. + await request(port, "POST", `/api/chapter-view/${chapterA.id}`); + expect(db.select().from(fileView).where(eq(fileView.runId, chapterA.runId)).all()).toHaveLength( + 1, + ); + }); + + it("DELETE /api/runs/:runId/file-views cascades to remove chapter state for that path", async () => { + // Without the cascade, a direct file unmark followed by a chapter mark/unmark + // cycle could resurrect file_view via the coverage rule. The cascade clears + // chapter state across every chapter in the run that touches the path. + const db = getDb({ dbPath }); + const fixture = makeFixture({ + chapters: [ + { + id: "ch-a", + order: 1, + title: "A", + summary: "", + hunkRefs: [{ filePath: "x.ts", oldStart: 1 }], + keyChanges: [], + }, + { + id: "ch-b", + order: 2, + title: "B", + summary: "", + hunkRefs: [{ filePath: "y.ts", oldStart: 1 }], + keyChanges: [], + }, + ], + }); + insertChaptersFile(db, fixture, makeRepoContext()); + const chapters = db.select().from(chapter).all(); + const chapterA = chapters.find((c) => c.chapterIndex === 1); + const chapterB = chapters.find((c) => c.chapterIndex === 2); + if (!chapterA || !chapterB) throw new Error("seed: missing chapters"); + const { port } = await startWithRoutes(); + + await request(port, "POST", `/api/chapter-view/${chapterA.id}`); + await request(port, "POST", `/api/chapter-view/${chapterB.id}`); + expect(db.select().from(chapterView).all()).toHaveLength(2); + + // Direct unmark from the Files tab. + const res = await requestWithBody(port, "DELETE", `/api/runs/${chapterA.runId}/file-views`, { + path: "x.ts", + }); + expect(res.status).toBe(200); + + expect(db.select().from(fileView).where(eq(fileView.filePath, "x.ts")).all()).toHaveLength(0); + expect(db.select().from(fileView).where(eq(fileView.filePath, "y.ts")).all()).toHaveLength(1); + expect( + db.select().from(chapterFileView).where(eq(chapterFileView.filePath, "x.ts")).all(), + ).toHaveLength(0); + expect( + db.select().from(chapterView).where(eq(chapterView.chapterId, chapterA.id)).all(), + ).toHaveLength(0); + expect( + db.select().from(chapterView).where(eq(chapterView.chapterId, chapterB.id)).all(), + ).toHaveLength(1); + }); + it("POST /api/chapter-view/:chapterId returns 404 for unknown chapter (no FK 500)", async () => { const { port } = await startWithRoutes(); const res = await request( @@ -289,6 +630,155 @@ describe("view-state API", () => { expect(db.select().from(keyChangeView).all()).toHaveLength(0); }); + it("POST /api/runs/:runId/file-views inserts a row and is idempotent", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + const first = await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + expect(first.status).toBe(200); + + const second = await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + expect(second.status).toBe(200); + + const db = getDb({ dbPath }); + const rows = db.select().from(fileView).where(eq(fileView.runId, runId)).all(); + expect(rows).toHaveLength(1); + expect(rows[0]?.filePath).toBe("src/foo.ts"); + }); + + it("POST /api/runs/:runId/file-views accepts paths with slashes and dots (no path traversal magic)", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + // File paths are pure identifiers in the view-state table; we don't resolve + // them to disk, so traversal characters are stored verbatim. + const tricky = "deep/../../weird path/with spaces.ts"; + const res = await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: tricky, + }); + expect(res.status).toBe(200); + + const get = await request(port, "GET", `/api/runs/${runId}/view-state`); + expect((get.body as { filePaths: string[] }).filePaths).toEqual([tricky]); + }); + + it("POST /api/runs/:runId/file-views returns 404 for unknown run", async () => { + const { port } = await startWithRoutes(); + const res = await requestWithBody( + port, + "POST", + "/api/runs/00000000-0000-0000-0000-000000000000/file-views", + { path: "src/foo.ts" }, + ); + expect(res.status).toBe(404); + expect((res.body as { error: string }).error).toMatch(/not found/i); + }); + + it("POST /api/runs/:runId/file-views returns 400 for missing or empty path", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + const empty = await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, {}); + expect(empty.status).toBe(400); + + const blank = await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "", + }); + expect(blank.status).toBe(400); + }); + + it("DELETE /api/runs/:runId/file-views removes the row and is idempotent", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + + const first = await requestWithBody(port, "DELETE", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + expect(first.status).toBe(200); + + const db = getDb({ dbPath }); + expect(db.select().from(fileView).where(eq(fileView.runId, runId)).all()).toHaveLength(0); + + const second = await requestWithBody(port, "DELETE", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + expect(second.status).toBe(200); + }); + + it("GET /api/runs/:runId/view-state returns viewed file paths in filePaths", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "packages/web/src/App.tsx", + }); + await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "packages/cli/src/index.ts", + }); + + const res = await request(port, "GET", `/api/runs/${runId}/view-state`); + expect(res.status).toBe(200); + const body = res.body as { filePaths: string[] }; + expect(body.filePaths).toContain("packages/web/src/App.tsx"); + expect(body.filePaths).toContain("packages/cli/src/index.ts"); + }); + + it("GET /api/runs/:runId/view-state isolates filePaths across runs", async () => { + const db = getDb({ dbPath }); + const fixtureA = makeFixture({ + scope: { + kind: "committed", + baseSha: "a".repeat(40), + headSha: "b".repeat(40), + mergeBaseSha: "c".repeat(40), + }, + }); + const fixtureB = makeFixture({ + scope: { + kind: "committed", + baseSha: "d".repeat(40), + headSha: "e".repeat(40), + mergeBaseSha: "f".repeat(40), + }, + }); + const runA = insertChaptersFile(db, fixtureA, makeRepoContext()); + const runB = insertChaptersFile(db, fixtureB, makeRepoContext()); + + const { port } = await startWithRoutes(); + await requestWithBody(port, "POST", `/api/runs/${runA.runId}/file-views`, { + path: "src/a.ts", + }); + + const stateA = await request(port, "GET", `/api/runs/${runA.runId}/view-state`); + const stateB = await request(port, "GET", `/api/runs/${runB.runId}/view-state`); + expect((stateA.body as { filePaths: string[] }).filePaths).toEqual(["src/a.ts"]); + expect((stateB.body as { filePaths: string[] }).filePaths).toEqual([]); + }); + + it("cascade: deleting a run removes its file_view rows", async () => { + const { runId } = seedRun(); + const { port } = await startWithRoutes(); + + await requestWithBody(port, "POST", `/api/runs/${runId}/file-views`, { + path: "src/foo.ts", + }); + + const db = getDb({ dbPath }); + expect(db.select().from(fileView).all()).toHaveLength(1); + + // File views are scoped to chapter_run; deleting the run cascades them. + db.delete(chapterRun).where(eq(chapterRun.id, runId)).run(); + expect(db.select().from(fileView).all()).toHaveLength(0); + }); + it("POST via external_id fans out across re-imports of the same scope (view-state survives regeneration)", async () => { // Importing twice with identical scope creates two chapter rows sharing one externalId. // POST to that externalId must mark both runs viewed; otherwise GET on whichever run diff --git a/packages/cli/src/db/schema/chapter-file-view.ts b/packages/cli/src/db/schema/chapter-file-view.ts new file mode 100644 index 0000000..6e4fa58 --- /dev/null +++ b/packages/cli/src/db/schema/chapter-file-view.ts @@ -0,0 +1,34 @@ +import { index, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { LOCAL_USER_ID } from "../local-user.js"; +import { chapter } from "./chapter.js"; +import { baseColumns } from "./columns.js"; + +/** + * Per-(chapter, file) viewed mark. The global `file_view` row for a path is + * only set once every chapter in the run touching that path has a row here, + * so a file shared across chapters stays unviewed until each one marks it. + */ +export const chapterFileView = sqliteTable( + "chapter_file_view", + { + ...baseColumns(), + userId: text().notNull().default(LOCAL_USER_ID), + chapterId: text() + .notNull() + .references(() => chapter.id, { onDelete: "cascade" }), + filePath: text().notNull(), + }, + (table) => [ + unique("chapter_file_view_user_chapter_path_unique").on( + table.userId, + table.chapterId, + table.filePath, + ), + // Chapter unmark bulk-deletes by chapterId; the unique above only helps + // (userId, chapterId, filePath) lookups, not chapterId alone. + index("chapter_file_view_chapter_id_idx").on(table.chapterId), + ], +); + +export type ChapterFileViewRow = typeof chapterFileView.$inferSelect; +export type ChapterFileViewInsert = typeof chapterFileView.$inferInsert; diff --git a/packages/cli/src/db/schema/file-view.ts b/packages/cli/src/db/schema/file-view.ts new file mode 100644 index 0000000..b19c10f --- /dev/null +++ b/packages/cli/src/db/schema/file-view.ts @@ -0,0 +1,22 @@ +import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { LOCAL_USER_ID } from "../local-user.js"; +import { chapterRun } from "./chapter-run.js"; +import { baseColumns } from "./columns.js"; + +export const fileView = sqliteTable( + "file_view", + { + ...baseColumns(), + userId: text().notNull().default(LOCAL_USER_ID), + runId: text() + .notNull() + .references(() => chapterRun.id, { onDelete: "cascade" }), + filePath: text().notNull(), + }, + (table) => [ + unique("file_view_user_run_path_unique").on(table.userId, table.runId, table.filePath), + ], +); + +export type FileViewRow = typeof fileView.$inferSelect; +export type FileViewInsert = typeof fileView.$inferInsert; diff --git a/packages/cli/src/db/schema/index.ts b/packages/cli/src/db/schema/index.ts index 08fc64e..8872e14 100644 --- a/packages/cli/src/db/schema/index.ts +++ b/packages/cli/src/db/schema/index.ts @@ -1,5 +1,7 @@ export * from "./chapter.js"; +export * from "./chapter-file-view.js"; export * from "./chapter-run.js"; export * from "./chapter-view.js"; +export * from "./file-view.js"; export * from "./key-change.js"; export * from "./key-change-view.js"; diff --git a/packages/cli/src/routes/json.ts b/packages/cli/src/routes/json.ts index 348aa1e..883d689 100644 --- a/packages/cli/src/routes/json.ts +++ b/packages/cli/src/routes/json.ts @@ -1,6 +1,24 @@ -import type { ServerResponse } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; export function writeJson(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(body)); } + +const MAX_JSON_BODY_BYTES = 1024 * 1024; + +export async function readJsonBody(req: IncomingMessage): Promise { + let total = 0; + const chunks: Buffer[] = []; + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > MAX_JSON_BODY_BYTES) { + throw new Error(`Request body exceeds ${MAX_JSON_BODY_BYTES} bytes`); + } + chunks.push(buf); + } + const text = Buffer.concat(chunks).toString("utf8"); + if (text.length === 0) return {}; + return JSON.parse(text); +} diff --git a/packages/cli/src/routes/view-state.ts b/packages/cli/src/routes/view-state.ts index bcb3398..aeaa3c3 100644 --- a/packages/cli/src/routes/view-state.ts +++ b/packages/cli/src/routes/view-state.ts @@ -1,9 +1,20 @@ +import { FileViewBodySchema } from "@stage-cli/types/view-state"; import { and, eq, inArray } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; import { LOCAL_USER_ID } from "../db/local-user.js"; -import { chapter, chapterRun, chapterView, keyChange, keyChangeView } from "../db/schema/index.js"; +import { + chapter, + chapterFileView, + chapterRun, + chapterView, + fileView, + keyChange, + keyChangeView, +} from "../db/schema/index.js"; import type { Route } from "../server.js"; -import { writeJson } from "./json.js"; +import { readJsonBody, writeJson } from "./json.js"; + +type Tx = Parameters[0]>[0]; export function viewStateRoutes(db: StageDb): Route[] { return [ @@ -11,17 +22,30 @@ export function viewStateRoutes(db: StageDb): Route[] { method: "POST", pattern: "/api/chapter-view/:chapterId", handler: (_req, res, params) => { - const ids = resolveChapterIds(db, params.chapterId); - if (ids.length === 0) { + const rows = resolveChapterRows(db, params.chapterId); + if (rows.length === 0) { writeJson(res, 404, { error: `Chapter ${params.chapterId} not found` }); return; } - // Fan out across every chapter row sharing this externalId so view-state survives - // re-imports of the same diff (PLA-117). For a uuid param this collapses to one row. - db.insert(chapterView) - .values(ids.map((id) => ({ userId: LOCAL_USER_ID, chapterId: id }))) - .onConflictDoNothing() - .run(); + // External-id fan-out: re-imports of the same diff produce multiple chapter + // rows sharing one externalId, and view-state must survive across them. + db.transaction((tx) => { + tx.insert(chapterView) + .values(rows.map((r) => ({ userId: LOCAL_USER_ID, chapterId: r.id }))) + .onConflictDoNothing() + .run(); + + // file_view is only promoted once every chapter in the run touching a + // path has a chapter_file_view row for it — see promoteFullyCoveredFiles. + const cfvInserts = chapterFileViewInserts(rows); + if (cfvInserts.length === 0) { + writeJson(res, 200, {}); + return; + } + tx.insert(chapterFileView).values(cfvInserts).onConflictDoNothing().run(); + + promoteFullyCoveredFiles(tx, touchedRunPaths(rows)); + }); writeJson(res, 200, {}); }, }, @@ -29,16 +53,49 @@ export function viewStateRoutes(db: StageDb): Route[] { method: "DELETE", pattern: "/api/chapter-view/:chapterId", handler: (_req, res, params) => { - const ids = resolveChapterIds(db, params.chapterId); - if (ids.length === 0) { + const rows = resolveChapterRows(db, params.chapterId); + if (rows.length === 0) { // Idempotent: if the chapter doesn't exist there's nothing to delete. The SPA // shouldn't have to distinguish "row was gone" from "chapter was gone". writeJson(res, 200, {}); return; } - db.delete(chapterView) - .where(and(eq(chapterView.userId, LOCAL_USER_ID), inArray(chapterView.chapterId, ids))) - .run(); + db.transaction((tx) => { + const chapterIds = rows.map((r) => r.id); + tx.delete(chapterView) + .where( + and( + eq(chapterView.userId, LOCAL_USER_ID), + inArray(chapterView.chapterId, chapterIds), + ), + ) + .run(); + tx.delete(chapterFileView) + .where( + and( + eq(chapterFileView.userId, LOCAL_USER_ID), + inArray(chapterFileView.chapterId, chapterIds), + ), + ) + .run(); + + // Unconditional file_view clear for every path the unmarked chapter + // touched, even if other chapters still cover the path. A future mark + // on any covering chapter re-promotes via promoteFullyCoveredFiles. + const touched = touchedRunPaths(rows); + if (touched.length === 0) return; + const runIds = Array.from(new Set(touched.map((t) => t.runId))); + const filePaths = Array.from(new Set(touched.map((t) => t.filePath))); + tx.delete(fileView) + .where( + and( + eq(fileView.userId, LOCAL_USER_ID), + inArray(fileView.runId, runIds), + inArray(fileView.filePath, filePaths), + ), + ) + .run(); + }); writeJson(res, 200, {}); }, }, @@ -75,6 +132,86 @@ export function viewStateRoutes(db: StageDb): Route[] { writeJson(res, 200, {}); }, }, + // File-view endpoints carry the path in the body so `/` separators don't + // have to be URL-encoded into route segments. + { + method: "POST", + pattern: "/api/runs/:runId/file-views", + handler: async (req, res, params) => { + const runId = params.runId; + if (!runId) { + writeJson(res, 400, { error: "Missing runId" }); + return; + } + if (!runExists(db, runId)) { + writeJson(res, 404, { error: `Run ${runId} not found` }); + return; + } + + const parsed = await parseFileViewBody(req, res); + if (!parsed) return; + + // Direct file mark deliberately doesn't backfill chapter_file_view — the + // intent is "I've reviewed this file", not "every chapter covers it". + db.insert(fileView) + .values({ userId: LOCAL_USER_ID, runId, filePath: parsed.path }) + .onConflictDoNothing() + .run(); + writeJson(res, 200, {}); + }, + }, + { + method: "DELETE", + pattern: "/api/runs/:runId/file-views", + handler: async (req, res, params) => { + const runId = params.runId; + if (!runId) { + writeJson(res, 400, { error: "Missing runId" }); + return; + } + if (!runExists(db, runId)) { + writeJson(res, 200, {}); + return; + } + + const parsed = await parseFileViewBody(req, res); + if (!parsed) return; + + // Cascade to chapter state too, matching hosted's file-unview behavior. + db.transaction((tx) => { + tx.delete(fileView) + .where( + and( + eq(fileView.userId, LOCAL_USER_ID), + eq(fileView.runId, runId), + eq(fileView.filePath, parsed.path), + ), + ) + .run(); + + const affectedChapterIds = chaptersContainingFile(tx, runId, parsed.path); + if (affectedChapterIds.length === 0) return; + tx.delete(chapterFileView) + .where( + and( + eq(chapterFileView.userId, LOCAL_USER_ID), + eq(chapterFileView.filePath, parsed.path), + inArray(chapterFileView.chapterId, affectedChapterIds), + ), + ) + .run(); + tx.delete(chapterView) + .where( + and( + eq(chapterView.userId, LOCAL_USER_ID), + inArray(chapterView.chapterId, affectedChapterIds), + ), + ) + .run(); + }); + writeJson(res, 200, {}); + }, + }, { method: "GET", pattern: "/api/runs/:runId/view-state", @@ -90,8 +227,8 @@ export function viewStateRoutes(db: StageDb): Route[] { return; } - // Returning external_id (not the uuid PK) is what makes view-state survive content - // regenerations — see PLA-116 for the externalId derivation. + // Returning external_id (not the uuid PK) is what makes view-state + // survive content regenerations. const viewedChapters = db .select({ externalId: chapter.externalId }) .from(chapterView) @@ -107,31 +244,172 @@ export function viewStateRoutes(db: StageDb): Route[] { .where(and(eq(keyChangeView.userId, LOCAL_USER_ID), eq(chapter.runId, runId))) .all(); + const viewedFiles = db + .select({ filePath: fileView.filePath }) + .from(fileView) + .where(and(eq(fileView.userId, LOCAL_USER_ID), eq(fileView.runId, runId))) + .all(); + writeJson(res, 200, { chapterIds: viewedChapters.map((r) => r.externalId), keyChangeIds: checkedKeyChanges.map((r) => r.externalId), + filePaths: viewedFiles.map((r) => r.filePath), }); }, }, ]; } -// Returns every chapter row matching the param: a singleton when given a uuid, or every -// chapter sharing an externalId (re-imports of the same scope). Empty array means 404. -function resolveChapterIds(db: StageDb, idOrExternalId: string | undefined): string[] { +interface ResolvedChapterRow { + id: string; + runId: string; + hunkRefs: typeof chapter.$inferSelect.hunkRefs; +} + +// Looks up by uuid first, falling back to externalId so re-imports of the same +// scope (which share an externalId across chapter rows) all get the cascade. +function resolveChapterRows(db: StageDb, idOrExternalId: string | undefined): ResolvedChapterRow[] { if (!idOrExternalId) return []; - const byPk = db - .select({ id: chapter.id }) + const cols = { id: chapter.id, runId: chapter.runId, hunkRefs: chapter.hunkRefs }; + const byPk = db.select(cols).from(chapter).where(eq(chapter.id, idOrExternalId)).all(); + if (byPk.length > 0) return byPk; + return db.select(cols).from(chapter).where(eq(chapter.externalId, idOrExternalId)).all(); +} + +function chapterFileViewInserts( + rows: ResolvedChapterRow[], +): Array<{ userId: string; chapterId: string; filePath: string }> { + const out: Array<{ userId: string; chapterId: string; filePath: string }> = []; + for (const row of rows) { + const seen = new Set(); + for (const ref of row.hunkRefs) { + if (seen.has(ref.filePath)) continue; + seen.add(ref.filePath); + out.push({ userId: LOCAL_USER_ID, chapterId: row.id, filePath: ref.filePath }); + } + } + return out; +} + +interface RunPath { + runId: string; + filePath: string; +} + +function touchedRunPaths(rows: ResolvedChapterRow[]): RunPath[] { + const seen = new Set(); + const out: RunPath[] = []; + for (const row of rows) { + for (const ref of row.hunkRefs) { + const key = `${row.runId} ${ref.filePath}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ runId: row.runId, filePath: ref.filePath }); + } + } + return out; +} + +/** + * Promotes file_view for each touched (runId, filePath) iff every chapter in + * the run whose hunkRefs contain that path has a chapter_file_view row for it. + */ +function promoteFullyCoveredFiles(tx: Tx, touched: RunPath[]): void { + if (touched.length === 0) return; + const runIds = Array.from(new Set(touched.map((t) => t.runId))); + const paths = Array.from(new Set(touched.map((t) => t.filePath))); + + // hunkRefs is JSON-stored, so we filter in JS. Bounded by the chapter count + // of the affected runs (typically a few dozen). + const allChapters = tx + .select({ id: chapter.id, runId: chapter.runId, hunkRefs: chapter.hunkRefs }) .from(chapter) - .where(eq(chapter.id, idOrExternalId)) + .where(inArray(chapter.runId, runIds)) .all(); - if (byPk.length > 0) return byPk.map((r) => r.id); - return db - .select({ id: chapter.id }) + + const containing = countChaptersPerPath(allChapters, runIds, paths); + + const markedRows = tx + .select({ + chapterId: chapterFileView.chapterId, + runId: chapter.runId, + filePath: chapterFileView.filePath, + }) + .from(chapterFileView) + .innerJoin(chapter, eq(chapter.id, chapterFileView.chapterId)) + .where( + and( + eq(chapterFileView.userId, LOCAL_USER_ID), + inArray(chapter.runId, runIds), + inArray(chapterFileView.filePath, paths), + ), + ) + .all(); + const marked = countChaptersFromRows(markedRows); + + // `marked` is always a subset of `containing` (chapter_file_view rows are + // only inserted for files in the chapter's own hunkRefs), so size equality + // is enough to detect full coverage. + const inserts: Array<{ userId: string; runId: string; filePath: string }> = []; + for (const t of touched) { + const have = marked.get(t.runId)?.get(t.filePath) ?? 0; + const need = containing.get(t.runId)?.get(t.filePath) ?? 0; + if (need > 0 && have === need) { + inserts.push({ userId: LOCAL_USER_ID, runId: t.runId, filePath: t.filePath }); + } + } + if (inserts.length > 0) { + tx.insert(fileView).values(inserts).onConflictDoNothing().run(); + } +} + +type CountMap = Map>; + +function bumpCount(map: CountMap, runId: string, filePath: string): void { + let inner = map.get(runId); + if (!inner) { + inner = new Map(); + map.set(runId, inner); + } + inner.set(filePath, (inner.get(filePath) ?? 0) + 1); +} + +function countChaptersPerPath( + rows: Array<{ id: string; runId: string; hunkRefs: ResolvedChapterRow["hunkRefs"] }>, + runIds: string[], + paths: string[], +): CountMap { + const runIdSet = new Set(runIds); + const pathSet = new Set(paths); + const out: CountMap = new Map(); + for (const row of rows) { + if (!runIdSet.has(row.runId)) continue; + const seen = new Set(); + for (const ref of row.hunkRefs) { + if (!pathSet.has(ref.filePath) || seen.has(ref.filePath)) continue; + seen.add(ref.filePath); + bumpCount(out, row.runId, ref.filePath); + } + } + return out; +} + +function countChaptersFromRows( + rows: Array<{ chapterId: string; runId: string; filePath: string }>, +): CountMap { + const out: CountMap = new Map(); + for (const row of rows) bumpCount(out, row.runId, row.filePath); + return out; +} + +function chaptersContainingFile(tx: Tx, runId: string, filePath: string): string[] { + return tx + .select({ id: chapter.id, hunkRefs: chapter.hunkRefs }) .from(chapter) - .where(eq(chapter.externalId, idOrExternalId)) + .where(eq(chapter.runId, runId)) .all() - .map((r) => r.id); + .filter((row) => row.hunkRefs.some((ref) => ref.filePath === filePath)) + .map((row) => row.id); } function resolveKeyChangeIds(db: StageDb, idOrExternalId: string | undefined): string[] { @@ -149,3 +427,32 @@ function resolveKeyChangeIds(db: StageDb, idOrExternalId: string | undefined): s .all() .map((r) => r.id); } + +function runExists(db: StageDb, runId: string): boolean { + const rows = db + .select({ id: chapterRun.id }) + .from(chapterRun) + .where(eq(chapterRun.id, runId)) + .limit(1) + .all(); + return rows.length > 0; +} + +async function parseFileViewBody( + req: Parameters[0], + res: Parameters[1], +): Promise<{ path: string } | null> { + let raw: unknown; + try { + raw = await readJsonBody(req); + } catch (err) { + writeJson(res, 400, { error: err instanceof Error ? err.message : "Invalid JSON body" }); + return null; + } + const parsed = FileViewBodySchema.safeParse(raw); + if (!parsed.success) { + writeJson(res, 400, { error: "Invalid file-view body: missing or empty `path`" }); + return null; + } + return parsed.data; +} diff --git a/packages/types/src/view-state.ts b/packages/types/src/view-state.ts index 0a70f86..2de6b75 100644 --- a/packages/types/src/view-state.ts +++ b/packages/types/src/view-state.ts @@ -3,5 +3,11 @@ import { z } from "zod"; export const ViewStateSchema = z.object({ chapterIds: z.array(z.string()), keyChangeIds: z.array(z.string()), + filePaths: z.array(z.string()), }); export type ViewState = z.infer; + +export const FileViewBodySchema = z.object({ + path: z.string().min(1), +}); +export type FileViewBody = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index 4485534..32ef9af 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@stage-cli/types": "workspace:*", + "@pierre/diffs": "^1.0.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -19,8 +19,8 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "@stage-cli/types": "workspace:*", "@tanstack/react-query": "^5.100.7", - "@pierre/diffs": "^1.0.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -28,6 +28,7 @@ "react-dom": "^19.2.3", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.18", + "tslib": "^2.8.1", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" }, diff --git a/packages/web/src/components/files/collapsible-picker.tsx b/packages/web/src/components/files/collapsible-picker.tsx new file mode 100644 index 0000000..f046bbd --- /dev/null +++ b/packages/web/src/components/files/collapsible-picker.tsx @@ -0,0 +1,117 @@ +import type { LucideIcon } from "lucide-react"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface CollapsiblePickerProps { + icon: LucideIcon; + title: string; + count: number; + collapsedIndicators: ReactNode; + headerExtra?: ReactNode; + children: ReactNode; + className?: string; + zIndex?: number; + defaultExpanded?: boolean; +} + +export function CollapsiblePicker({ + icon: Icon, + title, + count, + collapsedIndicators, + headerExtra, + children, + className, + zIndex = 30, + defaultExpanded = true, +}: CollapsiblePickerProps) { + const [isCollapsed, setIsCollapsed] = useState(!defaultExpanded); + + useEffect(() => { + const mql = window.matchMedia("(max-width: 768px)"); + const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { + if (e.matches) setIsCollapsed(true); + }; + handleChange(mql); + mql.addEventListener("change", handleChange); + return () => mql.removeEventListener("change", handleChange); + }, []); + + const toggleCollapsed = useCallback(() => setIsCollapsed((prev) => !prev), []); + + const header = ( +
+
+
+ {headerExtra} +
+ ); + + const listContent = ( +
{children}
+ ); + + if (isCollapsed) { + return ( +
+ + + {/* Clip wrapper hides the slid-left panel until the strip is hovered. */} +
+ +
+
+ ); + } + + return ( + + ); +} diff --git a/packages/web/src/components/files/file-diff-list.tsx b/packages/web/src/components/files/file-diff-list.tsx new file mode 100644 index 0000000..34527a0 --- /dev/null +++ b/packages/web/src/components/files/file-diff-list.tsx @@ -0,0 +1,118 @@ +import { FileCode } from "lucide-react"; +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import { FileHeader } from "@/components/chapter/file-header"; +import { PierreDiffViewer } from "@/components/chapter/pierre-diff-viewer"; +import type { FileDiffEntry } from "@/lib/parse-diff"; + +export interface FileDiffListHandle { + scrollToFile: (filePath: string) => void; +} + +export interface CollapseState { + collapsedFiles: ReadonlySet; + toggleFileCollapsed: (filePath: string) => void; + collapseAllFiles: () => void; + expandAllFiles: () => void; +} + +interface FileDiffListProps { + entries: FileDiffEntry[]; + emptyMessage: string; + viewedPathSet?: ReadonlySet; + onToggleViewed?: (path: string) => void; + collapseState: CollapseState; +} + +const FILE_TOP_PADDING = 16; + +export const FileDiffList = forwardRef(function FileDiffList( + { entries, emptyMessage, viewedPathSet, onToggleViewed, collapseState }, + ref, +) { + useImperativeHandle( + ref, + () => ({ + scrollToFile(filePath: string) { + const el = document.getElementById(`file-${filePath}`); + if (!el) return; + const stickyOffset = parseFloat( + getComputedStyle(el).getPropertyValue("--content-top") || "0", + ); + const top = + el.getBoundingClientRect().top + window.scrollY - stickyOffset - FILE_TOP_PADDING; + window.scrollTo({ top }); + }, + }), + [], + ); + + if (entries.length === 0) { + return ( +
+
+ ); + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +}); + +interface FileDiffSectionProps { + entry: FileDiffEntry; + isViewed: boolean; + onToggleViewed?: (path: string) => void; + collapseState: CollapseState; +} + +const noop = () => {}; + +function FileDiffSection({ entry, isViewed, onToggleViewed, collapseState }: FileDiffSectionProps) { + const { file, diff } = entry; + const isCollapsed = collapseState.collapsedFiles.has(file.path); + const [isExpanded, setIsExpanded] = useState(false); + + const handleToggle = useCallback( + () => collapseState.toggleFileCollapsed(file.path), + [collapseState, file.path], + ); + const handleToggleAll = useCallback( + () => (isCollapsed ? collapseState.expandAllFiles() : collapseState.collapseAllFiles()), + [isCollapsed, collapseState], + ); + const handleToggleExpand = useCallback(() => setIsExpanded((v) => !v), []); + const handleToggleViewed = useCallback(() => { + onToggleViewed?.(file.path); + }, [onToggleViewed, file.path]); + + return ( +
+ + {!isCollapsed && ( + + )} +
+ ); +} diff --git a/packages/web/src/components/files/file-picker.tsx b/packages/web/src/components/files/file-picker.tsx new file mode 100644 index 0000000..3f3ab9c --- /dev/null +++ b/packages/web/src/components/files/file-picker.tsx @@ -0,0 +1,240 @@ +import { ChevronRight, CircleCheck, FileText, Folder, Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { FILE_STATUS, type PullRequestFile } from "@/lib/diff-types"; +import { FILE_STATUS_ICONS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status"; +import { buildFileTree, collapseEmptyFolders, type FileNode, sortFileTree } from "@/lib/file-tree"; +import { cn } from "@/lib/utils"; +import { CollapsiblePicker } from "./collapsible-picker"; + +interface FilePickerProps { + files: PullRequestFile[]; + activeFilePath?: string; + viewedPathSet?: ReadonlySet; + onSelectFile?: (filePath: string) => void; + className?: string; +} + +export function FilePicker({ + files, + activeFilePath, + viewedPathSet, + onSelectFile, + className, +}: FilePickerProps) { + const [filter, setFilter] = useState(""); + + const tree = useMemo(() => collapseEmptyFolders(sortFileTree(buildFileTree(files))), [files]); + + const filteredTree = useMemo(() => { + if (!filter) return tree; + const lower = filter.toLowerCase(); + function filterNode(node: FileNode): FileNode | null { + const matchesSelf = node.path.toLowerCase().includes(lower); + const filteredChildren = new Map(); + for (const [name, child] of node.children) { + const filteredChild = filterNode(child); + if (filteredChild) filteredChildren.set(name, filteredChild); + } + if (matchesSelf || filteredChildren.size > 0) { + return { ...node, children: filteredChildren }; + } + return null; + } + return filterNode(tree) ?? { ...tree, children: new Map() }; + }, [tree, filter]); + + const rootChildren = useMemo(() => Array.from(filteredTree.children.values()), [filteredTree]); + + const filterInput = ( +
+
+ ); + + return ( + ( +
+ ))} + > + {rootChildren.length > 0 ? ( +
+ {rootChildren.map((node) => ( + + ))} +
+ ) : ( +

No files found

+ )} + + ); +} + +interface FilePickerTreeItemProps { + node: FileNode; + depth: number; + activeFilePath?: string; + viewedPathSet?: ReadonlySet; + onSelectFile?: (filePath: string) => void; + filter: string; +} + +function FilePickerTreeItem({ + node, + depth, + activeFilePath, + viewedPathSet, + onSelectFile, + filter, +}: FilePickerTreeItemProps) { + const [isExpanded, setIsExpanded] = useState(true); + const itemRef = useRef(null); + const isActive = node.file?.path === activeFilePath; + + useEffect(() => { + if (isActive && itemRef.current) { + itemRef.current.scrollIntoView({ block: "nearest" }); + } + }, [isActive]); + + useEffect(() => { + if (filter) { + setIsExpanded(true); + return; + } + if (activeFilePath && hasActiveDescendant(node, activeFilePath)) { + setIsExpanded(true); + } + }, [activeFilePath, node, filter]); + + const children = useMemo(() => Array.from(node.children.values()), [node.children]); + + if (node.type === "file" && node.file) { + const file = node.file; + const isModified = file.status === FILE_STATUS.MODIFIED; + const StatusIcon = FILE_STATUS_ICONS[file.status]; + const isViewed = viewedPathSet?.has(file.path) ?? false; + return ( + + ); + } + + return ( +
+ + {isExpanded && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +function hasActiveDescendant(node: FileNode, activeFilePath: string): boolean { + if (node.file?.path === activeFilePath) return true; + for (const child of node.children.values()) { + if (hasActiveDescendant(child, activeFilePath)) return true; + } + return false; +} diff --git a/packages/web/src/components/files/index.ts b/packages/web/src/components/files/index.ts new file mode 100644 index 0000000..c1beab7 --- /dev/null +++ b/packages/web/src/components/files/index.ts @@ -0,0 +1,7 @@ +export { + type CollapseState, + FileDiffList, + type FileDiffListHandle, +} from "./file-diff-list"; +export { FilePicker } from "./file-picker"; +export { SidebarLayout } from "./sidebar-layout"; diff --git a/packages/web/src/components/files/sidebar-layout.tsx b/packages/web/src/components/files/sidebar-layout.tsx new file mode 100644 index 0000000..703d0b8 --- /dev/null +++ b/packages/web/src/components/files/sidebar-layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface SidebarLayoutProps { + children: ReactNode; + sidebar: ReactNode; + className?: string; +} + +/** + * The sidebar pulls left to align with the page edge (counter to the route's + * `px-6 lg:px-8`); the main column needs `min-w-0` so its children can + * overflow within the column instead of pushing it wider. + */ +export function SidebarLayout({ children, sidebar, className }: SidebarLayoutProps) { + return ( +
+
+
+
{sidebar}
+
{children}
+
+
+ ); +} diff --git a/packages/web/src/lib/__tests__/file-tree.test.ts b/packages/web/src/lib/__tests__/file-tree.test.ts new file mode 100644 index 0000000..05212c5 --- /dev/null +++ b/packages/web/src/lib/__tests__/file-tree.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { FILE_STATUS, type PullRequestFile } from "../diff-types"; +import { + buildFileTree, + collapseEmptyFolders, + type FileNode, + flattenFileTree, + sortFileTree, +} from "../file-tree"; + +function makeFile(path: string): PullRequestFile { + const filename = path.split("/").pop(); + if (!filename) throw new Error("Expected file path to include a filename"); + return { + path, + filename, + status: FILE_STATUS.MODIFIED, + additions: 1, + deletions: 0, + hunks: [], + }; +} + +function getChildNames(node: FileNode): string[] { + return Array.from(sortFileTree(node).children.values()).map((c) => c.name); +} + +function firstChild(node: FileNode): FileNode { + const first = Array.from(node.children.values())[0]; + if (!first) throw new Error("Expected at least one child"); + return first; +} + +describe("collapseEmptyFolders", () => { + it("collapses single-child folder chains", () => { + const tree = buildFileTree([makeFile("apps/web/src/app/page.tsx")]); + const collapsed = collapseEmptyFolders(tree); + + expect(collapsed.children.size).toBe(1); + const folder = firstChild(collapsed); + expect(folder.name).toBe("apps/web/src/app"); + expect(folder.children.size).toBe(1); + const file = firstChild(folder); + expect(file.name).toBe("page.tsx"); + }); + + it("does not collapse folders with multiple children", () => { + const tree = buildFileTree([makeFile("src/a.ts"), makeFile("src/b.ts")]); + const collapsed = collapseEmptyFolders(tree); + + expect(collapsed.children.size).toBe(1); + const srcFolder = firstChild(collapsed); + expect(srcFolder.name).toBe("src"); + expect(srcFolder.children.size).toBe(2); + }); + + it("partially collapses when branches diverge", () => { + const tree = buildFileTree([ + makeFile("packages/api/src/router.ts"), + makeFile("packages/api/src/client.ts"), + makeFile("packages/db/src/schema.ts"), + ]); + const collapsed = collapseEmptyFolders(tree); + + expect(collapsed.children.size).toBe(1); + const packages = firstChild(collapsed); + expect(packages.name).toBe("packages"); + expect(packages.children.size).toBe(2); + + const names = getChildNames(packages); + expect(names).toEqual(["api/src", "db/src"]); + }); + + it("handles files at the root level", () => { + const tree = buildFileTree([makeFile("README.md"), makeFile("package.json")]); + const collapsed = collapseEmptyFolders(tree); + + expect(collapsed.children.size).toBe(2); + const names = getChildNames(collapsed); + expect(names).toEqual(["package.json", "README.md"]); + }); + + it("does not collapse a folder whose only child is a file", () => { + const tree = buildFileTree([makeFile("src/index.ts")]); + const collapsed = collapseEmptyFolders(tree); + + expect(collapsed.children.size).toBe(1); + const src = firstChild(collapsed); + expect(src.name).toBe("src"); + expect(src.type).toBe("folder"); + expect(src.children.size).toBe(1); + }); + + it("preserves the path on collapsed nodes", () => { + const tree = buildFileTree([makeFile("a/b/c/file.ts")]); + const collapsed = collapseEmptyFolders(tree); + + const folder = firstChild(collapsed); + expect(folder.name).toBe("a/b/c"); + expect(folder.path).toBe("a/b/c"); + }); + + it("handles an empty tree", () => { + const tree = buildFileTree([]); + const collapsed = collapseEmptyFolders(tree); + expect(collapsed.children.size).toBe(0); + }); +}); + +describe("sortFileTree", () => { + it("places folders before sibling files at each level", () => { + const tree = sortFileTree( + buildFileTree([makeFile("README.md"), makeFile("src/index.ts"), makeFile("package.json")]), + ); + expect(Array.from(tree.children.keys())).toEqual(["src", "package.json", "README.md"]); + }); + + it("sorts alphabetically within each group", () => { + const tree = sortFileTree( + buildFileTree([makeFile("src/c.ts"), makeFile("src/a.ts"), makeFile("src/b.ts")]), + ); + const src = firstChild(tree); + expect(Array.from(src.children.keys())).toEqual(["a.ts", "b.ts", "c.ts"]); + }); + + it("recurses into nested folders", () => { + const tree = sortFileTree( + buildFileTree([ + makeFile("packages/db/src/schema.ts"), + makeFile("packages/api/src/router.ts"), + makeFile("packages/api/src/client.ts"), + ]), + ); + const packages = firstChild(tree); + expect(Array.from(packages.children.keys())).toEqual(["api", "db"]); + const api = firstChild(packages); + const apiSrc = firstChild(api); + expect(Array.from(apiSrc.children.keys())).toEqual(["client.ts", "router.ts"]); + }); +}); + +describe("flattenFileTree", () => { + it("returns leaf files in iteration order", () => { + const tree = sortFileTree( + buildFileTree([ + makeFile("README.md"), + makeFile("src/b.ts"), + makeFile("src/a.ts"), + makeFile("package.json"), + ]), + ); + expect(flattenFileTree(tree).map((f) => f.path)).toEqual([ + "src/a.ts", + "src/b.ts", + "package.json", + "README.md", + ]); + }); + + it("returns the same order before and after collapseEmptyFolders", () => { + const files = [ + makeFile("apps/web/src/page.tsx"), + makeFile("apps-mobile/index.ts"), + makeFile("packages/api/src/router.ts"), + makeFile("README.md"), + ]; + const sorted = sortFileTree(buildFileTree(files)); + const collapsed = collapseEmptyFolders(sorted); + expect(flattenFileTree(sorted).map((f) => f.path)).toEqual( + flattenFileTree(collapsed).map((f) => f.path), + ); + }); + + it("returns an empty array for an empty tree", () => { + expect(flattenFileTree(buildFileTree([]))).toEqual([]); + }); +}); diff --git a/packages/web/src/lib/__tests__/fixtures.tsx b/packages/web/src/lib/__tests__/fixtures.tsx index d8e2a8d..abead60 100644 --- a/packages/web/src/lib/__tests__/fixtures.tsx +++ b/packages/web/src/lib/__tests__/fixtures.tsx @@ -54,7 +54,7 @@ export function makeFetchScript(over: Partial = {}): FetchScript { function ensureRun(script: FetchScript, runId: string): ViewState { let state = script.viewState[runId]; if (!state) { - state = { chapterIds: [], keyChangeIds: [] }; + state = { chapterIds: [], keyChangeIds: [], filePaths: [] }; script.viewState[runId] = state; } return state; @@ -96,7 +96,7 @@ export function installFetch(script: FetchScript): void { if (method === "GET" && viewMatch) { const runId = decodeURIComponent(viewMatch[1] ?? ""); await maybeWaitGate(script, method, url); - const body = script.viewState[runId] ?? { chapterIds: [], keyChangeIds: [] }; + const body = script.viewState[runId] ?? { chapterIds: [], keyChangeIds: [], filePaths: [] }; return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, @@ -135,6 +135,22 @@ export function installFetch(script: FetchScript): void { return new Response("{}", { status: 200 }); } + const fileViewMatch = url.match(/\/api\/runs\/([^/]+)\/file-views$/); + if (fileViewMatch && (method === "POST" || method === "DELETE")) { + const runId = decodeURIComponent(fileViewMatch[1] ?? ""); + await maybeWaitGate(script, method, url); + const body = init?.body ? (JSON.parse(String(init.body)) as { path: string }) : null; + const path = body?.path ?? ""; + if (!path) return new Response("missing path", { status: 400 }); + const state = ensureRun(script, runId); + if (method === "POST") { + if (!state.filePaths.includes(path)) state.filePaths.push(path); + } else { + state.filePaths = state.filePaths.filter((p) => p !== path); + } + return new Response("{}", { status: 200 }); + } + return new Response("not found", { status: 404 }); }; diff --git a/packages/web/src/lib/__tests__/parse-diff.test.tsx b/packages/web/src/lib/__tests__/parse-diff.test.tsx new file mode 100644 index 0000000..0a03532 --- /dev/null +++ b/packages/web/src/lib/__tests__/parse-diff.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useFileDiffEntries } from "../parse-diff"; + +const ADD_PATCH = `diff --git a/src/foo.ts b/src/foo.ts +new file mode 100644 +index 0000000..e69de29 +--- /dev/null ++++ b/src/foo.ts +@@ -0,0 +1,3 @@ ++export const greet = "hello"; ++ ++greet(); +`; + +const MODIFY_PATCH = `diff --git a/README.md b/README.md +index abc1234..def5678 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,4 @@ + # stage-cli + +-Old line. ++New line one. ++New line two. +`; + +const RENAME_PATCH = `diff --git a/old-name.ts b/new-name.ts +similarity index 100% +rename from old-name.ts +rename to new-name.ts +`; + +const TWO_FILE_PATCH = ADD_PATCH + MODIFY_PATCH; + +describe("useFileDiffEntries", () => { + it("returns an empty list for empty input", () => { + const { result } = renderHook(() => useFileDiffEntries("")); + expect(result.current).toEqual([]); + }); + + it("returns an empty list for undefined input", () => { + const { result } = renderHook(() => useFileDiffEntries(undefined)); + expect(result.current).toEqual([]); + }); + + it("parses a single-file added patch with addition counts", () => { + const { result } = renderHook(() => useFileDiffEntries(ADD_PATCH)); + expect(result.current).toHaveLength(1); + const entry = result.current[0]; + if (!entry) throw new Error("expected one entry"); + expect(entry.file.path).toBe("src/foo.ts"); + expect(entry.file.status).toBe("added"); + expect(entry.file.additions).toBe(3); + expect(entry.file.deletions).toBe(0); + }); + + it("parses a modify patch with mixed additions and deletions", () => { + const { result } = renderHook(() => useFileDiffEntries(MODIFY_PATCH)); + expect(result.current).toHaveLength(1); + const entry = result.current[0]; + if (!entry) throw new Error("expected one entry"); + expect(entry.file.path).toBe("README.md"); + expect(entry.file.status).toBe("modified"); + expect(entry.file.additions).toBe(2); + expect(entry.file.deletions).toBe(1); + }); + + it("parses a pure rename patch as MOVED with the new path and oldPath set", () => { + const { result } = renderHook(() => useFileDiffEntries(RENAME_PATCH)); + expect(result.current).toHaveLength(1); + const entry = result.current[0]; + if (!entry) throw new Error("expected one entry"); + expect(entry.file.path).toBe("new-name.ts"); + expect(entry.file.oldPath).toBe("old-name.ts"); + expect(entry.file.status).toBe("moved"); + }); + + it("flattens multiple files in one patch into a single flat list", () => { + const { result } = renderHook(() => useFileDiffEntries(TWO_FILE_PATCH)); + expect(result.current).toHaveLength(2); + expect(result.current.map((e) => e.file.path)).toEqual(["src/foo.ts", "README.md"]); + }); +}); diff --git a/packages/web/src/lib/__tests__/use-file-collapse-state.test.ts b/packages/web/src/lib/__tests__/use-file-collapse-state.test.ts new file mode 100644 index 0000000..0590c88 --- /dev/null +++ b/packages/web/src/lib/__tests__/use-file-collapse-state.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useFileCollapseState } from "../use-file-collapse-state"; + +const EMPTY_DEFAULTS = new Set(); +const RESET_KEY = "owner/repo/1"; + +describe("useFileCollapseState — basic operations", () => { + describe("initial state", () => { + it("collapses only default-collapsed files", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "b.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + expect(result.current.collapsedFiles).toEqual(new Set(["deleted.ts"])); + }); + + it("starts with no files collapsed when there are no defaults", () => { + const allPaths = ["a.ts", "b.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + expect(result.current.collapsedFiles.size).toBe(0); + }); + }); + + describe("toggleFileCollapsed", () => { + it("collapses a non-default file", () => { + const allPaths = ["a.ts", "b.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + act(() => result.current.toggleFileCollapsed("a.ts")); + expect(result.current.collapsedFiles).toEqual(new Set(["a.ts"])); + }); + + it("un-collapses a toggled file on second toggle", () => { + const allPaths = ["a.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + act(() => result.current.toggleFileCollapsed("a.ts")); + act(() => result.current.toggleFileCollapsed("a.ts")); + expect(result.current.collapsedFiles.size).toBe(0); + }); + + it("expands a default-collapsed file", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + act(() => result.current.toggleFileCollapsed("deleted.ts")); + expect(result.current.collapsedFiles.has("deleted.ts")).toBe(false); + }); + }); + + describe("collapseAllFiles", () => { + it("collapses all files when none are collapsed", () => { + const allPaths = ["a.ts", "b.ts", "c.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + act(() => result.current.collapseAllFiles()); + expect(result.current.collapsedFiles).toEqual(new Set(["a.ts", "b.ts", "c.ts"])); + }); + + it("collapses all files when some are already default-collapsed", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "b.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + act(() => result.current.collapseAllFiles()); + expect(result.current.collapsedFiles).toEqual(new Set(["a.ts", "b.ts", "deleted.ts"])); + }); + + it("collapses all files even after some were manually expanded", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + act(() => result.current.toggleFileCollapsed("deleted.ts")); + act(() => result.current.collapseAllFiles()); + expect(result.current.collapsedFiles).toEqual(new Set(["a.ts", "deleted.ts"])); + }); + + it("is a no-op when all files are already collapsed", () => { + const allPaths = ["a.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + act(() => result.current.collapseAllFiles()); + const first = result.current.collapsedFiles; + act(() => result.current.collapseAllFiles()); + expect(result.current.collapsedFiles).toEqual(first); + }); + }); + + describe("expandAllFiles", () => { + it("expands all files when all are collapsed", () => { + const allPaths = ["a.ts", "b.ts"]; + const { result } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, RESET_KEY), + ); + act(() => result.current.collapseAllFiles()); + act(() => result.current.expandAllFiles()); + expect(result.current.collapsedFiles.size).toBe(0); + }); + + it("expands all files including default-collapsed ones", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + act(() => result.current.expandAllFiles()); + expect(result.current.collapsedFiles.size).toBe(0); + }); + + it("expands all after a mix of manual toggles", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "b.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + act(() => result.current.toggleFileCollapsed("a.ts")); + act(() => result.current.toggleFileCollapsed("deleted.ts")); + act(() => result.current.expandAllFiles()); + expect(result.current.collapsedFiles.size).toBe(0); + }); + }); +}); + +describe("useFileCollapseState — advanced behaviors", () => { + describe("resetKey", () => { + it("clears overrides when resetKey changes", () => { + const allPaths = ["a.ts", "b.ts"]; + let resetKey = "owner/repo/1"; + const { result, rerender } = renderHook(() => + useFileCollapseState(EMPTY_DEFAULTS, allPaths, resetKey), + ); + + act(() => result.current.toggleFileCollapsed("a.ts")); + expect(result.current.collapsedFiles).toEqual(new Set(["a.ts"])); + + resetKey = "owner/repo/2"; + rerender(); + + expect(result.current.collapsedFiles.size).toBe(0); + }); + }); + + describe("default changes reconcile overrides", () => { + it("keeps a manually-collapsed file collapsed when it becomes default-collapsed", () => { + const allPaths = ["a.ts", "b.ts"]; + let defaults: ReadonlySet = new Set(); + const { result, rerender } = renderHook(() => + useFileCollapseState(defaults, allPaths, RESET_KEY), + ); + + act(() => result.current.toggleFileCollapsed("a.ts")); + expect(result.current.collapsedFiles.has("a.ts")).toBe(true); + + defaults = new Set(["a.ts"]); + rerender(); + + expect(result.current.collapsedFiles.has("a.ts")).toBe(true); + }); + + it("keeps a manually-expanded file expanded when it leaves defaults", () => { + const allPaths = ["a.ts", "b.ts"]; + let defaults: ReadonlySet = new Set(["a.ts"]); + const { result, rerender } = renderHook(() => + useFileCollapseState(defaults, allPaths, RESET_KEY), + ); + + act(() => result.current.toggleFileCollapsed("a.ts")); + expect(result.current.collapsedFiles.has("a.ts")).toBe(false); + + defaults = new Set(); + rerender(); + + expect(result.current.collapsedFiles.has("a.ts")).toBe(false); + }); + + it("preserves overrides for files whose default status did not change", () => { + const allPaths = ["a.ts", "b.ts"]; + let defaults: ReadonlySet = new Set(); + const { result, rerender } = renderHook(() => + useFileCollapseState(defaults, allPaths, RESET_KEY), + ); + + act(() => result.current.toggleFileCollapsed("a.ts")); + act(() => result.current.toggleFileCollapsed("b.ts")); + + defaults = new Set(["a.ts"]); + rerender(); + + expect(result.current.collapsedFiles.has("a.ts")).toBe(true); + expect(result.current.collapsedFiles.has("b.ts")).toBe(true); + }); + }); + + describe("collapse then expand round-trip", () => { + it("returns to fully expanded state after collapse-all then expand-all", () => { + const defaults = new Set(["deleted.ts"]); + const allPaths = ["a.ts", "b.ts", "deleted.ts"]; + const { result } = renderHook(() => useFileCollapseState(defaults, allPaths, RESET_KEY)); + + act(() => result.current.collapseAllFiles()); + expect(result.current.collapsedFiles.size).toBe(3); + + act(() => result.current.expandAllFiles()); + expect(result.current.collapsedFiles.size).toBe(0); + }); + }); +}); diff --git a/packages/web/src/lib/__tests__/use-view-state-reads.test.tsx b/packages/web/src/lib/__tests__/use-view-state-reads.test.tsx index 30fd83f..e7d8ac6 100644 --- a/packages/web/src/lib/__tests__/use-view-state-reads.test.tsx +++ b/packages/web/src/lib/__tests__/use-view-state-reads.test.tsx @@ -13,7 +13,7 @@ afterEach(() => { describe("useViewState — reads", () => { it("hydrates the initial viewed sets from GET /api/runs/:runId/view-state", async () => { const script = makeFetchScript({ - viewState: { run1: { chapterIds: ["chap-a"], keyChangeIds: ["kc-a"] } }, + viewState: { run1: { chapterIds: ["chap-a"], keyChangeIds: ["kc-a"], filePaths: [] } }, }); installFetch(script); const { Wrapper } = makeWrapper(); @@ -29,7 +29,7 @@ describe("useViewState — reads", () => { it("idempotent mark of an already-viewed chapter does not duplicate cache entries", async () => { const script = makeFetchScript({ - viewState: { run1: { chapterIds: ["chap-1"], keyChangeIds: [] } }, + viewState: { run1: { chapterIds: ["chap-1"], keyChangeIds: [], filePaths: [] } }, }); installFetch(script); const { client, Wrapper } = makeWrapper(); @@ -51,8 +51,8 @@ describe("useViewState — reads", () => { it("changing runId triggers a refetch and isolates state per run", async () => { const script = makeFetchScript({ viewState: { - run1: { chapterIds: ["chap-a"], keyChangeIds: [] }, - run2: { chapterIds: ["chap-b"], keyChangeIds: ["kc-b"] }, + run1: { chapterIds: ["chap-a"], keyChangeIds: [], filePaths: [] }, + run2: { chapterIds: ["chap-b"], keyChangeIds: ["kc-b"], filePaths: [] }, }, }); installFetch(script); diff --git a/packages/web/src/lib/__tests__/use-view-state-writes.test.tsx b/packages/web/src/lib/__tests__/use-view-state-writes.test.tsx index 3ed1874..e1e2b09 100644 --- a/packages/web/src/lib/__tests__/use-view-state-writes.test.tsx +++ b/packages/web/src/lib/__tests__/use-view-state-writes.test.tsx @@ -45,7 +45,7 @@ describe("useViewState — writes", () => { it("unmarkChapterViewed deletes and removes from cache", async () => { const script = makeFetchScript({ - viewState: { run1: { chapterIds: ["chap-1"], keyChangeIds: [] } }, + viewState: { run1: { chapterIds: ["chap-1"], keyChangeIds: [], filePaths: [] } }, }); installFetch(script); const { Wrapper } = makeWrapper(); @@ -87,6 +87,32 @@ describe("useViewState — writes", () => { ).toBe(true); }); + it("markFileViewed / unmarkFileViewed round-trip via /api/runs/:runId/file-views", async () => { + const script = makeFetchScript({ mutateRuns: ["run1"] }); + installFetch(script); + const { Wrapper } = makeWrapper(); + + const { result } = renderHook(() => useViewState("run1"), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.markFileViewed("src/foo.ts"); + }); + await waitFor(() => expect(result.current.isFileViewed("src/foo.ts")).toBe(true)); + expect( + script.calls.some((c) => c.method === "POST" && c.url === "/api/runs/run1/file-views"), + ).toBe(true); + expect(script.viewState.run1?.filePaths).toContain("src/foo.ts"); + + act(() => { + result.current.unmarkFileViewed("src/foo.ts"); + }); + await waitFor(() => expect(result.current.isFileViewed("src/foo.ts")).toBe(false)); + expect( + script.calls.some((c) => c.method === "DELETE" && c.url === "/api/runs/run1/file-views"), + ).toBe(true); + }); + it("rolls back optimistic state when the POST fails", async () => { const script = makeFetchScript({ mutateRuns: ["run1"], diff --git a/packages/web/src/lib/file-tree.ts b/packages/web/src/lib/file-tree.ts new file mode 100644 index 0000000..327a083 --- /dev/null +++ b/packages/web/src/lib/file-tree.ts @@ -0,0 +1,120 @@ +import type { PullRequestFile } from "./diff-types"; + +export const FILE_NODE_TYPE = { + FILE: "file", + FOLDER: "folder", +} as const; +export type FileNodeType = (typeof FILE_NODE_TYPE)[keyof typeof FILE_NODE_TYPE]; + +export interface FileNode { + name: string; + path: string; + type: FileNodeType; + file?: PullRequestFile; + children: Map; +} + +export function buildFileTree(files: PullRequestFile[]): FileNode { + const root: FileNode = { + name: "", + path: "", + type: FILE_NODE_TYPE.FOLDER, + children: new Map(), + }; + + for (const file of files) { + const parts = file.path.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === undefined) continue; + const isFile = i === parts.length - 1; + const fullPath = parts.slice(0, i + 1).join("/"); + + let next = current.children.get(part); + if (!next) { + next = { + name: part, + path: fullPath, + type: isFile ? FILE_NODE_TYPE.FILE : FILE_NODE_TYPE.FOLDER, + file: isFile ? file : undefined, + children: new Map(), + }; + current.children.set(part, next); + } + current = next; + } + } + + return root; +} + +/** + * Recursively orders each node's children: folders before files, alphabetical + * within each group. Must run before `collapseEmptyFolders` so the sort key is + * the raw single-segment `name` (e.g. "apps") rather than the post-collapse + * merged name (e.g. "apps/web/src"), keeping sort and collapse independent. + */ +export function sortFileTree(node: FileNode): FileNode { + const sorted = Array.from(node.children.values()) + .sort((a, b) => { + if (a.type !== b.type) return a.type === FILE_NODE_TYPE.FOLDER ? -1 : 1; + return a.name.localeCompare(b.name); + }) + .map(sortFileTree); + + const children = new Map(); + for (const child of sorted) children.set(child.name, child); + return { ...node, children }; +} + +/** + * Collapses folder nodes that have exactly one child which is also a folder. + * For example, `apps/` → `web/` → `src/` → `file.tsx` becomes `apps/web/src/` → `file.tsx`. + * Purely presentational: preserves child iteration order, so collapsing a + * sorted tree leaves its order intact. + */ +export function collapseEmptyFolders(node: FileNode): FileNode { + const collapsedChildren = new Map(); + + for (const [, child] of node.children) { + let current = child; + + while (current.type === FILE_NODE_TYPE.FOLDER && current.children.size === 1) { + const onlyChild = Array.from(current.children.values())[0]; + if (!onlyChild || onlyChild.type !== FILE_NODE_TYPE.FOLDER) break; + current = { + name: `${current.name}/${onlyChild.name}`, + path: onlyChild.path, + type: onlyChild.type, + file: onlyChild.file, + children: onlyChild.children, + }; + } + + const collapsed = collapseEmptyFolders(current); + collapsedChildren.set(collapsed.name, collapsed); + } + + return { ...node, children: collapsedChildren }; +} + +/** + * Returns the leaf files of a tree in iteration order. Pair with + * `sortFileTree` to get files in the same order the picker renders them. + */ +export function flattenFileTree(node: FileNode): PullRequestFile[] { + const result: PullRequestFile[] = []; + function visit(n: FileNode): void { + for (const child of n.children.values()) { + if (child.type === FILE_NODE_TYPE.FILE && child.file) { + result.push(child.file); + } else { + visit(child); + } + } + } + visit(node); + return result; +} diff --git a/packages/web/src/lib/parse-diff.ts b/packages/web/src/lib/parse-diff.ts new file mode 100644 index 0000000..22e155d --- /dev/null +++ b/packages/web/src/lib/parse-diff.ts @@ -0,0 +1,63 @@ +import { type FileDiffMetadata, parsePatchFiles } from "@pierre/diffs"; +import { useMemo } from "react"; +import { FILE_STATUS, type FileStatus, type PullRequestFile } from "./diff-types"; + +// Flatten across ParsedPatch envelopes — `parsePatchFiles` returns one per +// `From ` block, but plain `git diff` output yields a single envelope +// and callers don't need to care which. +export function parsePatchToFileDiffs(patch: string): FileDiffMetadata[] { + if (!patch.trim()) return []; + const parsed = parsePatchFiles(patch); + return parsed.flatMap((p) => p.files); +} + +export function fileDiffToPullRequestFile(diff: FileDiffMetadata): PullRequestFile { + let additions = 0; + let deletions = 0; + for (const hunk of diff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + const status = changeTypeToFileStatus(diff.type); + const path = diff.name; + const oldPath = diff.prevName; + return { + path, + oldPath: oldPath && oldPath !== path ? oldPath : undefined, + filename: path.split("/").pop() ?? path, + status, + additions, + deletions, + // Hunks live on the FileDiffMetadata; PullRequestFile only carries + // additions/deletions for header rendering, so we don't translate them. + hunks: [], + }; +} + +function changeTypeToFileStatus(type: FileDiffMetadata["type"]): FileStatus { + switch (type) { + case "new": + return FILE_STATUS.ADDED; + case "deleted": + return FILE_STATUS.DELETED; + case "rename-pure": + return FILE_STATUS.MOVED; + case "rename-changed": + return FILE_STATUS.RENAMED; + case "change": + return FILE_STATUS.MODIFIED; + } +} + +export interface FileDiffEntry { + file: PullRequestFile; + diff: FileDiffMetadata; +} + +export function useFileDiffEntries(patch: string | undefined): FileDiffEntry[] { + return useMemo(() => { + if (!patch) return []; + const diffs = parsePatchToFileDiffs(patch); + return diffs.map((diff) => ({ file: fileDiffToPullRequestFile(diff), diff })); + }, [patch]); +} diff --git a/packages/web/src/lib/use-active-file-on-scroll.ts b/packages/web/src/lib/use-active-file-on-scroll.ts new file mode 100644 index 0000000..fe09d28 --- /dev/null +++ b/packages/web/src/lib/use-active-file-on-scroll.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PullRequestFile } from "./diff-types"; + +const HEADER_OFFSET = 96; + +export function useActiveFileOnScroll(files: PullRequestFile[]) { + const [activeFilePath, setActiveFilePath] = useState(); + const suppressUntilRef = useRef(0); + + const setActiveFileManually = useCallback((path: string) => { + setActiveFilePath(path); + suppressUntilRef.current = Date.now() + 100; + }, []); + + useEffect(() => { + if (files.length === 0) return; + + const handleScrollEnd = () => { + if (Date.now() < suppressUntilRef.current) return; + + const path = findActiveFile(files); + if (path) setActiveFilePath(path); + }; + + handleScrollEnd(); + + window.addEventListener("scrollend", handleScrollEnd, { passive: true }); + return () => window.removeEventListener("scrollend", handleScrollEnd); + }, [files]); + + return { activeFilePath, setActiveFileManually }; +} + +function findActiveFile(files: PullRequestFile[]): string | undefined { + let bestPath: string | undefined; + let bestDistance = Infinity; + + for (const file of files) { + const el = document.getElementById(`file-${file.path}`); + if (!el) continue; + + const rect = el.getBoundingClientRect(); + const distance = rect.top - HEADER_OFFSET; + // Element is a candidate if any part is still visible below the sticky header. + if (rect.bottom > HEADER_OFFSET && Math.abs(distance) < bestDistance) { + bestDistance = Math.abs(distance); + bestPath = file.path; + } + } + + return bestPath; +} diff --git a/packages/web/src/lib/use-diff-patch.ts b/packages/web/src/lib/use-diff-patch.ts new file mode 100644 index 0000000..3d42c56 --- /dev/null +++ b/packages/web/src/lib/use-diff-patch.ts @@ -0,0 +1,21 @@ +import { type UseQueryResult, useQuery } from "@tanstack/react-query"; + +const DIFF_QUERY_ROOT = "diff"; + +export function diffPatchQueryKey(runId: string): readonly unknown[] { + return [DIFF_QUERY_ROOT, runId]; +} + +// Shared queryKey lets react-query dedupe the patch fetch when the same hook +// is mounted from more than one component for the same run. +export function useDiffPatch(runId: string): UseQueryResult { + return useQuery({ + queryKey: diffPatchQueryKey(runId), + queryFn: async () => { + const res = await fetch(`/api/runs/${encodeURIComponent(runId)}/diff.patch`); + if (!res.ok) throw new Error(`GET diff.patch failed: ${res.status}`); + return res.text(); + }, + enabled: runId !== "", + }); +} diff --git a/packages/web/src/lib/use-file-collapse-state.ts b/packages/web/src/lib/use-file-collapse-state.ts new file mode 100644 index 0000000..7aeff3a --- /dev/null +++ b/packages/web/src/lib/use-file-collapse-state.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo, useRef, useState } from "react"; + +/** + * Manages file collapse state using a symmetric-difference (XOR) model: + * the visible collapsed set is `defaultCollapsedIds XOR overrides`. + * + * When defaults change (e.g. a file is marked viewed), overrides that now + * conflict are pruned so already-collapsed files stay collapsed and + * already-expanded files stay expanded. + */ +export function useFileCollapseState( + defaultCollapsedIds: ReadonlySet, + allFilePaths: readonly string[], + resetKey: string, +) { + const [overrides, setOverrides] = useState>(new Set()); + const prevResetKey = useRef(resetKey); + if (prevResetKey.current !== resetKey) { + prevResetKey.current = resetKey; + setOverrides(new Set()); + } + + // When a file transitions in/out of defaults, any existing override for it + // would flip the intended state (XOR). Remove stale overrides so the + // visible collapsed set stays consistent with the user's last action. + const prevDefaultsRef = useRef(defaultCollapsedIds); + if (prevDefaultsRef.current !== defaultCollapsedIds) { + const prev = prevDefaultsRef.current; + prevDefaultsRef.current = defaultCollapsedIds; + setOverrides((current) => { + let pruned: Set | null = null; + for (const id of current) { + const wasDefault = prev.has(id); + const isDefault = defaultCollapsedIds.has(id); + if (wasDefault !== isDefault) { + pruned ??= new Set(current); + pruned.delete(id); + } + } + return pruned ?? current; + }); + } + + const collapsedFiles = useMemo(() => { + const result = new Set(defaultCollapsedIds); + for (const id of overrides) { + if (result.has(id)) { + result.delete(id); + } else { + result.add(id); + } + } + return result; + }, [defaultCollapsedIds, overrides]); + + const toggleFileCollapsed = useCallback((filePath: string) => { + setOverrides((prev) => { + const next = new Set(prev); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + }, []); + + const collapseAllFiles = useCallback(() => { + // Overrides XOR defaults = all file paths → overrides = paths NOT in defaults + const next = new Set(); + for (const path of allFilePaths) { + if (!defaultCollapsedIds.has(path)) { + next.add(path); + } + } + setOverrides(next); + }, [allFilePaths, defaultCollapsedIds]); + + const expandAllFiles = useCallback(() => { + // Overrides XOR defaults = empty → overrides = defaults (cancels them out) + setOverrides(new Set(defaultCollapsedIds)); + }, [defaultCollapsedIds]); + + return useMemo( + () => ({ collapsedFiles, toggleFileCollapsed, collapseAllFiles, expandAllFiles }), + [collapsedFiles, toggleFileCollapsed, collapseAllFiles, expandAllFiles], + ); +} diff --git a/packages/web/src/lib/use-view-state.ts b/packages/web/src/lib/use-view-state.ts index 40cf2fe..719199b 100644 --- a/packages/web/src/lib/use-view-state.ts +++ b/packages/web/src/lib/use-view-state.ts @@ -54,13 +54,34 @@ const postKeyChangeView = (id: string) => const deleteKeyChangeView = (id: string) => jsonFetch(`/api/key-change-view/${encodeURIComponent(id)}`, { method: "DELETE" }); +const fileViewRequest = (method: "POST" | "DELETE", path: string): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), +}); + +const postFileView = (runId: string, path: string) => + jsonFetch( + `/api/runs/${encodeURIComponent(runId)}/file-views`, + fileViewRequest("POST", path), + ); + +const deleteFileView = (runId: string, path: string) => + jsonFetch( + `/api/runs/${encodeURIComponent(runId)}/file-views`, + fileViewRequest("DELETE", path), + ); + export interface UseViewStateDataResult { /** Stable reference; mutates only when the underlying query data changes. */ chapterIdSet: ReadonlySet; /** Stable reference; mutates only when the underlying query data changes. */ keyChangeIdSet: ReadonlySet; + /** Stable reference; mutates only when the underlying query data changes. */ + filePathSet: ReadonlySet; isChapterViewed: (chapterId: string) => boolean; isKeyChangeChecked: (keyChangeId: string) => boolean; + isFileViewed: (filePath: string) => boolean; isLoading: boolean; error: unknown; } @@ -70,15 +91,12 @@ export interface UseViewStateResult extends UseViewStateDataResult { unmarkChapterViewed: (chapterId: string) => void; markKeyChangeChecked: (keyChangeId: string) => void; unmarkKeyChangeChecked: (keyChangeId: string) => void; + markFileViewed: (filePath: string) => void; + unmarkFileViewed: (filePath: string) => void; } -/** - * Read-only view-state for components that just need to know what's viewed. - * Returns memoized Sets so callers can include them as effect/memo deps - * without re-running on every render. Components that also need to mutate - * view-state should use `useViewState` instead — calling this hook avoids - * instantiating the four mutation hooks. - */ +// Returns stable Sets so callers can use them as effect/memo deps. +// Read-only — `useViewState` adds the mutation hooks on top of this. export function useViewStateData(runId: string): UseViewStateDataResult { const { data, isLoading, error } = useQuery({ queryKey: viewStateQueryKey(runId), @@ -88,17 +106,20 @@ export function useViewStateData(runId: string): UseViewStateDataResult { const chapterIdSet = useMemo(() => new Set(data?.chapterIds ?? []), [data?.chapterIds]); const keyChangeIdSet = useMemo(() => new Set(data?.keyChangeIds ?? []), [data?.keyChangeIds]); + const filePathSet = useMemo(() => new Set(data?.filePaths ?? []), [data?.filePaths]); return useMemo( () => ({ chapterIdSet, keyChangeIdSet, + filePathSet, isChapterViewed: (id: string) => chapterIdSet.has(id), isKeyChangeChecked: (id: string) => keyChangeIdSet.has(id), + isFileViewed: (path: string) => filePathSet.has(path), isLoading, error, }), - [chapterIdSet, keyChangeIdSet, isLoading, error], + [chapterIdSet, keyChangeIdSet, filePathSet, isLoading, error], ); } @@ -119,7 +140,7 @@ export function useViewState(runId: string): UseViewStateResult { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData(queryKey, (old) => { - const base: ViewState = old ?? { chapterIds: [], keyChangeIds: [] }; + const base: ViewState = old ?? { chapterIds: [], keyChangeIds: [], filePaths: [] }; return patch(base); }); return { previous, queryKey }; @@ -184,11 +205,35 @@ export function useViewState(runId: string): UseViewStateResult { onSettled: settle, }); + const markFileMutation = useMutation({ + mutationFn: (filePath) => postFileView(runId, filePath), + onMutate: (filePath) => + snapshotAndPatch((prev) => { + if (prev.filePaths.includes(filePath)) return prev; + return { ...prev, filePaths: [...prev.filePaths, filePath] }; + }), + onError: (_err, _filePath, ctx) => rollback(ctx), + onSettled: settle, + }); + + const unmarkFileMutation = useMutation({ + mutationFn: (filePath) => deleteFileView(runId, filePath), + onMutate: (filePath) => + snapshotAndPatch((prev) => ({ + ...prev, + filePaths: prev.filePaths.filter((p) => p !== filePath), + })), + onError: (_err, _filePath, ctx) => rollback(ctx), + onSettled: settle, + }); + return { ...data, markChapterViewed: markChapterMutation.mutate, unmarkChapterViewed: unmarkChapterMutation.mutate, markKeyChangeChecked: markKeyChangeMutation.mutate, unmarkKeyChangeChecked: unmarkKeyChangeMutation.mutate, + markFileViewed: markFileMutation.mutate, + unmarkFileViewed: unmarkFileMutation.mutate, }; } diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 86cb049..3b1ac27 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -3,6 +3,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import { ThemeProvider } from "./lib/theme"; +import { DiffSettingsProvider } from "./lib/use-diff-settings"; import "./styles/globals.css"; const rootElement = document.getElementById("root"); @@ -25,7 +26,9 @@ createRoot(rootElement).render( - + + + , diff --git a/packages/web/src/routes/files-page.tsx b/packages/web/src/routes/files-page.tsx new file mode 100644 index 0000000..adaa716 --- /dev/null +++ b/packages/web/src/routes/files-page.tsx @@ -0,0 +1,132 @@ +import { useCallback, useMemo, useRef } from "react"; +import { + FileDiffList, + type FileDiffListHandle, + FilePicker, + SidebarLayout, +} from "@/components/files"; +import { Skeleton } from "@/components/ui/skeleton"; +import { FILE_STATUS } from "@/lib/diff-types"; +import { buildFileTree, flattenFileTree, sortFileTree } from "@/lib/file-tree"; +import { type FileDiffEntry, useFileDiffEntries } from "@/lib/parse-diff"; +import { useActiveFileOnScroll } from "@/lib/use-active-file-on-scroll"; +import { useDiffPatch } from "@/lib/use-diff-patch"; +import { useFileCollapseState } from "@/lib/use-file-collapse-state"; +import { useViewState } from "@/lib/use-view-state"; + +interface FilesPageProps { + runId: string; +} + +export function FilesPage({ runId }: FilesPageProps) { + const { data, isLoading, error } = useDiffPatch(runId); + + const rawEntries = useFileDiffEntries(data); + const entries = useMemo(() => sortFileDiffEntries(rawEntries), [rawEntries]); + const files = useMemo(() => entries.map((e) => e.file), [entries]); + + const { filePathSet, markFileViewed, unmarkFileViewed } = useViewState(runId); + const handleToggleViewed = useCallback( + (path: string) => { + if (filePathSet.has(path)) unmarkFileViewed(path); + else markFileViewed(path); + }, + [filePathSet, markFileViewed, unmarkFileViewed], + ); + + // Deleted and viewed files start collapsed; useFileCollapseState lets the + // user override per-file while keeping these defaults reactive. + const defaultCollapsedFileIds = useMemo(() => { + const ids = new Set(); + for (const file of files) { + if (file.status === FILE_STATUS.DELETED) ids.add(file.path); + } + for (const path of filePathSet) ids.add(path); + return ids; + }, [files, filePathSet]); + + const filePaths = useMemo(() => files.map((f) => f.path), [files]); + const collapseState = useFileCollapseState(defaultCollapsedFileIds, filePaths, runId); + + const diffListRef = useRef(null); + const { activeFilePath, setActiveFileManually } = useActiveFileOnScroll(files); + + const handleSelectFile = useCallback( + (filePath: string) => { + setActiveFileManually(filePath); + diffListRef.current?.scrollToFile(filePath); + }, + [setActiveFileManually], + ); + + if (error) return ; + if (isLoading || data === undefined) return ; + + return ( + + } + > + + + ); +} + +function sortFileDiffEntries(entries: FileDiffEntry[]): FileDiffEntry[] { + const entryByPath = new Map(entries.map((entry) => [entry.file.path, entry])); + const sortedFiles = flattenFileTree( + sortFileTree(buildFileTree(entries.map((entry) => entry.file))), + ); + return sortedFiles.map((file) => { + const entry = entryByPath.get(file.path); + if (!entry) throw new Error(`Missing diff entry for sorted file ${file.path}`); + return entry; + }); +} + +function FilesPageSkeleton() { + return ( +
+ + + + +
+ ); +} + +function SkeletonFile() { + return ( +
+ +
+ + + +
+
+ ); +} + +function FilesPageError({ error }: { error: unknown }) { + const message = error instanceof Error ? error.message : String(error); + return ( +
+

Couldn't load file diffs

+

{message}

+
+ ); +} diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index b810ab3..e059f04 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -1,11 +1,13 @@ import { BookOpen, FileText } from "lucide-react"; -import { useMemo, useState } from "react"; +import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { SectionLabel } from "@/components/pull-request/section-label"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useFileDiffEntries } from "@/lib/parse-diff"; import { useChapters } from "@/lib/use-chapters"; +import { useDiffPatch } from "@/lib/use-diff-patch"; import { useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; import { ChaptersIndexPage } from "./chapters-index-page"; +import { FilesPage } from "./files-page"; const PR_TAB = { CHAPTERS: "chapters", @@ -17,19 +19,11 @@ interface TabDef { id: PrTab; label: string; icon: React.ElementType; - disabled?: boolean; - disabledReason?: string; } const tabs: TabDef[] = [ { id: PR_TAB.CHAPTERS, label: "Chapters", icon: BookOpen }, - { - id: PR_TAB.FILES, - label: "Files changed", - icon: FileText, - disabled: true, - disabledReason: "Coming soon — needs a diff endpoint from the CLI server", - }, + { id: PR_TAB.FILES, label: "Files changed", icon: FileText }, ]; interface TabLinkProps { @@ -40,38 +34,25 @@ interface TabLinkProps { } function TabLink({ tab, isActive, onSelect, countLabel }: TabLinkProps) { - const { icon: Icon, label, disabled, disabledReason } = tab; - const button = ( + const { icon: Icon, label } = tab; + return ( ); - if (disabled && disabledReason) { - return ( - - - {button} - - {disabledReason} - - ); - } - return {button}; } function ErrorState({ error }: { error: unknown }) { @@ -91,38 +72,74 @@ export function PullRequestLayout({ runId }: { runId: string }) { const { data, isLoading, error } = useChapters(runId); const [activeTab, setActiveTab] = useState(PR_TAB.CHAPTERS); - // Lift the viewed count out of ChaptersIndexPage so the tab strip can render - // "X/N viewed". Read-only hook avoids instantiating the four mutation hooks - // we don't use here, and chapterIdSet is a stable reference so the memo - // actually caches across renders. - const { chapterIdSet } = useViewStateData(runId); + const { chapterIdSet, filePathSet } = useViewStateData(runId); const chapters = data?.chapters; - const viewedCount = useMemo(() => { + const viewedChapterCount = useMemo(() => { if (!chapters) return 0; let n = 0; for (const c of chapters) if (chapterIdSet.has(c.externalId)) n++; return n; }, [chapters, chapterIdSet]); - // Mirrors hosted's chapterCountLabel: just the total when nothing's been - // viewed yet, otherwise "X/N viewed". Drops the count entirely if the - // chapters API hasn't responded yet. + // Fetched here so the Files tab's "N/M viewed" label can render before the + // user clicks into the tab; react-query dedupes the same fetch from FilesPage. + const { data: patch } = useDiffPatch(runId); + const fileEntries = useFileDiffEntries(patch); + const totalFileCount = fileEntries.length; + const viewedFileCount = useMemo(() => { + if (totalFileCount === 0) return 0; + let n = 0; + for (const entry of fileEntries) { + if (filePathSet.has(entry.file.path)) n++; + } + return n; + }, [fileEntries, filePathSet, totalFileCount]); + + // `undefined` while loading so the count chip is suppressed entirely; + // otherwise the bare total until at least one item is viewed. const chapterCountLabel = (() => { if (chapters === undefined) return undefined; - if (viewedCount > 0) return `${viewedCount}/${chapters.length} viewed`; + if (viewedChapterCount > 0) return `${viewedChapterCount}/${chapters.length} viewed`; return String(chapters.length); })(); + const fileCountLabel = (() => { + if (patch === undefined) return undefined; + if (viewedFileCount > 0) return `${viewedFileCount}/${totalFileCount} viewed`; + return String(totalFileCount); + })(); + + // `--content-top` and `--main-height` are read by the sticky file picker. + const navRef = useRef(null); + const [navHeight, setNavHeight] = useState(0); + useEffect(() => { + const el = navRef.current; + if (!el) return; + const observer = new ResizeObserver(() => setNavHeight(el.getBoundingClientRect().height)); + observer.observe(el); + setNavHeight(el.getBoundingClientRect().height); + return () => observer.disconnect(); + }, []); + if (error) return ; + // 48 = the app-shell Topbar's `h-12`, which the picker also has to clear. + const layoutStyle = { + "--content-top": `${48 + navHeight}px`, + "--main-height": "100vh", + } as CSSProperties; + return ( -
+
Run

{data?.run.id ?? runId}

-
); diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 9322f23..4f2ca69 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -300,6 +300,54 @@ animation: slide-in-right 0.2s ease-out; } +/* ======================================== + Scrollbar Styling + ======================================== */ + +.scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: oklch(0.88 0 0) transparent; +} + +.dark .scrollbar-thin { + scrollbar-color: oklch(0.3 0 0) transparent; +} + +.scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + background: oklch(0.88 0 0); + border-radius: 3px; +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb { + background: oklch(0.3 0 0); +} + +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: oklch(0.8 0 0); +} + +.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: oklch(0.4 0 0); +} + +.scrollbar-none { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollbar-none::-webkit-scrollbar { + display: none; +} + /* Accessibility: respect user preference for reduced motion */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4f738..a8f7a33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: tailwindcss: specifier: ^4.1.18 version: 4.2.4 + tslib: + specifier: ^2.8.1 + version: 2.8.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0