import React, { useCallback, useEffect, useRef } from 'react'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language'; import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge'; import { Compartment, EditorState, type Extension } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { EditorView, keymap, lineNumbers } from '@codemirror/view'; import { getAsyncLanguageDesc, getSyncLanguageExtension, } from '@renderer/utils/codemirrorLanguages'; import { buildSelectionInfo } from '@renderer/utils/codemirrorSelectionInfo'; import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; import { acceptChunk, getChunks, mergeUndoSupport, mirrorEditsAfterResolve, rejectChunk, } from './CodeMirrorDiffUtils'; import { portionCollapseExtension } from './portionCollapse'; import type { EditorSelectionInfo } from '@shared/types/editor'; interface CodeMirrorDiffViewProps { original: string; modified: string; fileName: string; maxHeight?: string; readOnly?: boolean; showMergeControls?: boolean; collapseUnchanged?: boolean; collapseMargin?: number; onHunkAccepted?: (hunkIndex: number) => void; onHunkRejected?: (hunkIndex: number) => void; /** Called when the user scrolls to the end of the diff (auto-viewed) */ onFullyViewed?: () => void; /** Ref to expose the EditorView for external navigation */ editorViewRef?: React.RefObject; /** Called whenever the internal EditorView is created or destroyed */ onViewChange?: (view: EditorView | null) => void; /** Called when editor content changes (debounced, only when readOnly=false) */ onContentChanged?: (content: string) => void; /** Cached EditorState to restore (preserves undo history between file switches) */ initialState?: EditorState; /** Use portion collapse instead of CM's collapseUnchanged (Expand N / Expand All buttons) */ usePortionCollapse?: boolean; /** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */ portionSize?: number; /** Called when text selection changes (for floating action menu) */ onSelectionChange?: (info: EditorSelectionInfo | null) => void; /** Global hunk offset for this file in the review order */ globalHunkOffset?: number; /** Total hunk count across all review files */ totalReviewHunks?: number; } /** Compute hunk index for the chunk at a given position (B-side / modified doc). * If the position falls inside a chunk, returns that chunk's index. * Otherwise returns the nearest chunk by distance (avoids defaulting to 0). */ function computeHunkIndexAtPos(state: EditorState, pos: number): number { const chunks = getChunks(state); if (!chunks || chunks.chunks.length === 0) return 0; let nearestIndex = 0; let nearestDist = Infinity; let index = 0; for (const chunk of chunks.chunks) { // Exact match — position is inside this chunk if (pos >= chunk.fromB && pos <= chunk.toB) { return index; } // Track nearest chunk for fallback const dist = Math.min(Math.abs(pos - chunk.fromB), Math.abs(pos - chunk.toB)); if (dist < nearestDist) { nearestDist = dist; nearestIndex = index; } index++; } return nearestIndex; } /** Diff-specific theme — merge toolbar, changed/deleted line backgrounds, collapse markers */ const diffSpecificTheme = EditorView.theme({ '.cm-changedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' }, '.cm-deletedChunk': { backgroundColor: 'var(--diff-cm-deleted-bg)', position: 'relative', overflow: 'visible', }, '.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' }, '.cm-deletedLine': { backgroundColor: 'var(--diff-cm-deleted-bg) !important' }, // Merge toolbar — absolute, Y and left set dynamically by JS handlers '.cm-deletedChunk .cm-chunkButtons': { position: 'absolute', top: '0', zIndex: 10, display: 'flex', justifyContent: 'flex-end', }, '.cm-merge-toolbar': { display: 'none', alignItems: 'center', gap: '2px', '&.cm-merge-toolbar-active': { display: 'flex', }, }, '.cm-merge-nav': { display: 'flex', alignItems: 'center', gap: '0', marginRight: '2px', border: '1px solid var(--color-border)', borderRadius: '6px', backgroundColor: 'var(--color-surface-raised)', overflow: 'hidden', }, '.cm-merge-nav-btn': { border: 'none', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', padding: '3px 8px', fontSize: '13px', lineHeight: '20px', '&:hover': { background: 'var(--diff-merge-nav-hover-bg)' }, }, '.cm-merge-nav-counter': { fontSize: '12px', color: 'var(--color-text-secondary)', padding: '0 2px', whiteSpace: 'nowrap', }, '.cm-merge-undo': { cursor: 'pointer', padding: '3px 10px', borderRadius: '5px', fontSize: '12px', fontWeight: '500', lineHeight: '20px', color: 'var(--diff-merge-undo-color)', backgroundColor: 'var(--diff-merge-undo-bg)', border: '1px solid var(--diff-merge-undo-border)', '&:hover': { backgroundColor: 'var(--diff-merge-undo-hover-bg)' }, '& kbd': { fontSize: '10px', color: 'var(--color-text-muted)', marginLeft: '4px' }, }, '.cm-merge-keep': { cursor: 'pointer', padding: '3px 10px', borderRadius: '5px', fontSize: '12px', fontWeight: '500', lineHeight: '20px', color: 'var(--diff-merge-keep-color)', backgroundColor: 'var(--diff-merge-keep-bg)', border: '1px solid var(--diff-merge-keep-border)', '&:hover': { backgroundColor: 'var(--diff-merge-keep-hover-bg)' }, '& kbd': { fontSize: '10px', color: 'var(--diff-merge-keep-kbd)', marginLeft: '4px' }, }, // Collapse unchanged region marker '.cm-collapsedLines': { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text-muted)', fontSize: '12px', padding: '2px 8px', cursor: 'pointer', borderTop: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)', }, }); /** When original is empty (all additions), avoid showing a stray "deleted" block at the top. */ const emptyOriginalOverrideTheme = EditorView.theme({ '.cm-deletedChunk': { backgroundColor: 'transparent !important', paddingLeft: '0 !important', }, '.cm-deletedLine': { backgroundColor: 'transparent !important', }, }); export const CodeMirrorDiffView = ({ original, modified, fileName, maxHeight = '100%', readOnly = false, showMergeControls = false, collapseUnchanged: collapseUnchangedProp = true, collapseMargin = 3, onHunkAccepted, onHunkRejected, onFullyViewed, editorViewRef: externalViewRef, onViewChange, onContentChanged, initialState, usePortionCollapse = false, portionSize = 100, onSelectionChange, globalHunkOffset = 0, totalReviewHunks, }: CodeMirrorDiffViewProps): React.ReactElement => { const rootRef = useRef(null); const containerRef = useRef(null); const viewRef = useRef(null); const endSentinelRef = useRef(null); const lastPointerRef = useRef<{ x: number; y: number } | null>(null); const floatingToolbarRef = useRef(null); const floatingNavRef = useRef(null); const floatingCounterRef = useRef(null); const activeChunkIndexRef = useRef(null); // Local ref to hold externalViewRef for syncing via useEffect const externalViewRefHolder = useRef(externalViewRef); // Stabilize callbacks via useEffect (cannot update refs during render) const onAcceptRef = useRef(onHunkAccepted); const onRejectRef = useRef(onHunkRejected); const onContentChangedRef = useRef(onContentChanged); const onViewChangeRef = useRef(onViewChange); const onSelectionChangeRef = useRef(onSelectionChange); const debounceTimer = useRef>(undefined); useEffect(() => { onAcceptRef.current = onHunkAccepted; onRejectRef.current = onHunkRejected; onContentChangedRef.current = onContentChanged; onViewChangeRef.current = onViewChange; onSelectionChangeRef.current = onSelectionChange; externalViewRefHolder.current = externalViewRef; }, [ onHunkAccepted, onHunkRejected, onContentChanged, onViewChange, onSelectionChange, externalViewRef, ]); // Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate) const scrollToNextChunk = useCallback(() => { requestAnimationFrame(() => { if (viewRef.current) goToNextChunk(viewRef.current); }); }, []); // Compartment for lazy-injected language support const langCompartment = useRef(new Compartment()); // Compartment for merge view — allows dynamic collapse reconfigure without editor recreation const mergeCompartment = useRef(new Compartment()); // Compartment for portion collapse (separate from merge to allow independent reconfigure) const portionCompartment = useRef(new Compartment()); // Collapse as ref — used in buildExtensions (initial value) without triggering full rebuild const collapseRef = useRef({ enabled: collapseUnchangedProp, margin: collapseMargin }); useEffect(() => { collapseRef.current = { enabled: collapseUnchangedProp, margin: collapseMargin }; }, [collapseUnchangedProp, collapseMargin]); const hideFloatingToolbar = useCallback(() => { const toolbar = floatingToolbarRef.current; if (!toolbar) return; toolbar.style.display = 'none'; activeChunkIndexRef.current = null; }, []); const positionFloatingToolbar = useCallback((view: EditorView, clientY: number) => { const toolbar = floatingToolbarRef.current; const root = rootRef.current; if (!toolbar || !root) return; const rootRect = root.getBoundingClientRect(); const scrollerRect = view.scrollDOM.getBoundingClientRect(); const toolbarWidth = toolbar.offsetWidth || 200; const toolbarHeight = toolbar.offsetHeight || 28; const margin = 12; const left = scrollerRect.right - rootRect.left - toolbarWidth - margin; const clampedTop = Math.max( scrollerRect.top, Math.min(clientY - toolbarHeight / 2, scrollerRect.bottom - toolbarHeight) ); toolbar.style.left = `${left}px`; toolbar.style.top = `${clampedTop - rootRect.top}px`; }, []); const resolveDeletedChunkIndex = useCallback( (deletedChunk: Element, view: EditorView): number => { try { return computeHunkIndexAtPos(view.state, view.posAtDOM(deletedChunk)); } catch { return -1; } }, [] ); const findHoveredChunkIndex = useCallback( (clientX: number, clientY: number, view: EditorView): number => { const hoveredElement = document.elementFromPoint(clientX, clientY); if (!hoveredElement) return -1; if (hoveredElement.closest('[data-review-floating-toolbar="true"]')) { return activeChunkIndexRef.current ?? -1; } const deletedChunk = hoveredElement.closest('.cm-deletedChunk'); if (deletedChunk) { return resolveDeletedChunkIndex(deletedChunk, view); } if ( !hoveredElement.closest( '.cm-changedLine, .cm-insertedLine, .cm-inlineChangedLine, .cm-changedText, .cm-deletedText' ) ) { return -1; } const pos = view.posAtCoords({ x: clientX, y: clientY }); if (pos === null) return -1; const chunks = getChunks(view.state); if (!chunks) return -1; for (let i = 0; i < chunks.chunks.length; i++) { const chunk = chunks.chunks[i]; const chunkEnd = Math.min(view.state.doc.length, chunk.endB); if (pos >= chunk.fromB && pos <= chunkEnd) { return i; } } return -1; }, [resolveDeletedChunkIndex] ); const updateFloatingToolbar = useCallback( ( view: EditorView, clientY: number, options?: { clientX?: number; followCursor?: boolean } ): void => { if (!showMergeControls) { hideFloatingToolbar(); return; } const toolbar = floatingToolbarRef.current; const nav = floatingNavRef.current; const counter = floatingCounterRef.current; const chunks = getChunks(view.state); if (!toolbar || !chunks || chunks.chunks.length === 0) { hideFloatingToolbar(); return; } let activeIndex = options?.clientX !== undefined ? findHoveredChunkIndex(options.clientX, clientY, view) : -1; if (activeIndex < 0) { hideFloatingToolbar(); return; } activeIndex = Math.max(0, Math.min(activeIndex, chunks.chunks.length - 1)); activeChunkIndexRef.current = activeIndex; if (counter) { const displayIndex = globalHunkOffset + activeIndex + 1; const displayTotal = totalReviewHunks ?? chunks.chunks.length; counter.textContent = `${displayIndex} of ${displayTotal}`; } if (nav) { nav.style.display = chunks.chunks.length > 1 ? '' : 'none'; } toolbar.style.display = 'flex'; const scrollerRect = view.scrollDOM.getBoundingClientRect(); const targetY = options?.followCursor ? clientY : (scrollerRect.top + scrollerRect.bottom) / 2; positionFloatingToolbar(view, targetY); }, [ findHoveredChunkIndex, globalHunkOffset, hideFloatingToolbar, positionFloatingToolbar, showMergeControls, totalReviewHunks, ] ); const actOnActiveChunk = useCallback( (decision: 'accept' | 'reject') => { const view = viewRef.current; const activeChunkIndex = activeChunkIndexRef.current; if (!view || activeChunkIndex === null) return; const chunks = getChunks(view.state); const chunk = chunks?.chunks[activeChunkIndex]; if (!chunk) return; if (decision === 'accept') { acceptChunk(view, chunk.fromB); onAcceptRef.current?.(activeChunkIndex); } else { rejectChunk(view, chunk.fromB); onRejectRef.current?.(activeChunkIndex); } scrollToNextChunk(); requestAnimationFrame(() => { const scrollerRect = view.scrollDOM.getBoundingClientRect(); updateFloatingToolbar(view, (scrollerRect.top + scrollerRect.bottom) / 2); }); }, [scrollToNextChunk, updateFloatingToolbar] ); const moveBetweenChunks = useCallback( (direction: 'prev' | 'next') => { const view = viewRef.current; if (!view) return; if (direction === 'prev') { goToPreviousChunk(view); } else { goToNextChunk(view); } requestAnimationFrame(() => { const scrollerRect = view.scrollDOM.getBoundingClientRect(); updateFloatingToolbar(view, (scrollerRect.top + scrollerRect.bottom) / 2); }); }, [updateFloatingToolbar] ); useEffect(() => { if (!showMergeControls) return; const repositionToolbar = (): void => { const view = viewRef.current; const root = rootRef.current; const toolbar = floatingToolbarRef.current; if (!view || !root || !toolbar || toolbar.style.display === 'none') return; const pointer = lastPointerRef.current; const rootRect = root.getBoundingClientRect(); const pointerInsideRoot = pointer && pointer.x >= rootRect.left && pointer.x <= rootRect.right && pointer.y >= rootRect.top && pointer.y <= rootRect.bottom; if (pointerInsideRoot) { updateFloatingToolbar(view, pointer.y, { clientX: pointer.x, followCursor: true, }); return; } const scrollerRect = view.scrollDOM.getBoundingClientRect(); updateFloatingToolbar(view, (scrollerRect.top + scrollerRect.bottom) / 2); }; window.addEventListener('scroll', repositionToolbar, true); window.addEventListener('resize', repositionToolbar); return () => { window.removeEventListener('scroll', repositionToolbar, true); window.removeEventListener('resize', repositionToolbar); }; }, [showMergeControls, updateFloatingToolbar]); /** Build unified merge view extension. Extracted for dynamic compartment reconfigure. */ const buildMergeExtension = useCallback( (collapse: boolean, margin: number): Extension => { const mergeConfig: Parameters[0] = { original, highlightChanges: false, gutter: true, syntaxHighlightDeletions: true, }; // We render our own floating merge toolbar outside CodeMirror's DeletionWidget DOM. mergeConfig.mergeControls = false; if (collapse && !usePortionCollapse) { mergeConfig.collapseUnchanged = { margin, minSize: 4, }; } return unifiedMergeView(mergeConfig); }, [original, usePortionCollapse] ); const buildExtensions = useCallback(() => { const isEffectivelyEmptyOriginal = original.trim().length === 0; const extensions: Extension[] = [ baseEditorTheme, diffSpecificTheme, ...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []), lineNumbers(), syntaxHighlighting(oneDarkHighlightStyle), foldGutter(), EditorView.editable.of(!readOnly), EditorState.readOnly.of(readOnly), ]; // Undo/redo support and standard editing keybindings if (!readOnly) { extensions.push(history()); extensions.push(mergeUndoSupport); extensions.push(mirrorEditsAfterResolve); extensions.push(indentUnit.of(' ')); extensions.push( keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap]) ); } // Language placeholder — actual language injected async via compartment reconfigure extensions.push(langCompartment.current.of([])); // Keyboard shortcuts for chunk navigation (within single editor). // NOTE: Mod-y, Mod-n, Alt-j are intentionally NOT here — they are handled by // useDiffNavigation's document handler (cross-file aware) and IPC handler (Cmd+N on macOS). // Registering them in CM keymap would call event.preventDefault(), blocking the // document handler's cross-file logic. extensions.push( keymap.of([ { key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk, }, { key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk, }, ...foldKeymap, ]) ); // Debounced content change listener (only when editable) if (!readOnly) { extensions.push( EditorView.updateListener.of((update) => { if (update.docChanged) { clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => { onContentChangedRef.current?.(update.state.doc.toString()); }, 300); } }) ); } // Selection change listener (for floating action menu) extensions.push( EditorView.updateListener.of((update) => { if (update.selectionSet || update.docChanged) { const sel = update.state.selection.main; if (sel.empty) { onSelectionChangeRef.current?.(null); } else { onSelectionChangeRef.current?.(buildSelectionInfo(update.view, sel)); } } }) ); // External merge toolbar: follows cursor without depending on CodeMirror's widget DOM. if (showMergeControls) { extensions.push( EditorView.domEventHandlers({ mouseleave() { return false; }, scroll(_event, view) { const scrollerRect = view.scrollDOM.getBoundingClientRect(); const pointer = lastPointerRef.current; const pointerInsideScroller = pointer && pointer.x >= scrollerRect.left && pointer.x <= scrollerRect.right && pointer.y >= scrollerRect.top && pointer.y <= scrollerRect.bottom; const targetY = pointerInsideScroller ? pointer.y : (scrollerRect.top + scrollerRect.bottom) / 2; updateFloatingToolbar(view, targetY, { clientX: pointerInsideScroller ? pointer.x : undefined, followCursor: Boolean(pointerInsideScroller), }); return false; }, }) ); // Ensure at least one toolbar is visible (initial load + after accept/reject) extensions.push( EditorView.updateListener.of((update) => { requestAnimationFrame(() => { const v = update.view; const scrollerRect = v.scrollDOM.getBoundingClientRect(); updateFloatingToolbar(v, (scrollerRect.top + scrollerRect.bottom) / 2); }); }) ); } // Unified merge view (wrapped in compartment for dynamic collapse reconfigure) extensions.push( mergeCompartment.current.of( buildMergeExtension(collapseRef.current.enabled, collapseRef.current.margin) ) ); // Portion collapse — must come AFTER merge view so ChunkField is available extensions.push( portionCompartment.current.of( usePortionCollapse && collapseRef.current.enabled ? portionCollapseExtension({ margin: collapseRef.current.margin, minSize: 4, portionSize, }) : [] ) ); return extensions; }, [ readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize, original, updateFloatingToolbar, ]); useEffect(() => { if (!containerRef.current) return; // Destroy previous view if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } const view = initialState ? new EditorView({ state: initialState, parent: containerRef.current }) : new EditorView({ doc: modified, extensions: buildExtensions(), parent: containerRef.current, }); viewRef.current = view; // Sync to external ref via holder const extRef = externalViewRefHolder.current; if (extRef) { (extRef as React.MutableRefObject).current = view; } // Notify parent that a new EditorView was created onViewChangeRef.current?.(view); return () => { clearTimeout(debounceTimer.current); hideFloatingToolbar(); view.destroy(); viewRef.current = null; if (extRef) { (extRef as React.MutableRefObject).current = null; } // Notify parent that the EditorView was destroyed onViewChangeRef.current?.(null); }; // We intentionally rebuild the entire editor when key props change }, [original, modified, buildExtensions, initialState, hideFloatingToolbar]); // Inject language extension via compartment after editor creation useEffect(() => { const view = viewRef.current; if (!view) return; // Try synchronous (bundled) language first const syncLang = getSyncLanguageExtension(fileName); if (syncLang) { view.dispatch({ effects: langCompartment.current.reconfigure(syncLang) }); return; } // Async fallback for rare languages via @codemirror/language-data const desc = getAsyncLanguageDesc(fileName); if (!desc) return; if (desc.support) { view.dispatch({ effects: langCompartment.current.reconfigure(desc.support) }); return; } let cancelled = false; void desc.load().then((support: Extension) => { if (!cancelled && viewRef.current === view) { view.dispatch({ effects: langCompartment.current.reconfigure(support) }); } }); return () => { cancelled = true; }; }, [fileName, buildExtensions, initialState, original, modified]); // Dynamic collapse toggle — reconfigure compartments in-place, preserving undo history useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: [ mergeCompartment.current.reconfigure( buildMergeExtension(collapseUnchangedProp, collapseMargin) ), portionCompartment.current.reconfigure( usePortionCollapse && collapseUnchangedProp ? portionCollapseExtension({ margin: collapseMargin, minSize: 4, portionSize, }) : [] ), ], }); }, [collapseUnchangedProp, collapseMargin, buildMergeExtension, usePortionCollapse, portionSize]); // Auto-viewed detection via IntersectionObserver useEffect(() => { if (!endSentinelRef.current || !onFullyViewed) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { onFullyViewed(); } } }, { threshold: 0.85 } ); observer.observe(endSentinelRef.current); return () => observer.disconnect(); }, [onFullyViewed]); return (
{ lastPointerRef.current = { x: e.clientX, y: e.clientY }; const view = viewRef.current; if (!view) return; updateFloatingToolbar(view, e.clientY, { clientX: e.clientX, followCursor: true, }); }} onMouseLeave={() => { lastPointerRef.current = null; hideFloatingToolbar(); }} >
{showMergeControls && (
)} {/* Invisible sentinel for auto-viewed detection */}
); };