diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77e804a71..ec347d4c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@monaco-editor/react': specifier: ^4.6.0 version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 @@ -874,105 +877,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1263,42 +1250,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1382,79 +1363,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1499,6 +1467,15 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -1525,35 +1502,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.0': resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.0': resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.0': resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.0': resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} @@ -3383,12 +3355,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -6568,6 +6540,14 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.23': {} + '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.0': diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 667df7b35..58c979046 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@monaco-editor/react": "^4.6.0", + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-autostart": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/src/web-ui/src/flow_chat/components/CodePreview.tsx b/src/web-ui/src/flow_chat/components/CodePreview.tsx index 1a4c3ccde..c54db9e8d 100644 --- a/src/web-ui/src/flow_chat/components/CodePreview.tsx +++ b/src/web-ui/src/flow_chat/components/CodePreview.tsx @@ -11,7 +11,7 @@ * 4. Large content can be truncated when exceeding limits */ -import React, { useMemo, memo, useRef, useEffect, useState, useCallback } from 'react'; +import React, { useMemo, memo, useRef, useEffect, useState, useCallback, useDeferredValue } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { getPrismLanguage } from '@/infrastructure/language-detection'; import { useTheme } from '@/infrastructure/theme'; @@ -66,7 +66,13 @@ export const CodePreview: React.FC = memo(({ const containerRef = useRef(null); const prevContentLengthRef = useRef(0); - + + // During streaming, content updates at high frequency. Defer the highlighted + // content passed to SyntaxHighlighter so that auto-scroll and cursor updates + // (which use the real content) remain responsive on the main thread while + // tokenization runs during browser idle time. + const deferredContent = useDeferredValue(content); + const [highlightedLine, setHighlightedLine] = useState(null); const detectedLanguage = useMemo(() => { @@ -160,7 +166,7 @@ export const CodePreview: React.FC = memo(({ opacity: isLight ? 0.88 : 0.6, }} > - {content} + {deferredContent} {/* Streaming cursor indicator */} diff --git a/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx b/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx index c1f57826e..c69ed0dd2 100644 --- a/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx +++ b/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx @@ -6,12 +6,14 @@ * 1. Avoid Monaco DiffEditor (too heavy) * 2. Use the diff library (npm: diff) for performance * 3. GitHub-style unified diff view - * 4. Syntax highlighting via react-syntax-highlighter - * 5. Timeout protection for large files + * 4. Token-first syntax highlighting: tokenize full content once via prismjs, + * split into per-line token arrays, render without per-line SyntaxHighlighter instances + * 5. Row virtualization via @tanstack/react-virtual: only visible rows are in the DOM */ -import React, { useMemo, memo, useRef, useCallback, useState } from 'react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import React, { useMemo, memo, useRef, useCallback, useState, CSSProperties } from 'react'; +import Prism from 'prismjs'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { diffLines, Change } from 'diff'; import { getPrismLanguage } from '@/infrastructure/language-detection'; import { useTheme } from '@/infrastructure/theme'; @@ -21,6 +23,9 @@ import './InlineDiffPreview.scss'; const log = createLogger('InlineDiffPreview'); +/** Estimated row height in px — must match CSS line-height × font-size. */ +const ROW_HEIGHT = 22; + export interface InlineDiffPreviewProps { /** Original content. */ originalContent: string; @@ -57,38 +62,146 @@ interface DiffLine { modifiedLineNumber?: number; } +/** A single token from Prism. */ +type PrismToken = string | Prism.Token; + +/** Per-line token array after splitting the full-content token stream. */ +type LineTokens = PrismToken[]; + +// --------------------------------------------------------------------------- +// Tokenization utilities +// --------------------------------------------------------------------------- + +/** + * Split a flat Prism token stream into per-line arrays. + * Handles nested Token.content recursively. + */ +function splitTokensByNewlines(tokens: PrismToken[]): LineTokens[] { + const lines: LineTokens[] = [[]]; + + function walk(token: PrismToken): void { + if (typeof token === 'string') { + const parts = token.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i] !== '') lines[lines.length - 1].push(parts[i]); + } + } else { + // Prism.Token with content that may be nested + const { type, content, alias } = token; + if (Array.isArray(content)) { + // Collect child tokens into a sub-array, then rewrap with correct type + const before = lines.length; + const startIdx = lines[lines.length - 1].length; + + for (const child of content) walk(child); + + // If all child tokens stayed on the same original line, merge them back + // into a single token to keep the structure compact. Otherwise the + // content already ended up split across lines — leave as-is. + if (lines.length === before) { + // Same line: replace what we appended with a re-wrapped token + const children = lines[lines.length - 1].splice(startIdx); + if (children.length > 0) { + lines[lines.length - 1].push(new Prism.Token(type, children, alias)); + } + } + } else if (typeof content === 'string') { + const parts = content.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i] !== '') { + lines[lines.length - 1].push(new Prism.Token(type, parts[i], alias)); + } + } + } + } + } + + for (const token of tokens) walk(token); + return lines; +} + +/** + * Tokenize a full content string with prismjs, return per-line token arrays. + * Falls back to plain-text lines when the language grammar is not registered. + */ +function tokenizeContent(content: string, language: string): LineTokens[] { + if (!content) return []; + const grammar = Prism.languages[language]; + if (!grammar) { + // Graceful fallback: split by lines, no highlighting + return content.split('\n').map(line => [line]); + } + try { + const tokens = Prism.tokenize(content, grammar); + return splitTokensByNewlines(tokens); + } catch { + return content.split('\n').map(line => [line]); + } +} + /** - * Compute line-level diff using the diff library. - * More performant than a custom LCS implementation (O(ND) vs O(nm)). + * Render a single Prism token as a React element. + * Mirrors what react-syntax-highlighter does internally. */ +function renderToken( + token: PrismToken, + stylesheet: Record, + key: string | number, +): React.ReactNode { + if (typeof token === 'string') return token; + + const aliases = Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : []; + const classNames = ['token', token.type, ...aliases]; + const style: CSSProperties = classNames.reduce((acc, cls) => { + return { ...acc, ...(stylesheet[`.${cls}`] ?? stylesheet[cls] ?? {}) }; + }, {}); + + const children = Array.isArray(token.content) + ? (token.content as PrismToken[]).map((child, i) => renderToken(child, stylesheet, i)) + : typeof token.content === 'string' + ? token.content + : null; + + return ( + + {children} + + ); +} + +/** + * Render a line's token array as React children. + */ +function renderTokenLine(tokens: LineTokens, stylesheet: Record): React.ReactNode { + if (!tokens || tokens.length === 0) return '\u00A0'; // non-breaking space for empty lines + return tokens.map((token, i) => renderToken(token, stylesheet, i)); +} + +// --------------------------------------------------------------------------- +// Diff computation (unchanged from original) +// --------------------------------------------------------------------------- + function computeLineDiff(originalContent: string, modifiedContent: string): DiffLine[] { const result: DiffLine[] = []; - + const changes: Change[] = diffLines(originalContent, modifiedContent); - + let originalLineNumber = 1; let modifiedLineNumber = 1; - + for (const change of changes) { - // Split into lines and drop the trailing empty line from split(). const lines = change.value.split('\n'); if (lines.length > 0 && lines[lines.length - 1] === '') { lines.pop(); } - + for (const line of lines) { if (change.added) { - result.push({ - type: 'added', - content: line, - modifiedLineNumber: modifiedLineNumber++, - }); + result.push({ type: 'added', content: line, modifiedLineNumber: modifiedLineNumber++ }); } else if (change.removed) { - result.push({ - type: 'removed', - content: line, - originalLineNumber: originalLineNumber++, - }); + result.push({ type: 'removed', content: line, originalLineNumber: originalLineNumber++ }); } else { result.push({ type: 'unchanged', @@ -99,78 +212,70 @@ function computeLineDiff(originalContent: string, modifiedContent: string): Diff } } } - + return result; } -/** - * Context-collapsed diff view. - * Hides long unchanged sections and keeps context around changes. - */ -function applyContextCollapsing(diffLines: DiffLine[], contextLines: number): DiffLine[] { - if (contextLines < 0) return diffLines; // Negative means show all lines. - - const result: DiffLine[] = []; +function applyContextCollapsing(lines: DiffLine[], contextLines: number): DiffLine[] { + if (contextLines < 0) return lines; + const changeIndices: number[] = []; - - diffLines.forEach((line, index) => { - if (line.type === 'added' || line.type === 'removed') { - changeIndices.push(index); - } + lines.forEach((line, index) => { + if (line.type === 'added' || line.type === 'removed') changeIndices.push(index); }); - + if (changeIndices.length === 0) { - return [{ - type: 'context-separator', - content: 'No differences; contents are identical.', - }]; + return [{ type: 'context-separator', content: 'No differences; contents are identical.' }]; } - + const showLine = new Set(); for (const idx of changeIndices) { - for (let i = Math.max(0, idx - contextLines); i <= Math.min(diffLines.length - 1, idx + contextLines); i++) { + for (let i = Math.max(0, idx - contextLines); i <= Math.min(lines.length - 1, idx + contextLines); i++) { showLine.add(i); } } - + + const result: DiffLine[] = []; let lastShownIndex = -1; - for (let i = 0; i < diffLines.length; i++) { + + for (let i = 0; i < lines.length; i++) { if (showLine.has(i)) { if (lastShownIndex >= 0 && i > lastShownIndex + 1) { - const skippedCount = i - lastShownIndex - 1; - result.push({ - type: 'context-separator', - content: `... omitted ${skippedCount} lines ...`, - }); + result.push({ type: 'context-separator', content: `... omitted ${i - lastShownIndex - 1} lines ...` }); } - result.push(diffLines[i]); + result.push(lines[i]); lastShownIndex = i; } } - + if (result.length > 0 && result[0].type !== 'context-separator') { const firstShownIdx = Array.from(showLine).sort((a, b) => a - b)[0]; if (firstShownIdx > 0) { - result.unshift({ - type: 'context-separator', - content: `... omitted first ${firstShownIdx} lines ...`, - }); + result.unshift({ type: 'context-separator', content: `... omitted first ${firstShownIdx} lines ...` }); } } - - if (lastShownIndex < diffLines.length - 1) { - const skippedCount = diffLines.length - 1 - lastShownIndex; + + if (lastShownIndex < lines.length - 1) { result.push({ type: 'context-separator', - content: `... omitted last ${skippedCount} lines ...`, + content: `... omitted last ${lines.length - 1 - lastShownIndex} lines ...`, }); } - + return result; } +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + /** * InlineDiffPreview component. + * + * Performance model: + * - Tokenize originalContent once (useMemo) + * - Tokenize modifiedContent once (useMemo) + * - Virtualize rows: only ~6 DOM nodes regardless of total line count */ export const InlineDiffPreview: React.FC = memo(({ originalContent, @@ -190,105 +295,87 @@ export const InlineDiffPreview: React.FC = memo(({ const containerRef = useRef(null); const [highlightedLine, setHighlightedLine] = useState(null); - + const detectedLanguage = useMemo(() => { if (language) return language; if (filePath) return getPrismLanguage(filePath); return 'text'; }, [language, filePath]); - - const diffLines = useMemo(() => { + + // Compute diff line list (fast, O(ND)) + const diffLineList = useMemo(() => { try { const rawDiff = computeLineDiff(originalContent, modifiedContent); return applyContextCollapsing(rawDiff, contextLines); } catch (error) { log.error('Diff computation failed', error); - return [{ - type: 'context-separator' as const, - content: 'Diff computation failed; file may be too large.', - }]; + return [{ type: 'context-separator' as const, content: 'Diff computation failed; file may be too large.' }]; } }, [originalContent, modifiedContent, contextLines]); - - const handleLineClick = useCallback((index: number, line: DiffLine) => { - if (line.type === 'context-separator') return; - - setHighlightedLine(prev => prev === index ? null : index); - - if (onLineClick) { - const lineNum = line.type === 'removed' ? line.originalLineNumber : line.modifiedLineNumber; - const type = line.type === 'removed' ? 'original' : 'modified'; - if (lineNum) { - onLineClick(lineNum, type); + + // Tokenize each content once — O(content_length), not O(lines²) + const originalLineTokens = useMemo( + () => tokenizeContent(originalContent, detectedLanguage), + [originalContent, detectedLanguage], + ); + const modifiedLineTokens = useMemo( + () => tokenizeContent(modifiedContent, detectedLanguage), + [modifiedContent, detectedLanguage], + ); + + // Build stylesheet from prism style for token coloring + const stylesheet = useMemo>(() => { + // prismStyle keys are CSS selectors like "token comment", ".token.comment", etc. + // Normalize to ".className" → CSSProperties for renderToken lookup. + const map: Record = {}; + for (const [selector, styles] of Object.entries(prismStyle)) { + // e.g. "token comment" → entries for "token" and "comment" + const parts = selector.split(/\s+|\./).filter(Boolean); + for (const part of parts) { + if (part && !map[part]) map[part] = styles as CSSProperties; + if (part && !map[`.${part}`]) map[`.${part}`] = styles as CSSProperties; } } - }, [onLineClick]); - - const renderLine = useCallback((line: DiffLine, index: number) => { - if (line.type === 'context-separator') { - return ( -
- - - {line.content} - -
- ); - } - - const isHighlighted = highlightedLine === index; - const lineClass = `diff-line diff-line--${line.type} ${isHighlighted ? 'diff-line--highlighted' : ''}`; - - const origNum = line.originalLineNumber ?? ''; - const modNum = line.modifiedLineNumber ?? ''; + return map; + }, [prismStyle]); - const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; + // Line number → token array lookup helpers + const getTokensForLine = useCallback( + (line: DiffLine): LineTokens => { + if (line.type === 'removed') { + const idx = (line.originalLineNumber ?? 1) - 1; + return originalLineTokens[idx] ?? [line.content]; + } + if (line.type === 'added' || line.type === 'unchanged') { + const idx = (line.modifiedLineNumber ?? 1) - 1; + return modifiedLineTokens[idx] ?? [line.content]; + } + return [line.content]; + }, + [originalLineTokens, modifiedLineTokens], + ); + + // Virtualizer + const virtualizer = useVirtualizer({ + count: diffLineList.length, + getScrollElement: () => containerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 3, + }); + + const handleLineClick = useCallback( + (index: number, line: DiffLine) => { + if (line.type === 'context-separator') return; + setHighlightedLine(prev => (prev === index ? null : index)); + if (onLineClick) { + const lineNum = line.type === 'removed' ? line.originalLineNumber : line.modifiedLineNumber; + const type = line.type === 'removed' ? 'original' : 'modified'; + if (lineNum) onLineClick(lineNum, type); + } + }, + [onLineClick], + ); - return ( -
handleLineClick(index, line)} - > - {showLineNumbers && ( - lineNumberMode === 'single' ? ( - - {index + 1} - - ) : ( - - {origNum} - {modNum} - - ) - )} - {showPrefix && {prefix}} - - - {line.content || ' '} - - -
- ); - }, [detectedLanguage, prismStyle, showLineNumbers, lineNumberMode, showPrefix, highlightedLine, handleLineClick]); - if (!originalContent && !modifiedContent) { return (
@@ -296,15 +383,92 @@ export const InlineDiffPreview: React.FC = memo(({
); } - + + const totalHeight = virtualizer.getTotalSize(); + const virtualItems = virtualizer.getVirtualItems(); + return (
- {diffLines.map((line, index) => renderLine(line, index))} + {/* Spacer div that gives the scrollable area its full virtual height */} +
+ {virtualItems.map(virtualRow => { + const line = diffLineList[virtualRow.index]; + const isHighlighted = highlightedLine === virtualRow.index; + + if (line.type === 'context-separator') { + return ( +
+ + {line.content} +
+ ); + } + + const lineClass = [ + 'diff-line', + `diff-line--${line.type}`, + isHighlighted ? 'diff-line--highlighted' : '', + ] + .filter(Boolean) + .join(' '); + + const origNum = line.originalLineNumber ?? ''; + const modNum = line.modifiedLineNumber ?? ''; + const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; + const lineTokens = getTokensForLine(line); + + return ( +
handleLineClick(virtualRow.index, line)} + > + {showLineNumbers && + (lineNumberMode === 'single' ? ( + + {virtualRow.index + 1} + + ) : ( + + {origNum} + {modNum} + + ))} + {showPrefix && {prefix}} + + {renderTokenLine(lineTokens, stylesheet)} + +
+ ); + })} +
);