Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 48 additions & 66 deletions src/modules/editor/GitDiffPane.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -127,6 +95,7 @@ function loadStateFromCache(
export function GitDiffPane({ source, chipLabel, active }: Props) {
const cmRef = useRef<ReactCodeMirrorRef>(null);
const themeExt = useEditorThemeExt();
const diffViewMode = usePreferencesStore((s) => s.diffViewMode);
const [state, setState] = useState<LoadState>(() =>
active ? loadStateFromCache(source) : { kind: "idle" },
);
Expand Down Expand Up @@ -197,22 +166,27 @@ export function GitDiffPane({ source, chipLabel, active }: Props) {
const useFallback = isBinary || isTooLarge;

const initialLang = useMemo(() => resolveLanguageSync(path), [path]);
// Only the inline <CodeMirror> 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
Expand All @@ -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;
Expand All @@ -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],
);

Expand Down Expand Up @@ -300,6 +276,12 @@ export function GitDiffPane({ source, chipLabel, active }: Props) {
{fallbackPatch || "Diff preview is not available for this file."}
</pre>
</ScrollArea>
) : diffViewMode === "split" ? (
<SplitDiffView
original={originalContent}
modified={modifiedContent}
path={path}
/>
) : (
<CodeMirror
ref={cmRef}
Expand Down
123 changes: 123 additions & 0 deletions src/modules/editor/SplitDiffView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { defaultKeymap } from "@codemirror/commands";
import { foldGutter } from "@codemirror/language";
import { MergeView } from "@codemirror/merge";
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";

type Props = {
original: string;
modified: string;
path: string;
};

const SHARED_EXT = buildSharedExtensions();
const themeCompartment = new Compartment();

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<HTMLDivElement>(null);
const viewRef = useRef<MergeView | null>(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 <CodeMirror>.
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 (
<div
ref={containerRef}
className="h-full [&>.cm-mergeView]:h-full [&>.cm-mergeView]:overflow-auto"
/>
);
}
81 changes: 81 additions & 0 deletions src/modules/editor/lib/diffTheme.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading