From c4de90e737c8a30c8a3630205acefbd42df0ed62 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 08:55:45 +0900 Subject: [PATCH 1/3] feat(desktop): add CodeMirror-based diff viewer for large files The existing @pierre/diffs diff viewer renders all lines at once, causing severe lag on files with 2000+ lines (~180k+ DOM nodes). Add CodeMirrorDiffViewer using @codemirror/merge which provides virtualized rendering (only visible lines in DOM). Files with >2000 total lines automatically use this viewer instead. - Reuses existing CodeMirror theme, fonts, and syntax highlighting - Unchanged regions are collapsed (margin: 3, minSize: 4) - Read-only side-by-side view with line numbers - No changes to LightDiffViewer (small files use it as before) --- apps/desktop/package.json | 1 + .../CodeMirrorDiffViewer.tsx | 131 ++++++++++++++++++ .../components/CodeMirrorDiffViewer/index.ts | 1 + .../FileViewerContent/FileViewerContent.tsx | 15 ++ bun.lock | 3 + 5 files changed, 151 insertions(+) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a72fe284e2e..a1894df9deb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -57,6 +57,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.2", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/merge": "^6.12.1", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx new file mode 100644 index 00000000000..eb83d0bdd5a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx @@ -0,0 +1,131 @@ +import { defaultKeymap, indentWithTab } from "@codemirror/commands"; +import { MergeView } from "@codemirror/merge"; +import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; +import { Compartment, EditorState } from "@codemirror/state"; +import { + drawSelection, + EditorView, + highlightSpecialChars, + keymap, + lineNumbers, +} from "@codemirror/view"; +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { createCodeMirrorTheme } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/createCodeMirrorTheme"; +import { loadLanguageSupport } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/loadLanguageSupport"; +import { getCodeSyntaxHighlighting } from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; +import { useResolvedTheme } from "renderer/stores/theme"; +import type { DiffViewMode } from "shared/changes-types"; + +interface CodeMirrorDiffViewerProps { + original: string; + modified: string; + language: string; + viewMode: DiffViewMode; +} + +export function CodeMirrorDiffViewer({ + original, + modified, + language, + viewMode, +}: CodeMirrorDiffViewerProps) { + const containerRef = useRef(null); + const mergeViewRef = useRef(null); + const langCompartmentA = useRef(new Compartment()).current; + const langCompartmentB = useRef(new Compartment()).current; + const themeCompartmentA = useRef(new Compartment()).current; + const themeCompartmentB = useRef(new Compartment()).current; + const activeTheme = useResolvedTheme(); + const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery( + undefined, + { staleTime: 30_000 }, + ); + const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; + const editorFontSize = fontSettings?.editorFontSize ?? undefined; + + // biome-ignore lint/correctness/useExhaustiveDependencies: MergeView is created once and destroyed on unmount + useEffect(() => { + if (!containerRef.current) return; + + const baseExtensions = [ + lineNumbers(), + highlightSpecialChars(), + drawSelection(), + highlightSelectionMatches(), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.lineWrapping, + keymap.of([indentWithTab, ...defaultKeymap, ...searchKeymap]), + ]; + + const themeExts = [ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + true, + ), + ]; + + const mergeView = new MergeView({ + parent: containerRef.current, + collapseUnchanged: { margin: 3, minSize: 4 }, + a: { + doc: original, + extensions: [ + ...baseExtensions, + themeCompartmentA.of(themeExts), + langCompartmentA.of([]), + ], + }, + b: { + doc: modified, + extensions: [ + ...baseExtensions, + themeCompartmentB.of(themeExts), + langCompartmentB.of([]), + ], + }, + }); + + mergeViewRef.current = mergeView; + + void loadLanguageSupport(language).then((ext) => { + if (!ext || !mergeViewRef.current) return; + const mv = mergeViewRef.current; + mv.a.dispatch({ effects: langCompartmentA.reconfigure(ext) }); + mv.b.dispatch({ effects: langCompartmentB.reconfigure(ext) }); + }); + + return () => { + mergeView.destroy(); + mergeViewRef.current = null; + }; + }, [original, modified, language, viewMode]); + + useEffect(() => { + const mv = mergeViewRef.current; + if (!mv) return; + + const themeExts = [ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + true, + ), + ]; + + mv.a.dispatch({ effects: themeCompartmentA.reconfigure(themeExts) }); + mv.b.dispatch({ effects: themeCompartmentB.reconfigure(themeExts) }); + }, [ + activeTheme, + editorFontFamily, + editorFontSize, + themeCompartmentA, + themeCompartmentB, + ]); + + return
; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts new file mode 100644 index 00000000000..13fcbde4860 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/index.ts @@ -0,0 +1 @@ +export { CodeMirrorDiffViewer } from "./CodeMirrorDiffViewer"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index 0148172700f..b0d5bb5b51c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -18,6 +18,7 @@ import { detectLanguage } from "shared/detect-language"; import { isImageFile, isSpreadsheetFile } from "shared/file-types"; import type { FileViewerMode } from "shared/tabs-types"; import { useScrollToFirstDiffChange } from "../../hooks/useScrollToFirstDiffChange"; +import { CodeMirrorDiffViewer } from "../CodeMirrorDiffViewer"; import { DiffScrollbarDecorations } from "../DiffScrollbarDecorations"; import { DiffViewerContextMenu } from "../DiffViewerContextMenu"; import { FileEditorContextMenu } from "../FileEditorContextMenu"; @@ -319,6 +320,20 @@ export function FileViewerContent({ ); } + const totalLines = + diffData.original.split("\n").length + + diffData.modified.split("\n").length; + if (totalLines > 2000) { + return ( + + ); + } + return ( Date: Sat, 28 Mar 2026 09:18:03 +0900 Subject: [PATCH 2/3] fix: increase diff scanLimit to handle large files correctly The default scanLimit (500) causes the diff algorithm to fall back to an imprecise mode for large files, marking everything as changed. Increase to 50000 with a 5s timeout. --- .../components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx index eb83d0bdd5a..177dae7bfa5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/CodeMirrorDiffViewer/CodeMirrorDiffViewer.tsx @@ -71,6 +71,7 @@ export function CodeMirrorDiffViewer({ const mergeView = new MergeView({ parent: containerRef.current, collapseUnchanged: { margin: 3, minSize: 4 }, + diffConfig: { scanLimit: 50000, timeout: 5000 }, a: { doc: original, extensions: [ From 672ef876e9d13f1a1c6c7785f60bf97ac296066c Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 28 Mar 2026 09:20:27 +0900 Subject: [PATCH 3/3] fix: wrap CodeMirrorDiffViewer in DiffViewerContextMenu Keep context menu actions (split, move, close, edit at location) available for large file diffs. Also conditionally skip MarkdownSearch and DiffScrollbarDecorations for CodeMirror path since they are @pierre/diffs specific. --- .../FileViewerContent/FileViewerContent.tsx | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index b0d5bb5b51c..61c7e13eedd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -323,16 +323,7 @@ export function FileViewerContent({ const totalLines = diffData.original.split("\n").length + diffData.modified.split("\n").length; - if (totalLines > 2000) { - return ( - - ); - } + const useLargeDiffViewer = totalLines > 2000; return (
- + {!useLargeDiffViewer && ( + + )}
{ + if (useLargeDiffViewer) return; const location = getDiffLocationFromEvent(event.nativeEvent); if (!location) { return; @@ -401,16 +395,27 @@ export function FileViewerContent({ }; }} > - + {useLargeDiffViewer ? ( + + ) : ( + + )}
- + {!useLargeDiffViewer && ( + + )}
);