import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { usePreferencesStore } from "@/modules/settings/preferences"; import type { AiDiffStatus } from "@/modules/tabs"; import { presentableDiff, unifiedMergeView } from "@codemirror/merge"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { Cancel01Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { useEffect, useMemo, useRef } from "react"; import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; import { resolveLanguage } from "./lib/languageResolver"; import { EDITOR_THEME_EXT } from "./lib/themes"; type Props = { path: string; originalContent: string; proposedContent: string; status: AiDiffStatus; isNewFile: boolean; onAccept: () => void; onReject: () => void; }; // Override default merge styles: replace the default 2px linear-gradient // underline with proper block backgrounds. Reads cleaner — especially for // pure insertions, where the underline-style marker looked decorative. const DIFF_THEME = EditorView.theme({ // ".cm-changedLine": { // backgroundColor: // "color-mix(in srgb, #22c55e 10%, transparent) !important", // }, // ".cm-merge-b .cm-changedText, .cm-merge-b ins.cm-insertedLine": { // background: // "color-mix(in srgb, #22c55e 28%, transparent) !important", // textDecoration: "none !important", // borderRadius: "2px", // }, // ".cm-deletedChunk": { // backgroundColor: // "color-mix(in srgb, #ef4444 8%, transparent)", // paddingLeft: "6px", // paddingTop: "1px", // paddingBottom: "1px", // }, // ".cm-deletedChunk .cm-deletedText, .cm-deletedLine del": { // background: // "color-mix(in srgb, #ef4444 26%, transparent) !important", // textDecoration: "none !important", // borderRadius: "2px", // }, // ".cm-changeGutter": { // width: "3px", // }, // ".cm-changedLineGutter": { // backgroundColor: "#22c55e", // }, // ".cm-deletedLineGutter": { // backgroundColor: "#ef4444", // }, ".cm-changedText": { background: "#88ff881a !important", }, }); const STATUS_LABEL: Record = { pending: "Pending review", approved: "Applied", rejected: "Rejected", }; const STATUS_BADGE: Record< AiDiffStatus, "outline" | "secondary" | "destructive" > = { pending: "outline", approved: "secondary", rejected: "destructive", }; export function AiDiffPane({ path, originalContent, proposedContent, status, isNewFile, onAccept, onReject, }: Props) { const cmRef = useRef(null); const editorThemeId = usePreferencesStore((s) => s.editorTheme); const themeExt = EDITOR_THEME_EXT[editorThemeId] ?? EDITOR_THEME_EXT.atomone; // The merge extension diffs the current document against `original`. // We bake originalContent into the extension once on mount; if the AI // updates its proposal, the surrounding bridge re-creates the tab. const extensions = useMemo( () => [ ...buildSharedExtensions(), languageCompartment.of([]), EditorState.readOnly.of(true), EditorView.editable.of(false), unifiedMergeView({ original: originalContent, mergeControls: false, highlightChanges: true, gutter: true, syntaxHighlightDeletions: true, collapseUnchanged: { margin: 3, minSize: 6 }, }), DIFF_THEME, ], [originalContent], ); // Resolve language by path (same approach as EditorPane). useEffect(() => { let cancelled = false; resolveLanguage(path).then((ext) => { if (cancelled) return; const view = cmRef.current?.view; if (!view) return; view.dispatch({ effects: languageCompartment.reconfigure(ext ?? []), }); }); return () => { cancelled = true; }; }, [path]); const stats = useMemo( () => computeLineStats(originalContent, proposedContent), [originalContent, proposedContent], ); return (
{STATUS_LABEL[status]} {isNewFile ? ( New file ) : null} {path} +{stats.added} −{stats.removed}
{status === "pending" ? (
) : null}
); } function computeLineStats( original: string, proposed: string, ): { added: number; removed: number } { const changes = presentableDiff(original, proposed); let added = 0; let removed = 0; for (const c of changes) { removed += countLines(original, c.fromA, c.toA); added += countLines(proposed, c.fromB, c.toB); } return { added, removed }; } function countLines(doc: string, from: number, to: number): number { if (from === to) return 0; const slice = doc.slice(from, to); // A change spanning N newlines touches N+1 lines, but a trailing newline // means the final segment is empty — don't count that as a touched line. let n = 1; for (let i = 0; i < slice.length; i++) { if (slice.charCodeAt(i) === 10) n++; } if (slice.endsWith("\n")) n--; return Math.max(n, 1); }