From c98b112534394800ddd289fd47b00900f666e4af Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 14 Aug 2026 16:44:00 -0500 Subject: [PATCH 1/4] Upgrade @pierre/diffs to 1.3.5 --- package-lock.json | 233 ++++++++++++++++++++++++++++++++++++++-------- package.json | 2 +- src/App.tsx | 17 ++-- 3 files changed, 204 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7955cd..0c89704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@commander-js/extra-typings": "^15.0.0", "@hono/node-server": "^2.1.1", "@hono/zod-validator": "^0.9.0", - "@pierre/diffs": "^1.2.9", + "@pierre/diffs": "^1.3.5", "@tanstack/react-query": "^5.96.0", "@types/node": "^22", "@types/react": "^19.2.14", @@ -951,19 +951,19 @@ } }, "node_modules/@pierre/diffs": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.9.tgz", - "integrity": "sha512-nqpU6BGb6mUiYnu0lDG2BVH0aKFDCQpFD/SwcY5spy395RVKJDmS5uk0mE1q+EPq1cqOEdo72DUXmty4VsvaVA==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.3.5.tgz", + "integrity": "sha512-BhaLEiUvR+BdIyOYdogA4JLQjluWPubuwySmmIqEkcE0FwIWRbgJgHNC/r884dlxEr89fO4hTk/sBln0a7NSOw==", "dev": true, "license": "apache-2.0", "dependencies": { - "@pierre/theme": "1.0.3", - "@pierre/theming": "0.0.1", - "@shikijs/transformers": "^3.0.0", - "diff": "8.0.3", + "@pierre/theme": "2.0.0", + "@pierre/theming": "1.0.1", + "@shikijs/transformers": "^3.0.0 || ^4.0.0", + "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", - "shiki": "^3.0.0" + "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", @@ -971,23 +971,23 @@ } }, "node_modules/@pierre/theme": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz", - "integrity": "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-2.0.0.tgz", + "integrity": "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==", "dev": true, - "license": "MIT", + "license": "apache-2.0", "engines": { "vscode": "^1.0.0" } }, "node_modules/@pierre/theming": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.1.tgz", - "integrity": "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-1.0.1.tgz", + "integrity": "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==", "dev": true, "license": "apache-2.0", "peerDependencies": { - "@pierre/theme": "^1.0.0", + "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", @@ -1318,38 +1318,143 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/langs": { - "version": "3.23.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0" + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "3.23.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0" + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/transformers": { @@ -1443,7 +1548,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2486,7 +2593,9 @@ } }, "node_modules/diff": { - "version": "8.0.3", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3097,16 +3206,20 @@ } }, "node_modules/oniguruma-parser": { - "version": "0.12.1", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "dev": true, "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.5", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "dev": true, "license": "MIT", "dependencies": { - "oniguruma-parser": "^0.12.1", + "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } @@ -3315,6 +3428,8 @@ }, "node_modules/regex": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "dev": true, "license": "MIT", "dependencies": { @@ -3323,6 +3438,8 @@ }, "node_modules/regex-recursion": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "dev": true, "license": "MIT", "dependencies": { @@ -3331,6 +3448,8 @@ }, "node_modules/regex-utilities": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "dev": true, "license": "MIT" }, @@ -3433,18 +3552,54 @@ "license": "MIT" }, "node_modules/shiki": { - "version": "3.23.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/shiki/node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/shiki/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/siginfo": { diff --git a/package.json b/package.json index 35534c0..0b5d05d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@commander-js/extra-typings": "^15.0.0", "@hono/node-server": "^2.1.1", "@hono/zod-validator": "^0.9.0", - "@pierre/diffs": "^1.2.9", + "@pierre/diffs": "^1.3.5", "@tanstack/react-query": "^5.96.0", "@types/node": "^22", "@types/react": "^19.2.14", diff --git a/src/App.tsx b/src/App.tsx index fe7e484..72def74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -953,14 +953,15 @@ function DiffView() { [patch], ) - // Hunk expansion needs "non-partial" diffs built from full file contents. - // The diffs library can't lazily fetch context on expand — it reads revealed - // lines straight out of the full content arrays and exposes no expansion hook - // (confirmed through 1.3.0-beta) — so the contents must be loaded before its - // expand controls can work. To avoid fetching files the user never looks at, - // a file is fetched only once it enters the virtualization window (viewport + - // buffer). onPostRender marks rendered items "seen"; seeing a new one bumps - // state so the queries below re-evaluate `enabled` and fire. + // Hunk expansion needs "non-partial" diffs built from full file contents: + // the library reads revealed lines straight out of the full content arrays, + // so the contents must be loaded before its expand controls can work. To + // avoid fetching files the user never looks at, a file is fetched only once + // it enters the virtualization window (viewport + buffer). onPostRender marks + // rendered items "seen"; seeing a new one bumps state so the queries below + // re-evaluate `enabled` and fire. (@pierre/diffs 1.3 added a `loadDiffFiles` + // option that hydrates a partial diff on demand and could replace this whole + // path, plus the expand-chevron placeholder CSS below.) const seenFilesRef = useRef(new Set()) const [, bumpSeen] = useState(0) const markSeen = useCallback((id: string) => { From e79d1dedede451f22f4321824e637b82c614c7e3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 14 Aug 2026 17:05:24 -0500 Subject: [PATCH 2/4] Run diff syntax highlighting in a worker pool --- src/App.tsx | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 72def74..bfba990 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,8 +26,9 @@ import { import type { ReactElement } from 'react' import { Tooltip } from '@base-ui/react/tooltip' import { parsePatchFiles, parseDiffFromFile } from '@pierre/diffs' -import { CodeView } from '@pierre/diffs/react' +import { CodeView, WorkerPoolContextProvider } from '@pierre/diffs/react' import type { CodeViewHandle } from '@pierre/diffs/react' +import DiffsHighlightWorker from '@pierre/diffs/worker/worker.js?worker' import type { CodeViewDiffItem, CodeViewItem, @@ -57,6 +58,11 @@ import { const queryClient = new QueryClient() +// Always the dual theme: Shiki tokenizes both up front and switching themes is +// purely a CSS flip (a single theme would retokenize on every toggle). Shared +// with the worker pool's highlighter, which is initialized separately. +const DIFF_THEME = { light: 'github-light-default', dark: 'github-dark-default' } as const + // The /api/theme.js boot script in index.html stamps the stored theme // preference onto as data-theme before this bundle loads, so CSS pins // color-scheme (and our light-dark() tokens resolve to the forced side) @@ -1371,12 +1377,10 @@ function DiffView() { const options = useMemo>( () => ({ - // Always the dual theme: Shiki tokenizes both up front and switching - // themes is purely a CSS flip (a single theme would retokenize on every - // toggle). themeType pins the shadow roots' color-scheme — they don't - // inherit the page's value ('system' leaves their :host default of - // `light dark`, following the OS). - theme: { light: 'github-light-default', dark: 'github-dark-default' }, + // themeType pins the shadow roots' color-scheme — they don't inherit the + // page's value ('system' leaves their :host default of `light dark`, + // following the OS). + theme: DIFF_THEME, themeType: theme, diffStyle, // The library lays out collapsed files (and unmeasured estimates) from @@ -1736,12 +1740,30 @@ function DiffView() { ) } +// Without this provider CodeView's worker pool is undefined and Shiki +// tokenizes on the main thread, which stalls the UI for hundreds of ms every +// time a big file's virtualization window moves (TextMate tokenization has to +// walk the file from the top to reach the window). The pool's highlighter is +// initialized separately from the CodeView options, so it needs the same theme +// pair — miss it and the workers hand back tokens in the library's default +// theme. +const poolOptions = { + workerFactory: () => new DiffsHighlightWorker(), + poolSize: 2, +} +const highlighterOptions = { theme: DIFF_THEME } + export function App() { return ( - - - + + + + + ) } From 30d3a0f812fdb6cc3403fc77bd49b43740feb3c7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 14 Aug 2026 17:41:07 -0500 Subject: [PATCH 3/4] Load full file contents on expand instead of on render --- src/App.tsx | 174 +++++++++++++++++++++++----------------------------- 1 file changed, 76 insertions(+), 98 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bfba990..fd5df3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -222,36 +222,6 @@ const REVIEW_CSS = ` } ` -// The diffs library's expand chevron sprite (a down chevron), as a mask so we -// can paint a placeholder copy in the same color. -const EXPAND_CHEVRON = - "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.47 5.47a.75.75 0 0 1 1.06 0L8 8.94l3.47-3.47a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06'/%3E%3C/svg%3E\")" - -// Until a file's full contents load it renders as a "partial" diff (host tagged -// data-skepsis-partial in onPostRender), whose hunk separators have no expand -// control — and the library lays the "N unmodified lines" label flush in the -// gutter. When contents arrive the separator becomes expandable: a chevron -// appears and the label jumps one gutter-width to the right. To avoid that pop, -// give the partial separators the same gutter grid and a dimmed ghost chevron, -// so loading only lights the chevron up in place. -const EXPAND_PLACEHOLDER_CSS = ` - :host([data-skepsis-partial]) [data-separator]:not(:has([data-expand-button])) [data-separator-wrapper] { - display: grid; - grid-template-columns: var(--diffs-column-number-width) 1fr; - align-items: center; - } - :host([data-skepsis-partial]) [data-separator]:not(:has([data-expand-button])) [data-separator-wrapper]::before { - content: ''; - width: 16px; - height: 16px; - justify-self: center; - background-color: var(--diffs-fg-number); - opacity: 0.4; - -webkit-mask: ${EXPAND_CHEVRON} center / 16px no-repeat; - mask: ${EXPAND_CHEVRON} center / 16px no-repeat; - } -` - /** * Tag addition lines within review-block ranges with `data-review-comment` so * REVIEW_CSS can style them. `node` is the item's `diffs-container` element; @@ -959,27 +929,40 @@ function DiffView() { [patch], ) - // Hunk expansion needs "non-partial" diffs built from full file contents: - // the library reads revealed lines straight out of the full content arrays, - // so the contents must be loaded before its expand controls can work. To - // avoid fetching files the user never looks at, a file is fetched only once - // it enters the virtualization window (viewport + buffer). onPostRender marks - // rendered items "seen"; seeing a new one bumps state so the queries below - // re-evaluate `enabled` and fire. (@pierre/diffs 1.3 added a `loadDiffFiles` - // option that hydrates a partial diff on demand and could replace this whole - // path, plus the expand-chevron placeholder CSS below.) - const seenFilesRef = useRef(new Set()) - const [, bumpSeen] = useState(0) - const markSeen = useCallback((id: string) => { - if (seenFilesRef.current.has(id)) return - seenFilesRef.current.add(id) - bumpSeen((n) => n + 1) - }, []) - - // When contents arrive the file upgrades to an expandable diff. Files with no - // hunks (binary, pure renames) have nothing to expand, and collapsed files - // can't show expansion, so neither is fetched. + // Hunk expansion needs "non-partial" diffs built from full file contents: the + // library reads revealed lines straight out of the full content arrays. It + // fetches them itself through the `loadDiffFiles` option below, on the first + // click of an expand control — so a file the user never expands never pays + // for the fetch or the full-file tokenization. The cost is that an unexpanded + // file stays a partial diff, and Shiki tokenizes only its patch lines: inside + // a construct the patch truncates (a JSON object, a block comment) colors can + // differ from the full-file result until the file is expanded. const expandable = data?.expandable ?? false + + // The fetch behind `loadDiffFiles`. Routed through react-query's cache (keyed + // by content hash, same key as the expand-all queries below) so the two + // expansion paths share one request per file version. + const fetchContents = useCallback( + (file: FileDiffMetadata) => { + const params = new URLSearchParams({ path: file.name }) + if (file.prevName) params.set('oldPath', file.prevName) + return qc.fetchQuery({ + queryKey: ['file-contents', file.name, data?.fileHashes[file.name]] as const, + queryFn: () => apiFetch(`/api/file-contents?${params}`), + staleTime: Infinity, + }) + }, + [qc, data], + ) + // Held in a ref so the CodeView options object (which re-renders every item + // when its identity changes) doesn't depend on the current file hashes. + const fetchContentsRef = useRef(fetchContents) + fetchContentsRef.current = fetchContents + + // The header's expand-all-lines button needs the whole file as a single hunk, + // which the library's incremental expansion can't produce — so those files + // still get fetched here and re-parsed at whole-file context. Only files the + // user actually expanded are fetched. const contentQueries = useQueries({ queries: patchFiles.map((f) => { const hash = data?.fileHashes[f.name] @@ -988,11 +971,7 @@ function DiffView() { return { queryKey: ['file-contents', f.name, hash] as const, queryFn: () => apiFetch(`/api/file-contents?${params}`), - enabled: - expandable && - f.hunks.length > 0 && - seenFilesRef.current.has(f.name) && - !(collapsed[f.name] ?? false), + enabled: expandable && f.hunks.length > 0 && (expandedFiles[f.name] ?? false), staleTime: Infinity, } }), @@ -1009,43 +988,33 @@ function DiffView() { return map }, [patchFiles, contentQueries]) - // Upgrade files whose contents have loaded to non-partial (expandable) diffs; - // others keep their partial patch parse. parseDiffFromFile (jsdiff) is cached - // per file+content so it doesn't re-run on unrelated renders. + // Re-parse expand-all files at whole-file context; everything else keeps its + // patch parse (which the library hydrates in place as needed). + // parseDiffFromFile (jsdiff) is cached per file+content so it doesn't re-run + // on unrelated renders. const parsedCacheRef = useRef( - new Map< - string, - { old: string | null; new: string | null; expanded: boolean; diff: FileDiffMetadata } - >(), + new Map(), ) const files = useMemo(() => { return patchFiles.map((f) => { + if (!(expandedFiles[f.name] ?? false)) return f const c = contentsByFile.get(f.name) if (!c || (c.oldContents == null && c.newContents == null)) return f - const isExpanded = expandedFiles[f.name] ?? false const cached = parsedCacheRef.current.get(f.name) - if ( - cached && - cached.old === c.oldContents && - cached.new === c.newContents && - cached.expanded === isExpanded - ) { + if (cached && cached.old === c.oldContents && cached.new === c.newContents) { return cached.diff } - // context: 3 matches git's default so the visible context doesn't jump - // when a file upgrades from its patch parse to the full-content diff. - // Expand-all uses whole-file context so the entire file shows as one hunk. + // Whole-file context so the entire file renders as one hunk. const diff = normalizeFileType( parseDiffFromFile( { name: f.prevName ?? f.name, contents: c.oldContents ?? '' }, { name: f.name, contents: c.newContents ?? '' }, - { context: isExpanded ? Number.MAX_SAFE_INTEGER : 3 }, + { context: Number.MAX_SAFE_INTEGER }, ), ) parsedCacheRef.current.set(f.name, { old: c.oldContents, new: c.newContents, - expanded: isExpanded, diff, }) return diff @@ -1147,19 +1116,23 @@ function DiffView() { // Build the CodeView item list. CodeView only re-renders an item when its // `version` changes, so we bump version whenever any rendered input for a - // file changes: its content hash (which also covers review annotations, - // since those are derived from diff content), the composing-form line, or - // its collapsed state. diffStyle is deliberately not in the key: an - // `options` change makes the library re-render every item on its own. + // file changes: the fileDiff object itself (expand-all swaps in a whole-file + // re-parse), its content hash (which also covers review annotations, since + // those are derived from diff content), the composing-form line, or its + // collapsed state. diffStyle is deliberately not in the key: an `options` + // change makes the library re-render every item on its own. Neither is + // isPartial: loadDiffFiles hydration flips it by mutating the fileDiff in + // place, and the library re-renders the item itself when it does. // // The map is mutated during render, which is safe here: writes are // idempotent per key and versions only increase, so a StrictMode double // render or a discarded concurrent render can't produce an inconsistent // version for a given key. - const versionsRef = useRef(new Map()) + const versionsRef = useRef( + new Map(), + ) const items = useMemo[]>(() => { if (!data) return [] - const { fileHashes } = data return files.map((fileDiff) => { const name = fileDiff.name const syntax = data.commentSyntaxes[name] @@ -1174,16 +1147,12 @@ function DiffView() { }) } const isCollapsed = collapsed[name] ?? false - const isExpanded = expandedFiles[name] ?? false const composingLine = composing?.file === name ? composing.line : -1 - // fileDiff.isPartial flips false once full contents load, upgrading the - // item to an expandable diff — bump the version so CodeView re-renders it. - // isExpanded is in the key too: expand-all swaps fileDiff for a re-parse - // at the same content hash, so nothing else here would move the version. - const key = `${fileHashes[name] ?? ''}|${composingLine}|${isCollapsed ? 1 : 0}|${fileDiff.isPartial ? 1 : 0}|${isExpanded ? 1 : 0}` + const key = `${data.fileHashes[name] ?? ''}|${composingLine}|${isCollapsed ? 1 : 0}` const prev = versionsRef.current.get(name) - const version = !prev || prev.key !== key ? (prev?.version ?? 0) + 1 : prev.version - if (!prev || prev.key !== key) versionsRef.current.set(name, { key, version }) + const changed = !prev || prev.key !== key || prev.fileDiff !== fileDiff + const version = changed ? (prev?.version ?? 0) + 1 : prev.version + if (changed) versionsRef.current.set(name, { key, fileDiff, version }) return { id: name, type: 'diff', @@ -1193,7 +1162,7 @@ function DiffView() { version, } }) - }, [files, data, composing, collapsed, expandedFiles, commentsEnabled]) + }, [files, data, composing, collapsed, commentsEnabled]) itemsRef.current = items // The header highlight falls back to the first file before any focus exists @@ -1402,20 +1371,29 @@ function DiffView() { // entirely, so the slot never exists and the portal has nowhere to land. stickyHeaders: true, enableGutterUtility: commentsEnabled, - unsafeCSS: REVIEW_CSS + EXPAND_PLACEHOLDER_CSS, + // Hydrates a partial diff with full file contents on the first expand + // click, then re-renders the item itself. Setting it is also what makes + // the library draw expand controls on partial diffs at all, so leave it + // off when the diff range can't serve contents. + loadDiffFiles: expandable + ? async (fileDiff) => { + const c = await fetchContentsRef.current(fileDiff) + return { + oldFile: { + name: fileDiff.prevName ?? fileDiff.name, + contents: c.oldContents ?? '', + }, + newFile: { name: fileDiff.name, contents: c.newContents ?? '' }, + } + } + : undefined, + unsafeCSS: REVIEW_CSS, onGutterUtilityClick: (range, context) => gutterClickRef.current(range, context.item), // Re-tag review-comment lines whenever an item (re)renders. Runs per item // and re-derives tags from scratch, so pooled/recycled elements never keep // stale highlights from a previously-rendered file. onPostRender: (node, instance, phase, context) => { if (phase === 'unmount') return - // Mark the file seen so its full contents get fetched for expansion. - markSeen(context.item.id) - // Tag partial (not-yet-loaded) diffs so EXPAND_PLACEHOLDER_CSS can ghost - // an expand chevron into their separators and avoid layout pop on load. - if (context.item.type === 'diff') { - node.toggleAttribute('data-skepsis-partial', context.item.fileDiff.isPartial) - } tagReviewLines(node, reviewRanges(context.item.annotations)) // Re-apply the cursor selection: a re-rendered (version-bumped) or // pooled element loses its selection styling, and the library's own @@ -1431,7 +1409,7 @@ function DiffView() { } }, }), - [theme, diffStyle, commentsEnabled, markSeen], + [theme, diffStyle, commentsEnabled, expandable], ) // Keyboard shortcuts. File navigation (n/p) and the focused-file actions From 51362384821cc2fb9758ed10582cfb737742837d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 18 Aug 2026 16:59:34 -0500 Subject: [PATCH 4/4] minor bump to 0.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c89704..2e089b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@oxide/skepsis", - "version": "0.2.4", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@oxide/skepsis", - "version": "0.2.4", + "version": "0.3.0", "bin": { "skepsis": "dist/cli.js" }, diff --git a/package.json b/package.json index 0b5d05d..68fb159 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@oxide/skepsis", - "version": "0.2.4", + "version": "0.3.0", "repository": "github:oxidecomputer/skepsis", "bin": { "skepsis": "dist/cli.js"