From 26d376ddda7847eddecbc6c99e5ac74a4a0239cc Mon Sep 17 00:00:00 2001 From: ceyhuncicek <40377224+ceyhuncicek@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:24:06 +0200 Subject: [PATCH 1/2] feat(editor): add side-by-side diff view setting --- src/modules/editor/GitDiffPane.tsx | 31 +++++-- src/modules/editor/SplitDiffView.tsx | 111 +++++++++++++++++++++++ src/modules/settings/store.ts | 12 +++ src/settings/sections/GeneralSection.tsx | 28 +++++- 4 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/modules/editor/SplitDiffView.tsx diff --git a/src/modules/editor/GitDiffPane.tsx b/src/modules/editor/GitDiffPane.tsx index b4773b630..a7a5d3c2b 100644 --- a/src/modules/editor/GitDiffPane.tsx +++ b/src/modules/editor/GitDiffPane.tsx @@ -1,21 +1,23 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; +import { usePreferencesStore } from "@/modules/settings/preferences"; import { unifiedMergeView } from "@codemirror/merge"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { useEffect, useMemo, useRef, useState } from "react"; -import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; import { + commitDiffKey, fetchCommitDiff, fetchWorkingDiff, getCachedDiff, workingDiffKey, - commitDiffKey, } from "./lib/diffCache"; +import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; import { resolveLanguage, resolveLanguageSync } from "./lib/languageResolver"; import { useEditorThemeExt } from "./lib/useEditorThemeExt"; +import { SplitDiffView } from "./SplitDiffView"; type WorkingSource = { kind: "working"; @@ -101,7 +103,13 @@ function countDiffLines(patch: string): { added: number; removed: number } { type LoadState = | { kind: "idle" } | { kind: "loading" } - | { kind: "loaded"; originalContent: string; modifiedContent: string; isBinary: boolean; fallbackPatch: string } + | { + kind: "loaded"; + originalContent: string; + modifiedContent: string; + isBinary: boolean; + fallbackPatch: string; + } | { kind: "error"; message: string }; function cacheKey(source: WorkingSource | CommitSource): string { @@ -110,9 +118,7 @@ function cacheKey(source: WorkingSource | CommitSource): string { : commitDiffKey(source.repoRoot, source.sha, source.path); } -function loadStateFromCache( - source: WorkingSource | CommitSource, -): LoadState { +function loadStateFromCache(source: WorkingSource | CommitSource): LoadState { const hit = getCachedDiff(cacheKey(source)); if (!hit) return { kind: "idle" }; return { @@ -127,6 +133,7 @@ function loadStateFromCache( export function GitDiffPane({ source, chipLabel, active }: Props) { const cmRef = useRef(null); const themeExt = useEditorThemeExt(); + const diffViewMode = usePreferencesStore((s) => s.diffViewMode); const [state, setState] = useState(() => active ? loadStateFromCache(source) : { kind: "idle" }, ); @@ -224,6 +231,7 @@ export function GitDiffPane({ source, chipLabel, active }: Props) { useEffect(() => { if (useFallback || initialLang) return; if (state.kind !== "loaded") return; + if (diffViewMode === "split") return; let cancelled = false; resolveLanguage(path).then((res) => { if (cancelled) return; @@ -236,10 +244,11 @@ export function GitDiffPane({ source, chipLabel, active }: Props) { return () => { cancelled = true; }; - }, [useFallback, path, initialLang, state.kind]); + }, [useFallback, path, initialLang, state.kind, diffViewMode]); const stats = useMemo( - () => (useFallback ? countDiffLines(fallbackPatch) : { added: 0, removed: 0 }), + () => + useFallback ? countDiffLines(fallbackPatch) : { added: 0, removed: 0 }, [useFallback, fallbackPatch], ); @@ -300,6 +309,12 @@ export function GitDiffPane({ source, chipLabel, active }: Props) { {fallbackPatch || "Diff preview is not available for this file."} + ) : diffViewMode === "split" ? ( + ) : ( (null); + const themeExt = useEditorThemeExt(); + const initialLang = useMemo(() => resolveLanguageSync(path), [path]); + + // MergeView is a plain class managing two EditorViews, not a CodeMirror + // extension, so it cannot go through . Rebuild on any input + // change; diff panes are read-only so there is no editor state to preserve. + useEffect(() => { + const parent = containerRef.current; + if (!parent) return; + const sideExtensions: Extension[] = [ + ...SHARED_EXT, + lineNumbers(), + languageCompartment.of(initialLang?.ext ?? []), + ...READONLY_EXT, + themeExt, + SPLIT_DIFF_THEME, + ]; + const view = new MergeView({ + a: { doc: original, extensions: sideExtensions }, + b: { doc: modified, extensions: sideExtensions }, + parent, + gutter: true, + highlightChanges: true, + collapseUnchanged: { margin: 3, minSize: 6 }, + }); + let cancelled = false; + if (!initialLang) { + void resolveLanguage(path).then((res) => { + if (cancelled || !res) return; + view.a.dispatch({ + effects: languageCompartment.reconfigure(res.ext), + }); + view.b.dispatch({ + effects: languageCompartment.reconfigure(res.ext), + }); + }); + } + return () => { + cancelled = true; + view.destroy(); + }; + }, [original, modified, path, initialLang, themeExt]); + + return ( +
+ ); +} diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index a300c6f7d..e980664d9 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -111,6 +111,8 @@ export const EDITOR_THEME_LABELS: Record = { "xcode-light": "Xcode Light", }; +export type DiffViewMode = "inline" | "split"; + export type Preferences = { theme: ThemePref; themeId: string; @@ -144,6 +146,7 @@ export type Preferences = { recentModelIds: string[]; vimMode: boolean; editorWordWrap: boolean; + diffViewMode: DiffViewMode; showHidden: boolean; explorerGitDecorations: boolean; terminalWebglEnabled: boolean; @@ -210,6 +213,7 @@ const KEY_FAVORITE_MODELS = "favoriteModelIds"; const KEY_RECENT_MODELS = "recentModelIds"; const KEY_VIM_MODE = "vimMode"; const KEY_EDITOR_WORD_WRAP = "editorWordWrap"; +const KEY_DIFF_VIEW_MODE = "diffViewMode"; const KEY_SHOW_HIDDEN = "showHidden"; const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories"; const KEY_EXPLORER_GIT_DECORATIONS = "explorerGitDecorations"; @@ -279,6 +283,7 @@ export const DEFAULT_PREFERENCES: Preferences = { recentModelIds: [], vimMode: false, editorWordWrap: false, + diffViewMode: "inline", showHidden: false, explorerGitDecorations: true, terminalWebglEnabled: true, @@ -410,6 +415,8 @@ export async function loadPreferences(): Promise { vimMode: get(KEY_VIM_MODE) ?? DEFAULT_PREFERENCES.vimMode, editorWordWrap: get(KEY_EDITOR_WORD_WRAP) ?? DEFAULT_PREFERENCES.editorWordWrap, + diffViewMode: + get(KEY_DIFF_VIEW_MODE) === "split" ? "split" : "inline", showHidden: get(KEY_SHOW_HIDDEN) ?? get(LEGACY_KEY_SHOW_HIDDEN_DIRS) ?? @@ -642,6 +649,10 @@ export async function setEditorWordWrap(value: boolean): Promise { await writePref(KEY_EDITOR_WORD_WRAP, value); } +export async function setDiffViewMode(value: DiffViewMode): Promise { + await writePref(KEY_DIFF_VIEW_MODE, value); +} + export async function setShowHidden(value: boolean): Promise { await writePref(KEY_SHOW_HIDDEN, value); } @@ -784,6 +795,7 @@ export async function onPreferencesChange( [KEY_RECENT_MODELS]: "recentModelIds", [KEY_VIM_MODE]: "vimMode", [KEY_EDITOR_WORD_WRAP]: "editorWordWrap", + [KEY_DIFF_VIEW_MODE]: "diffViewMode", [KEY_SHOW_HIDDEN]: "showHidden", [KEY_EXPLORER_GIT_DECORATIONS]: "explorerGitDecorations", [KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled", diff --git a/src/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index 321df08e1..e3b1c22ea 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -16,11 +16,12 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { usePreferencesStore } from "@/modules/settings/preferences"; -import type { ThemePref } from "@/modules/settings/store"; +import type { DiffViewMode, ThemePref } from "@/modules/settings/store"; import { setAgentNotifications, setAutostart, setDefaultWorkspaceEnv, + setDiffViewMode, setEditorAutoSave, setEditorAutoSaveDelay, setEditorWordWrap, @@ -89,6 +90,7 @@ export function GeneralSection() { const vimMode = usePreferencesStore((s) => s.vimMode); const editorWordWrap = usePreferencesStore((s) => s.editorWordWrap); const editorAutoSave = usePreferencesStore((s) => s.editorAutoSave); + const diffViewMode = usePreferencesStore((s) => s.diffViewMode); const editorAutoSaveDelay = usePreferencesStore((s) => s.editorAutoSaveDelay); const showHidden = usePreferencesStore((s) => s.showHidden); const explorerGitDecorations = usePreferencesStore( @@ -217,6 +219,30 @@ export function GeneralSection() { onCheckedChange={(v) => void setEditorWordWrap(v)} /> + + + Date: Thu, 2 Jul 2026 20:38:11 +0200 Subject: [PATCH 2/2] fix(editor): address review findings on split diff view --- src/modules/editor/GitDiffPane.tsx | 85 +++++--------- src/modules/editor/SplitDiffView.tsx | 142 ++++++++++++----------- src/modules/editor/lib/diffTheme.ts | 81 +++++++++++++ src/modules/editor/lib/extensions.ts | 5 + src/modules/settings/store.ts | 7 +- src/settings/sections/GeneralSection.tsx | 7 +- 6 files changed, 196 insertions(+), 131 deletions(-) create mode 100644 src/modules/editor/lib/diffTheme.ts diff --git a/src/modules/editor/GitDiffPane.tsx b/src/modules/editor/GitDiffPane.tsx index a7a5d3c2b..11e1e4352 100644 --- a/src/modules/editor/GitDiffPane.tsx +++ b/src/modules/editor/GitDiffPane.tsx @@ -3,8 +3,6 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import { usePreferencesStore } from "@/modules/settings/preferences"; import { unifiedMergeView } from "@codemirror/merge"; -import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -14,7 +12,12 @@ import { getCachedDiff, workingDiffKey, } from "./lib/diffCache"; -import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; +import { UNIFIED_DIFF_THEME } from "./lib/diffTheme"; +import { + buildSharedExtensions, + languageCompartment, + READONLY_EXTENSIONS, +} from "./lib/extensions"; import { resolveLanguage, resolveLanguageSync } from "./lib/languageResolver"; import { useEditorThemeExt } from "./lib/useEditorThemeExt"; import { SplitDiffView } from "./SplitDiffView"; @@ -44,47 +47,6 @@ type Props = { const LARGE_FILE_THRESHOLD = 256 * 1024; const SHARED_EXT = buildSharedExtensions(); -const READONLY_EXT = [ - EditorState.readOnly.of(true), - EditorView.editable.of(false), -]; -const DIFF_THEME = EditorView.theme({ - "&.cm-merge-b .cm-changedText, .cm-changedText": { - background: "rgba(110, 200, 120, 0.20) !important", - borderRadius: "3px", - padding: "0 1px", - }, - ".cm-deletedChunk .cm-deletedText, &.cm-merge-b .cm-deletedText": { - background: "rgba(220, 90, 90, 0.22) !important", - borderRadius: "3px", - padding: "0 1px", - }, - "&.cm-merge-b .cm-changedLine, .cm-changedLine, .cm-inlineChangedLine": { - backgroundColor: "rgba(110, 200, 120, 0.05) !important", - }, - ".cm-deletedChunk": { - backgroundColor: "rgba(220, 90, 90, 0.05) !important", - paddingTop: "1px", - paddingBottom: "1px", - }, - "&.cm-merge-b .cm-changedLineGutter, .cm-changedLineGutter": { - background: "rgba(110, 200, 120, 0.55) !important", - }, - ".cm-deletedLineGutter, &.cm-merge-a .cm-changedLineGutter": { - background: "rgba(220, 90, 90, 0.5) !important", - }, - ".cm-changeGutter": { - width: "2px !important", - paddingLeft: "0 !important", - }, - ".cm-collapsedLines": { - backgroundColor: "transparent", - color: "var(--muted-foreground, #9ca3af)", - fontSize: "10.5px", - padding: "2px 8px", - opacity: 0.7, - }, -}); function countDiffLines(patch: string): { added: number; removed: number } { let added = 0; @@ -204,22 +166,27 @@ export function GitDiffPane({ source, chipLabel, active }: Props) { const useFallback = isBinary || isTooLarge; const initialLang = useMemo(() => resolveLanguageSync(path), [path]); + // Only the inline consumes these; skip building them in split + // mode where SplitDiffView owns its own editor setup. const extensions = useMemo( - () => [ - ...SHARED_EXT, - languageCompartment.of(initialLang?.ext ?? []), - ...READONLY_EXT, - unifiedMergeView({ - original: originalContent, - mergeControls: false, - highlightChanges: true, - gutter: true, - syntaxHighlightDeletions: true, - collapseUnchanged: { margin: 3, minSize: 6 }, - }), - DIFF_THEME, - ], - [originalContent, initialLang], + () => + diffViewMode === "split" + ? [] + : [ + ...SHARED_EXT, + languageCompartment.of(initialLang?.ext ?? []), + ...READONLY_EXTENSIONS, + unifiedMergeView({ + original: originalContent, + mergeControls: false, + highlightChanges: true, + gutter: true, + syntaxHighlightDeletions: true, + collapseUnchanged: { margin: 3, minSize: 6 }, + }), + UNIFIED_DIFF_THEME, + ], + [originalContent, initialLang, diffViewMode], ); // Resolve and apply syntax highlighting asynchronously when the language pack diff --git a/src/modules/editor/SplitDiffView.tsx b/src/modules/editor/SplitDiffView.tsx index f9dbfe2fa..29a4e6b08 100644 --- a/src/modules/editor/SplitDiffView.tsx +++ b/src/modules/editor/SplitDiffView.tsx @@ -1,8 +1,22 @@ +import { defaultKeymap } from "@codemirror/commands"; +import { foldGutter } from "@codemirror/language"; import { MergeView } from "@codemirror/merge"; -import { EditorState, type Extension } from "@codemirror/state"; -import { EditorView, lineNumbers } from "@codemirror/view"; -import { useEffect, useMemo, useRef } from "react"; -import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; +import { searchKeymap } from "@codemirror/search"; +import { Compartment, type Extension } from "@codemirror/state"; +import { + drawSelection, + type EditorView, + highlightSpecialChars, + keymap, + lineNumbers, +} from "@codemirror/view"; +import { useEffect, useRef } from "react"; +import { SPLIT_DIFF_THEME } from "./lib/diffTheme"; +import { + buildSharedExtensions, + languageCompartment, + READONLY_EXTENSIONS, +} from "./lib/extensions"; import { resolveLanguage, resolveLanguageSync } from "./lib/languageResolver"; import { useEditorThemeExt } from "./lib/useEditorThemeExt"; @@ -13,94 +27,92 @@ type Props = { }; const SHARED_EXT = buildSharedExtensions(); -const READONLY_EXT = [ - EditorState.readOnly.of(true), - EditorView.editable.of(false), -]; +const themeCompartment = new Compartment(); -// Editor A holds the original (deletions, red); editor B the modified -// (insertions, green). Scoped by the .cm-merge-a/.cm-merge-b root classes -// MergeView puts on each side. -const SPLIT_DIFF_THEME = EditorView.theme({ - "&.cm-merge-a .cm-changedText": { - background: "rgba(220, 90, 90, 0.22) !important", - borderRadius: "3px", - padding: "0 1px", - }, - "&.cm-merge-a .cm-changedLine": { - backgroundColor: "rgba(220, 90, 90, 0.05) !important", - }, - "&.cm-merge-a .cm-changedLineGutter": { - background: "rgba(220, 90, 90, 0.5) !important", - }, - "&.cm-merge-b .cm-changedText": { - background: "rgba(110, 200, 120, 0.20) !important", - borderRadius: "3px", - padding: "0 1px", - }, - "&.cm-merge-b .cm-changedLine": { - backgroundColor: "rgba(110, 200, 120, 0.05) !important", - }, - "&.cm-merge-b .cm-changedLineGutter": { - background: "rgba(110, 200, 120, 0.55) !important", - }, - ".cm-changeGutter": { - width: "2px !important", - paddingLeft: "0 !important", - }, - ".cm-collapsedLines": { - backgroundColor: "transparent", - color: "var(--muted-foreground, #9ca3af)", - fontSize: "10.5px", - padding: "2px 8px", - opacity: 0.7, - }, -}); +function replaceDoc(view: EditorView, doc: string): void { + if (view.state.doc.toString() === doc) return; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, + }); +} export function SplitDiffView({ original, modified, path }: Props) { const containerRef = useRef(null); + const viewRef = useRef(null); const themeExt = useEditorThemeExt(); - const initialLang = useMemo(() => resolveLanguageSync(path), [path]); + + // Effects read initial values through refs so the MergeView is only + // rebuilt on a path change; content and theme update in place below. + const originalRef = useRef(original); + originalRef.current = original; + const modifiedRef = useRef(modified); + modifiedRef.current = modified; + const themeRef = useRef(themeExt); + themeRef.current = themeExt; // MergeView is a plain class managing two EditorViews, not a CodeMirror - // extension, so it cannot go through . Rebuild on any input - // change; diff panes are read-only so there is no editor state to preserve. + // extension, so it cannot render through . useEffect(() => { const parent = containerRef.current; if (!parent) return; + const lang = resolveLanguageSync(path); const sideExtensions: Extension[] = [ ...SHARED_EXT, lineNumbers(), - languageCompartment.of(initialLang?.ext ?? []), - ...READONLY_EXT, - themeExt, + foldGutter(), + highlightSpecialChars(), + drawSelection(), + keymap.of([...defaultKeymap, ...searchKeymap]), + languageCompartment.of(lang?.ext ?? []), + themeCompartment.of(themeRef.current), + ...READONLY_EXTENSIONS, SPLIT_DIFF_THEME, ]; const view = new MergeView({ - a: { doc: original, extensions: sideExtensions }, - b: { doc: modified, extensions: sideExtensions }, + a: { doc: originalRef.current, extensions: sideExtensions }, + b: { doc: modifiedRef.current, extensions: sideExtensions }, parent, gutter: true, highlightChanges: true, collapseUnchanged: { margin: 3, minSize: 6 }, }); + viewRef.current = view; let cancelled = false; - if (!initialLang) { - void resolveLanguage(path).then((res) => { - if (cancelled || !res) return; - view.a.dispatch({ - effects: languageCompartment.reconfigure(res.ext), - }); - view.b.dispatch({ - effects: languageCompartment.reconfigure(res.ext), - }); - }); + if (!lang) { + resolveLanguage(path) + .then((res) => { + if (cancelled) return; + for (const side of [view.a, view.b]) { + side.dispatch({ + effects: languageCompartment.reconfigure(res?.ext ?? []), + }); + } + }) + .catch(() => undefined); } return () => { cancelled = true; + viewRef.current = null; view.destroy(); }; - }, [original, modified, path, initialLang, themeExt]); + }, [path]); + + // Working diffs refetch on file save; replacing the docs in place keeps + // scroll position and expanded regions, unlike a destroy/recreate. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + replaceDoc(view.a, original); + replaceDoc(view.b, modified); + }, [original, modified]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + for (const side of [view.a, view.b]) { + side.dispatch({ effects: themeCompartment.reconfigure(themeExt) }); + } + }, [themeExt]); return (
{ vimMode: get(KEY_VIM_MODE) ?? DEFAULT_PREFERENCES.vimMode, editorWordWrap: get(KEY_EDITOR_WORD_WRAP) ?? DEFAULT_PREFERENCES.editorWordWrap, - diffViewMode: - get(KEY_DIFF_VIEW_MODE) === "split" ? "split" : "inline", + diffViewMode: ((): DiffViewMode => { + const stored = get(KEY_DIFF_VIEW_MODE); + if (stored === "inline" || stored === "split") return stored; + return DEFAULT_PREFERENCES.diffViewMode; + })(), showHidden: get(KEY_SHOW_HIDDEN) ?? get(LEGACY_KEY_SHOW_HIDDEN_DIRS) ?? diff --git a/src/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index e3b1c22ea..e988008d8 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -221,16 +221,13 @@ export function GeneralSection() {