diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 5a86785e..eda371ac 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -29,8 +29,14 @@ import { useDeviceInfo } from '@/lib/device'; const SIDE_BY_SIDE_MIN_WIDTH = 1100; const DIFF_REQUEST_TIMEOUT_MS = 15000; -// Memory optimization: limit concurrent expanded diffs in stacked view -const STACKED_VIEW_MAX_EXPANDED_DIFFS = 10; +// Perf: limit concurrent expanded diffs in stacked view. +// Expanding many diffs mounts many Pierre instances + lots of DOM. +const getStackedViewDefaultExpandedCount = (fileCount: number): number => { + if (fileCount <= 6) return fileCount; + if (fileCount <= 12) return 6; + if (fileCount <= 25) return 4; + return 2; +}; type FileEntry = GitStatus['files'][number] & { insertions: number; @@ -771,7 +777,7 @@ const MultiFileDiffEntry = React.memo(({ Loading diff… ) : null} - {diffData ? ( + {isExpanded && diffData ? ( { const renderStackedDiffView = () => { if (!effectiveDirectory) return null; + const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length); + return (
{showFileSidebar && ( @@ -1087,7 +1095,7 @@ export const DiffView: React.FC = () => { isSelected={file.path === selectedFile} onSelect={handleSelectFile} registerSectionRef={registerSectionRef} - defaultCollapsed={index >= STACKED_VIEW_MAX_EXPANDED_DIFFS} + defaultCollapsed={index >= defaultExpandedCount} /> ))}
diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 1f4c1c0a..c945cfb2 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -1,11 +1,11 @@ import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { FileDiff } from '@pierre/diffs/react'; -import { parseDiffFromFile, type FileContents, type FileDiffMetadata, type SelectedLineRange } from '@pierre/diffs'; +import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange } from '@pierre/diffs'; import { RiSendPlane2Line } from '@remixicon/react'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useWorkerPool } from '@/contexts/DiffWorkerProvider'; import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; @@ -96,15 +96,23 @@ const WEBKIT_SCROLL_FIX_CSS = ` `; // Fast cache key - use length + samples instead of full hash -function getCacheKey(fileName: string, original: string, modified: string, themeKey: string): string { - // Sample a few characters instead of hashing entire content - const sampleOriginal = original.length > 100 - ? `${original.slice(0, 50)}${original.slice(-50)}` - : original; - const sampleModified = modified.length > 100 - ? `${modified.slice(0, 50)}${modified.slice(-50)}` - : modified; - return `${themeKey}::${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`; +function fnv1a32(input: string): string { + // Fast + stable across runtimes; good enough for cache keys. + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + // hash *= 16777619 (but keep 32-bit) + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash.toString(16); +} + +function makeContentCacheKey(contents: string): string { + // Avoid hashing full file; sample head+tail. + const sample = contents.length > 400 + ? `${contents.slice(0, 200)}${contents.slice(-200)}` + : contents; + return `${contents.length}:${fnv1a32(sample)}`; } const extractSelectedCode = (original: string, modified: string, range: SelectedLineRange): string => { @@ -315,6 +323,9 @@ export const PierreDiffViewer: React.FC = ({ const diffThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`; const diffRootRef = useRef(null); + const diffContainerRef = useRef(null); + const diffInstanceRef = useRef | null>(null); + const workerPool = useWorkerPool(); const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]); const darkResolvedTheme = useMemo(() => getResolvedShikiTheme(darkTheme), [darkTheme]); @@ -393,43 +404,6 @@ export const PierreDiffViewer: React.FC = ({ } }, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]); - // Cache the last computed diff to avoid recomputing on every render - const diffCacheRef = useRef<{ - key: string; - fileDiff: FileDiffMetadata; - } | null>(null); - - // Pre-parse the diff with cacheKey for worker pool caching - const fileDiff = useMemo(() => { - const cacheKey = getCacheKey(fileName, original, modified, diffThemeKey); - - // Return cached diff if inputs haven't changed - if (diffCacheRef.current?.key === cacheKey) { - return diffCacheRef.current.fileDiff; - } - - const oldFile: FileContents = { - name: fileName, - contents: original, - lang: language as FileContents['lang'], - cacheKey: `old-${cacheKey}`, - }; - - const newFile: FileContents = { - name: fileName, - contents: modified, - lang: language as FileContents['lang'], - cacheKey: `new-${cacheKey}`, - }; - - const diff = parseDiffFromFile(oldFile, newFile); - - // Cache the result - diffCacheRef.current = { key: cacheKey, fileDiff: diff }; - - return diff; - }, [diffThemeKey, fileName, original, modified, language]); - const options = useMemo(() => ({ theme: { dark: darkTheme.metadata.id, @@ -439,7 +413,8 @@ export const PierreDiffViewer: React.FC = ({ diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const), diffIndicators: 'none' as const, hunkSeparators: 'line-info' as const, - lineDiffType: 'word-alt' as const, + // Perf: disable intra-line diff (word-level) globally. + lineDiffType: 'none' as const, overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), disableFileHeader: true, enableLineSelection: true, @@ -448,6 +423,61 @@ export const PierreDiffViewer: React.FC = ({ unsafeCSS: WEBKIT_SCROLL_FIX_CSS, }), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange]); + // Imperative render (like upstream OpenCode): avoids `parseDiffFromFile` on main thread. + useEffect(() => { + if (typeof window === 'undefined') return; + + const container = diffContainerRef.current; + if (!container) return; + if (!workerPool) return; + + // Dispose previous instance + diffInstanceRef.current?.cleanUp(); + diffInstanceRef.current = null; + container.innerHTML = ''; + + const instance = new PierreFileDiff(options as unknown as FileDiffOptions, workerPool); + diffInstanceRef.current = instance; + + const oldFile: FileContents = { + name: fileName, + contents: original, + lang: language as FileContents['lang'], + cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`, + }; + const newFile: FileContents = { + name: fileName, + contents: modified, + lang: language as FileContents['lang'], + cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`, + }; + + instance.render({ + oldFile, + newFile, + lineAnnotations: [], + containerWrapper: container, + }); + + return () => { + instance.cleanUp(); + if (diffInstanceRef.current === instance) { + diffInstanceRef.current = null; + } + container.innerHTML = ''; + }; + }, [diffThemeKey, fileName, language, modified, options, original, workerPool]); + + useEffect(() => { + const instance = diffInstanceRef.current; + if (!instance) return; + try { + instance.setSelectedLines(selection); + } catch { + // ignore + } + }, [selection]); + if (typeof window === 'undefined') { return null; } @@ -557,12 +587,7 @@ export const PierreDiffViewer: React.FC = ({ fillContainer={true} >
- +
@@ -595,12 +620,7 @@ export const PierreDiffViewer: React.FC = ({ return (
- +
{selection && createPortal( diff --git a/packages/ui/src/contexts/DiffWorkerProvider.tsx b/packages/ui/src/contexts/DiffWorkerProvider.tsx index b2e39d25..cccad99c 100644 --- a/packages/ui/src/contexts/DiffWorkerProvider.tsx +++ b/packages/ui/src/contexts/DiffWorkerProvider.tsx @@ -1,84 +1,34 @@ -import React, { useMemo, useEffect, useRef } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react'; -import { parseDiffFromFile, type FileContents } from '@pierre/diffs'; import type { SupportedLanguages } from '@pierre/diffs'; import { useOptionalThemeSystem } from './useThemeSystem'; import { workerFactory } from '@/lib/diff/workerFactory'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; -import { useGitStore } from '@/stores/useGitStore'; -import { getLanguageFromExtension } from '@/lib/toolHelpers'; +// NOTE: keep provider lightweight; avoid main-thread diff parsing here. // Preload common languages for faster initial diff rendering const PRELOAD_LANGS: SupportedLanguages[] = [ + // Keep small; workers load others on-demand. 'typescript', 'javascript', 'tsx', 'jsx', 'json', - 'html', - 'css', 'markdown', - 'yaml', - 'python', - 'rust', - 'go', - 'bash', ]; -// Limit warmup to prevent memory bloat with many modified files -const WARMUP_MAX_FILES = 10; - -// Matches cache key logic in `packages/ui/src/components/views/PierreDiffViewer.tsx` -function getPierreCacheKey(fileName: string, original: string, modified: string, themeKey: string): string { - const sampleOriginal = original.length > 100 - ? `${original.slice(0, 50)}${original.slice(-50)}` - : original; - const sampleModified = modified.length > 100 - ? `${modified.slice(0, 50)}${modified.slice(-50)}` - : modified; - return `${themeKey}::${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`; -} - interface DiffWorkerProviderProps { children: React.ReactNode; } -type IdleDeadlineLike = { timeRemaining(): number; didTimeout: boolean }; - -function scheduleWarmupWork(cb: (deadline?: IdleDeadlineLike) => void): () => void { - if (typeof window === 'undefined') { - return () => {}; - } - - const id = window.setTimeout(() => cb(undefined), 0); - return () => window.clearTimeout(id); -} - // Component that warms up the worker pool and precomputes diff ASTs const WorkerPoolWarmup: React.FC<{ children: React.ReactNode; - themeKey: string; renderTheme: { light: string; dark: string }; -}> = ({ children, themeKey, renderTheme }) => { +}> = ({ children, renderTheme }) => { const workerPool = useWorkerPool(); - const activeDirectory = useGitStore((state) => state.activeDirectory); - const lastStatusChange = useGitStore((state) => { - if (!activeDirectory) return 0; - return state.directories.get(activeDirectory)?.lastStatusChange ?? 0; - }); - const diffCacheSize = useGitStore((state) => { - if (!activeDirectory) return 0; - return state.directories.get(activeDirectory)?.diffCache.size ?? 0; - }); - - const didDummyWarmupRef = useRef(false); - const warmedStatusRef = useRef(new Map()); - - useEffect(() => { - warmedStatusRef.current.clear(); - }, [themeKey]); useEffect(() => { if (!workerPool) { @@ -87,110 +37,10 @@ const WorkerPoolWarmup: React.FC<{ // Important: WorkerPoolContextProvider uses a singleton and does not react to // prop changes. Update the worker pool render options explicitly. - void workerPool.setRenderOptions({ theme: renderTheme }); + // Force-disable intra-line diff globally (word-level/char-level). + void workerPool.setRenderOptions({ theme: renderTheme, lineDiffType: 'none' }); }, [renderTheme, workerPool]); - useEffect(() => { - if (!workerPool || didDummyWarmupRef.current) return; - - didDummyWarmupRef.current = true; - - const dummyFile: FileContents = { - name: 'warmup.ts', - contents: 'const x = 1;', - lang: 'typescript', - cacheKey: 'warmup-file', - }; - - const dummyDiff = parseDiffFromFile(dummyFile, { ...dummyFile, contents: 'const x = 2;', cacheKey: 'warmup-file-2' }); - - const dummyInstance = { - onHighlightSuccess: () => {}, - onHighlightError: () => {}, - }; - - workerPool.highlightDiffAST(dummyInstance, dummyDiff); - }, [workerPool]); - - useEffect(() => { - if (!workerPool || !activeDirectory) return; - if (!lastStatusChange || diffCacheSize === 0) return; - - const dirState = useGitStore.getState().directories.get(activeDirectory); - if (!dirState || dirState.diffCache.size === 0) return; - - const alreadyWarmedAt = warmedStatusRef.current.get(activeDirectory) ?? 0; - if (alreadyWarmedAt >= dirState.lastStatusChange) return; - - // Only warm once per status-change tick - warmedStatusRef.current.set(activeDirectory, dirState.lastStatusChange); - - let cancelled = false; - let cancelScheduled: (() => void) | null = null; - - // Limit entries to prevent memory bloat with many modified files - const allEntries = Array.from(dirState.diffCache.entries()); - const entries = allEntries.slice(0, WARMUP_MAX_FILES); - - let index = 0; - - const processChunk = (deadline?: IdleDeadlineLike) => { - if (cancelled) return; - - const chunkStart = Date.now(); - while (index < entries.length) { - if (deadline && deadline.timeRemaining() < 8) break; - if (!deadline && Date.now() - chunkStart > 8) break; - - const [filePath, diff] = entries[index]; - index += 1; - - const language = getLanguageFromExtension(filePath) || 'text'; - const cacheKey = getPierreCacheKey(filePath, diff.original, diff.modified, themeKey); - - const oldFile: FileContents = { - name: filePath, - contents: diff.original, - lang: language as FileContents['lang'], - cacheKey: `old-${cacheKey}`, - }; - - const newFile: FileContents = { - name: filePath, - contents: diff.modified, - lang: language as FileContents['lang'], - cacheKey: `new-${cacheKey}`, - }; - - const fileDiff = parseDiffFromFile(oldFile, newFile); - - // Use a unique instance per request so Pierre doesn't ignore earlier results. - const instance = { - onHighlightSuccess: () => {}, - onHighlightError: () => {}, - }; - - workerPool.highlightDiffAST(instance, fileDiff); - } - - if (index < entries.length) { - cancelScheduled = scheduleWarmupWork(processChunk); - } - }; - - // Kick off immediately, then continue in chunks. - processChunk(undefined); - - if (index < entries.length) { - cancelScheduled = scheduleWarmupWork(processChunk); - } - - return () => { - cancelled = true; - cancelScheduled?.(); - }; - }, [workerPool, activeDirectory, lastStatusChange, diffCacheSize, themeKey]); - return <>{children}; }; @@ -223,8 +73,6 @@ export const DiffWorkerProvider: React.FC = ({ children langs: PRELOAD_LANGS, }), [darkTheme.metadata.id, isDark, lightTheme.metadata.id]); - const workerThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`; - const renderTheme = useMemo( () => ({ light: lightTheme.metadata.id, @@ -243,7 +91,6 @@ export const DiffWorkerProvider: React.FC = ({ children highlighterOptions={highlighterOptions} > {children}