From cb97226d29e21519844e709c64f1892d971d76ee Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 20:22:01 +0000 Subject: [PATCH 1/7] feat: use certified hot working diff in review mode --- src/lib/lix-diff-commands.ts | 4 ++-- src/queries.test.ts | 8 +++++++- src/queries.ts | 8 ++++++-- src/shell/layout-shell.test.tsx | 13 +++++++++++++ src/shell/layout-shell.tsx | 31 +++++++++++++++++-------------- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/lib/lix-diff-commands.ts b/src/lib/lix-diff-commands.ts index 3db82de4..565ad638 100644 --- a/src/lib/lix-diff-commands.ts +++ b/src/lib/lix-diff-commands.ts @@ -34,7 +34,7 @@ export async function createCheckpointForFiles( `SELECT commit_id FROM lix_create_checkpoint(ARRAY( SELECT row_ref - FROM lix_diff('lix_file') + FROM lix_working_diff('lix_file') WHERE id IN (${fileIdParameters(fileIds, 1)}) ))`, [...fileIds], @@ -55,7 +55,7 @@ export async function revertWorkingChangesForFiles( const result = await lix.execute( `INSERT INTO lix_revert (row_ref) SELECT row_ref - FROM lix_diff('lix_file') + FROM lix_working_diff('lix_file') WHERE id IN (${fileIdParameters(fileIds, 1)})`, [...fileIds], ); diff --git a/src/queries.test.ts b/src/queries.test.ts index 1c5e7410..988f4588 100644 --- a/src/queries.test.ts +++ b/src/queries.test.ts @@ -162,13 +162,19 @@ describe("checkpoint queries", () => { new TextEncoder().encode("draft"), ], ); - expect(await selectWorkingFileDiffs(lix).execute()).toEqual([ + const workingFiles = await selectWorkingFileDiffs(lix).execute(); + expect(workingFiles).toEqual([ expect.objectContaining({ id: fakeUuid("review-file"), path: "/drafts/review.md", diff_type: "added", + before_commit_id: expect.any(String), + after_commit_id: expect.any(String), }), ]); + expect(workingFiles[0]?.before_commit_id).not.toBe( + workingFiles[0]?.after_commit_id, + ); // The file's descriptor and content rows count; the workspace's own // key-value write does not. expect(await selectWorkingChangeCount(lix).execute()).toEqual([ diff --git a/src/queries.ts b/src/queries.ts index 15016f18..0ba4ab71 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -17,7 +17,7 @@ export type WorkingChangeCountRow = { file_count: number; }; -/** One changed file from lix_diff('lix_file'). */ +/** One changed file and its atomically pinned HOT epoch. */ export type FileDiffRow = { /** The file relation's typed primary key, projected by lix_diff. */ id: string; @@ -28,6 +28,8 @@ export type FileDiffRow = { /** Side paths: a modified row whose sides differ is a move/rename. */ from_path: string | null; to_path: string | null; + before_commit_id: string; + after_commit_id: string; }; export type CheckpointRow = { @@ -93,6 +95,8 @@ export function selectWorkingFileDiffs(lix: Lix) { .select([ "id", "diff_type", + "before_commit_id", + "after_commit_id", sql`coalesce(to_path, from_path)`.as("path"), "row_count", "from_path", @@ -113,7 +117,7 @@ export function selectWorkingChangeCount(lix: Lix) { } function workingFileDiffTable() { - return sql`lix_diff('lix_file')`; + return sql`lix_working_diff('lix_file')`; } /** diff --git a/src/shell/layout-shell.test.tsx b/src/shell/layout-shell.test.tsx index ccd0db68..6927f47e 100644 --- a/src/shell/layout-shell.test.tsx +++ b/src/shell/layout-shell.test.tsx @@ -521,6 +521,7 @@ describe("diff review navigation", () => { }), ).toHaveAttribute("aria-checked", "false"); }); + const reviewOpenExecute = vi.spyOn(lix, "execute"); await act(async () => { fireEvent.click( screen.getByRole("button", { @@ -531,6 +532,18 @@ describe("diff review navigation", () => { expect( await screen.findByRole("button", { name: /^Checkpoint/ }), ).toBeVisible(); + expect( + reviewOpenExecute.mock.calls.some(([statement]) => + String(statement).includes("lix_working_diff('lix_file')"), + ), + ).toBe(true); + expect( + reviewOpenExecute.mock.calls.some(([statement]) => + String(statement).includes( + "lix_latest_checkpoint_commit_id() AS before_commit_id", + ), + ), + ).toBe(false); const reviewFloat = document.querySelector( ".external-write-review-actions", ); diff --git a/src/shell/layout-shell.tsx b/src/shell/layout-shell.tsx index e7931609..469733a2 100644 --- a/src/shell/layout-shell.tsx +++ b/src/shell/layout-shell.tsx @@ -3537,23 +3537,26 @@ function LayoutShellLoadedContentResolved({ workingReviewOpeningRef.current = true; void (async () => { // The review window is always base-to-head, where the base is the - // latest checkpoint or the repository's empty root — exactly what - // lix_latest_checkpoint_commit_id() resolves. One lix_diff call - // yields the changed files with side-resolved paths — removed - // files keep their pre-deletion path from the base side. - const rangeResult = await lix.execute( - `SELECT lix_latest_checkpoint_commit_id() AS before_commit_id, - lix_active_branch_commit_id() AS after_commit_id`, - ); - const beforeCommitId = rangeResult.rows[0]?.before_commit_id; - const headCommitId = rangeResult.rows[0]?.after_commit_id; + // latest checkpoint or the repository's empty root. The HOT-only + // relation pins that epoch and the changed rows in the same statement, + // so a concurrent sync cannot pair rows from one head with coordinates + // from another. + const workingDiffs = await selectWorkingFileDiffs(lix).execute(); + const firstWorkingDiff = workingDiffs[0]; + if (!firstWorkingDiff) return; + const beforeCommitId = firstWorkingDiff.before_commit_id; + const headCommitId = firstWorkingDiff.after_commit_id; if ( - typeof beforeCommitId !== "string" || - typeof headCommitId !== "string" + workingDiffs.some( + (row) => + row.before_commit_id !== beforeCommitId || + row.after_commit_id !== headCommitId, + ) ) { - return; + throw new Error( + "working diff returned rows from multiple HOT epochs", + ); } - const workingDiffs = await selectWorkingFileDiffs(lix).execute(); // Every changed file joins the review — kinds without a content // diff (drawings, images, pdfs) still review, checkpoint, and // revert at file granularity. Filtering them out strands their From f46d54aaa2f3282ace96f78156e3e2f4e1a57823 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 20:44:25 +0000 Subject: [PATCH 2/7] test: assert hot working review query boundary --- src/extensions/files/index.test.tsx | 3 ++- src/shell/layout-shell.test.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensions/files/index.test.tsx b/src/extensions/files/index.test.tsx index b82faba6..f2461524 100644 --- a/src/extensions/files/index.test.tsx +++ b/src/extensions/files/index.test.tsx @@ -1273,7 +1273,8 @@ function workingDiffSqlCalls(calls: readonly unknown[][]): string[] { .map(([sql]) => sql) .filter( (sql): sql is string => - typeof sql === "string" && sql.includes("lix_diff('lix_file'"), + typeof sql === "string" && + sql.includes("lix_working_diff('lix_file'"), ); } diff --git a/src/shell/layout-shell.test.tsx b/src/shell/layout-shell.test.tsx index 6927f47e..b0ac456c 100644 --- a/src/shell/layout-shell.test.tsx +++ b/src/shell/layout-shell.test.tsx @@ -690,6 +690,7 @@ describe("diff review navigation", () => { activeHistoryInstance, ); const execute = vi.spyOn(lix, "execute"); + execute.mockClear(); fireEvent.click(screen.getByRole("button", { name: /^Checkpoint/ })); expect( await screen.findByRole("button", { From 1a181ca6418397415cb4800a807a1e083bd7e628 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 20:55:50 +0000 Subject: [PATCH 3/7] chore: pin Lix certified hot state dependency --- vendor/lix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/lix b/vendor/lix index 72eeab71..1fb49a8a 160000 --- a/vendor/lix +++ b/vendor/lix @@ -1 +1 @@ -Subproject commit 72eeab71542991f8e182aedfb5ee3a783a7c4f49 +Subproject commit 1fb49a8ab05a506313a44f36756c45943a707022 From 8563b2b95de826db5e50d886ac38929758ad4257 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 00:25:14 +0000 Subject: [PATCH 4/7] feat: pin review mode to certified hot epochs --- package.json | 2 +- src/extension-api.ts | 47 ++-- .../external-write-review-controls.test.tsx | 32 ++- .../external-write-review-controls.tsx | 20 +- src/extensions/csv/index.tsx | 207 ++++++++++++------ src/extensions/excalidraw/index.tsx | 117 +++++++--- src/extensions/html/index.tsx | 27 ++- src/extensions/image/index.tsx | 75 ++++++- .../markdown/editor/create-editor.test.ts | 7 +- src/extensions/markdown/index.test.tsx | 12 + src/extensions/markdown/index.tsx | 183 +++++++++++++--- .../review/markdown-diff-preview.test.tsx | 41 ++++ .../markdown/review/markdown-diff-preview.tsx | 156 ++++++++++--- src/extensions/pdf/index.tsx | 46 +++- src/extensions/text/index.test.tsx | 39 ++++ src/extensions/text/index.tsx | 121 +++++++--- src/extensions/video/index.tsx | 75 ++++++- src/file-preview.tsx | 2 +- src/lib/lix-diff-commands.test.ts | 68 +++++- src/lib/lix-diff-commands.ts | 25 ++- src/queries.test.ts | 12 + src/queries.ts | 21 ++ src/shell/external-write-review-history.ts | 126 ++++++++++- src/shell/layout-shell.tsx | 138 ++++++++---- vendor/lix | 2 +- 25 files changed, 1315 insertions(+), 286 deletions(-) create mode 100644 src/extensions/markdown/review/markdown-diff-preview.test.tsx diff --git a/package.json b/package.json index d7d9c18b..79cb3ccf 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "ci": "pnpm run build:lix && pnpm run build && pnpm test && pnpm --dir fixtures/consumer build" }, "peerDependencies": { - "@lix-js/sdk": ">=0.14.0 <0.16.0", + "@lix-js/sdk": ">=0.15.0 <0.16.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/src/extension-api.ts b/src/extension-api.ts index 8d8dd8ac..e41dd9a1 100644 --- a/src/extension-api.ts +++ b/src/extension-api.ts @@ -193,25 +193,41 @@ export type AtelierDocumentsApi = { * scrolling, so hosts control spacing and clipping. Previews load their own * content from the given identity; a live target stays live. * - * One shape covers every read surface: - * - neither `targetCommitId` nor `diff` — the live document; - * - `targetCommitId` alone — the document as of that commit; - * - `diff` alone — the change from its base to the live document; - * - both — the change between two commits. + * Every diff pins both sides. A working diff carries the exact HOT epoch that + * certified its bytes; an immutable diff names its target commit. There is no + * "base commit to implicit live target" mode because that can combine rows + * from different repository generations. */ -export type AtelierFilePreviewProps = { +type AtelierFilePreviewIdentity = { readonly lix: Lix; readonly fileId: string; readonly filePath: string; - /** Render the document as of this commit; absent — the live document. */ - readonly targetCommitId?: string | null; - /** - * Render the change from this base to the target (or live) state. A null - * base means the file was added — the before side is empty by definition. - */ - readonly diff?: { readonly baseCommitId: string | null } | null; }; +export type AtelierFilePreviewProps = AtelierFilePreviewIdentity & + ( + | { + /** No target and no diff renders the current live document. */ + readonly targetCommitId?: null; + readonly diff?: null; + } + | { + /** Render an immutable target, optionally diffed from an immutable base. */ + readonly targetCommitId: string; + readonly diff?: { readonly baseCommitId: string | null } | null; + } + | { + readonly targetCommitId?: null; + /** Render both sides from one certified HOT working epoch. */ + readonly diff: { + readonly workingEpoch: { + readonly beforeCommitId: string; + readonly afterCommitId: string; + }; + }; + } + ); + export type AtelierEvent = | { type: "document_open_attempted"; @@ -286,6 +302,11 @@ export type AtelierDiffFile = { readonly changeKind: "added" | "modified" | "removed"; /** Set when a modified file's side paths differ: a move/rename. */ readonly movedFromPath?: string; + /** Certified HOT epoch for a mutable working diff. */ + readonly workingEpoch?: { + readonly beforeCommitId: string; + readonly afterCommitId: string; + }; /** Present when the session reviews external writes (mutable target). */ readonly review?: { readonly id: string; diff --git a/src/extension-runtime/external-write-review-controls.test.tsx b/src/extension-runtime/external-write-review-controls.test.tsx index bd2e2d88..b4eac972 100644 --- a/src/extension-runtime/external-write-review-controls.test.tsx +++ b/src/extension-runtime/external-write-review-controls.test.tsx @@ -102,7 +102,11 @@ describe("ExternalWriteReviewControls", () => { // Both verbs apply to the selection. fireEvent.click(screen.getByRole("button", { name: "Undo" })); expect(undo).toHaveBeenCalledWith(["file-launch"]); - fireEvent.click(screen.getByRole("button", { name: "Checkpoint" })); + const checkpoint = await screen.findByRole("button", { + name: "Checkpoint", + }); + await waitFor(() => expect(checkpoint).toBeEnabled()); + fireEvent.click(checkpoint); await waitFor(() => expect(primary).toHaveBeenCalledWith(["file-launch"])); // Committing closes the list and restores the viewed-file default. expect(screen.queryByRole("checkbox")).toBeNull(); @@ -240,6 +244,32 @@ describe("ExternalWriteReviewControls", () => { expect(undo).not.toHaveBeenCalled(); }); + test("surfaces a stale Undo rejection without an unhandled promise", async () => { + const undo = vi.fn(async () => { + throw new Error("The working diff changed. Reopen the review."); + }); + render( + , + ); + + const button = screen.getByRole("button", { name: "Undo" }); + fireEvent.click(button); + await waitFor(() => + expect(button).toHaveAttribute( + "title", + "The working diff changed. Reopen the review.", + ), + ); + expect(button).toBeEnabled(); + }); + test("puts the Esc Exit control at the far left of the float", () => { const exit = vi.fn(); render( diff --git a/src/extension-runtime/external-write-review-controls.tsx b/src/extension-runtime/external-write-review-controls.tsx index a00e67c7..4c882810 100644 --- a/src/extension-runtime/external-write-review-controls.tsx +++ b/src/extension-runtime/external-write-review-controls.tsx @@ -185,6 +185,20 @@ export function ExternalWriteReviewControls({ readOnly, selectionIds, ]); + const runUndo = useCallback(async () => { + if (readOnly || !onUndo || isCommitting || !hasSelection) return; + setCommitError(null); + setIsCommitting(true); + try { + await onUndo(selectionIds); + } catch (cause) { + setCommitError( + cause instanceof Error ? cause.message : "The action failed", + ); + } finally { + setIsCommitting(false); + } + }, [hasSelection, isCommitting, onUndo, readOnly, selectionIds]); useEffect(() => { if (!isActive) return; @@ -419,10 +433,12 @@ export function ExternalWriteReviewControls({