diff --git a/src/modules/editor/GitDiffPane.tsx b/src/modules/editor/GitDiffPane.tsx index b4773b630..11e1e4352 100644 --- a/src/modules/editor/GitDiffPane.tsx +++ b/src/modules/editor/GitDiffPane.tsx @@ -1,21 +1,26 @@ 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 { 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"; type WorkingSource = { kind: "working"; @@ -42,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; @@ -101,7 +65,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 +80,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 +95,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" }, ); @@ -197,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 @@ -224,6 +198,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 +211,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 +276,12 @@ export function GitDiffPane({ source, chipLabel, active }: Props) { {fallbackPatch || "Diff preview is not available for this file."} + ) : diffViewMode === "split" ? ( + ) : ( (null); + const viewRef = useRef(null); + const themeExt = useEditorThemeExt(); + + // 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 render through . + useEffect(() => { + const parent = containerRef.current; + if (!parent) return; + const lang = resolveLanguageSync(path); + const sideExtensions: Extension[] = [ + ...SHARED_EXT, + lineNumbers(), + 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: 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 (!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(); + }; + }, [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 ( +
+ ); +} diff --git a/src/modules/editor/lib/diffTheme.ts b/src/modules/editor/lib/diffTheme.ts new file mode 100644 index 000000000..40ccac94e --- /dev/null +++ b/src/modules/editor/lib/diffTheme.ts @@ -0,0 +1,81 @@ +import { EditorView } from "@codemirror/view"; + +// Single source for the diff palette so the unified and split views cannot +// drift apart when colors are tuned. +const ADDED_TEXT = "rgba(110, 200, 120, 0.20) !important"; +const ADDED_LINE = "rgba(110, 200, 120, 0.05) !important"; +const ADDED_GUTTER = "rgba(110, 200, 120, 0.55) !important"; +const REMOVED_TEXT = "rgba(220, 90, 90, 0.22) !important"; +const REMOVED_LINE = "rgba(220, 90, 90, 0.05) !important"; +const REMOVED_GUTTER = "rgba(220, 90, 90, 0.5) !important"; + +const CHANGED_TEXT_SHAPE = { + borderRadius: "3px", + padding: "0 1px", +}; + +const SHARED_RULES = { + ".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, + }, +}; + +export const UNIFIED_DIFF_THEME = EditorView.theme({ + "&.cm-merge-b .cm-changedText, .cm-changedText": { + background: ADDED_TEXT, + ...CHANGED_TEXT_SHAPE, + }, + ".cm-deletedChunk .cm-deletedText, &.cm-merge-b .cm-deletedText": { + background: REMOVED_TEXT, + ...CHANGED_TEXT_SHAPE, + }, + "&.cm-merge-b .cm-changedLine, .cm-changedLine, .cm-inlineChangedLine": { + backgroundColor: ADDED_LINE, + }, + ".cm-deletedChunk": { + backgroundColor: REMOVED_LINE, + paddingTop: "1px", + paddingBottom: "1px", + }, + "&.cm-merge-b .cm-changedLineGutter, .cm-changedLineGutter": { + background: ADDED_GUTTER, + }, + ".cm-deletedLineGutter, &.cm-merge-a .cm-changedLineGutter": { + background: REMOVED_GUTTER, + }, + ...SHARED_RULES, +}); + +// MergeView roots carry .cm-merge-a (original) and .cm-merge-b (modified), +// so deletions read red on the left and insertions green on the right. +export const SPLIT_DIFF_THEME = EditorView.theme({ + "&.cm-merge-a .cm-changedText": { + background: REMOVED_TEXT, + ...CHANGED_TEXT_SHAPE, + }, + "&.cm-merge-a .cm-changedLine": { + backgroundColor: REMOVED_LINE, + }, + "&.cm-merge-a .cm-changedLineGutter": { + background: REMOVED_GUTTER, + }, + "&.cm-merge-b .cm-changedText": { + background: ADDED_TEXT, + ...CHANGED_TEXT_SHAPE, + }, + "&.cm-merge-b .cm-changedLine": { + backgroundColor: ADDED_LINE, + }, + "&.cm-merge-b .cm-changedLineGutter": { + background: ADDED_GUTTER, + }, + ...SHARED_RULES, +}); diff --git a/src/modules/editor/lib/extensions.ts b/src/modules/editor/lib/extensions.ts index cc0501e81..030e7ebf5 100644 --- a/src/modules/editor/lib/extensions.ts +++ b/src/modules/editor/lib/extensions.ts @@ -7,6 +7,11 @@ import { EditorView } from "@codemirror/view"; // Compartments allow runtime reconfiguration without rebuilding state. export const languageCompartment = new Compartment(); + +export const READONLY_EXTENSIONS: Extension[] = [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), +]; export const readOnlyCompartment = new Compartment(); export const wrapCompartment = new Compartment(); export const vimCompartment = new Compartment(); diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index a300c6f7d..8bfb73d30 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,11 @@ export async function loadPreferences(): Promise { vimMode: get(KEY_VIM_MODE) ?? DEFAULT_PREFERENCES.vimMode, editorWordWrap: get(KEY_EDITOR_WORD_WRAP) ?? DEFAULT_PREFERENCES.editorWordWrap, + 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) ?? @@ -642,6 +652,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 +798,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..e988008d8 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,27 @@ export function GeneralSection() { onCheckedChange={(v) => void setEditorWordWrap(v)} /> + + +