From 410af305e3a792ced370e280b89a8bbb774f4fe9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:09:08 -0700 Subject: [PATCH] feat(editor): support contextual copy in diff view (#507) - Extracted contextual copy setup logic into `useContextualCopySetup` hook. - Replaced direct `setupContextualCopy` usage in `MonacoEditor` with the new hook. - Added `useContextualCopySetup` to `DiffViewer` to enable contextual copy in diffs. - Bound copy handlers to both original and modified diff editors. - Updated `EditorContent` to pass `filePath` and `relativePath` down to `DiffViewer`. - Made `onSave` optional in `propsRef` since it's not strictly required in diff views. --- .../src/components/editor/DiffViewer.tsx | 21 ++++++- .../src/components/editor/EditorContent.tsx | 2 + .../src/components/editor/MonacoEditor.tsx | 45 ++------------- .../editor/setup-contextual-copy.ts | 17 +++--- .../editor/useContextualCopySetup.tsx | 56 +++++++++++++++++++ 5 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/components/editor/useContextualCopySetup.tsx diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 67e947c17..93c908403 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -3,11 +3,14 @@ import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import { useAppStore } from '@/store' import '@/lib/monaco-setup' import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { useContextualCopySetup } from './useContextualCopySetup' type DiffViewerProps = { originalContent: string modifiedContent: string language: string + filePath: string + relativePath: string sideBySide: boolean editable?: boolean onContentChange?: (content: string) => void @@ -18,6 +21,8 @@ export default function DiffViewer({ originalContent, modifiedContent, language, + filePath, + relativePath, sideBySide, editable, onContentChange, @@ -39,11 +44,20 @@ export default function DiffViewer({ const onContentChangeRef = useRef(onContentChange) onContentChangeRef.current = onContentChange + const { setupCopy, toastNode } = useContextualCopySetup() + + const propsRef = useRef({ relativePath, language, onSave }) + propsRef.current = { relativePath, language, onSave } + const handleMount: DiffOnMount = useCallback( (editor, monaco) => { - if (editable) { - const modifiedEditor = editor.getModifiedEditor() + const originalEditor = editor.getOriginalEditor() + const modifiedEditor = editor.getModifiedEditor() + setupCopy(originalEditor, monaco, filePath, propsRef) + setupCopy(modifiedEditor, monaco, filePath, propsRef) + + if (editable) { // Cmd/Ctrl+S to save modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { onSaveRef.current?.(modifiedEditor.getValue()) @@ -59,7 +73,7 @@ export default function DiffViewer({ editor.focus() } }, - [editable] + [editable, setupCopy, filePath] ) return ( @@ -92,6 +106,7 @@ export default function DiffViewer({ }} /> + {toastNode} ) } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 45768a1bc..c295e0e2b 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -287,6 +287,8 @@ export function EditorContent({ originalContent={dc.originalContent} modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent} language={resolvedLanguage} + filePath={activeFile.filePath} + relativePath={activeFile.relativePath} sideBySide={sideBySide} editable={isEditable} onContentChange={isEditable ? handleContentChange : undefined} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 99d4854d0..51aef82d8 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -11,9 +11,10 @@ import { import { useAppStore } from '@/store' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import '@/lib/monaco-setup' -import { setupContextualCopy } from './setup-contextual-copy' import { computeEditorFontSize } from '@/lib/editor-font-zoom' +import { useContextualCopySetup } from './useContextualCopySetup' + type MonacoEditorProps = { filePath: string relativePath: string @@ -38,8 +39,7 @@ export default function MonacoEditor({ revealMatchLength }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) - const copyToastTimeoutRef = useRef(null) - const copyHintIntervalRef = useRef(null) + const { setupCopy, toastNode } = useContextualCopySetup() // Why: The scroll throttle timer must be accessible from useLayoutEffect cleanup // so we can cancel any pending write before synchronously snapshotting the final // scroll position on unmount. Without this, a pending timer could fire after @@ -64,9 +64,6 @@ export default function MonacoEditor({ const [gutterMenuOpen, setGutterMenuOpen] = useState(false) const [gutterMenuPoint, setGutterMenuPoint] = useState({ x: 0, y: 0 }) const [gutterMenuLine, setGutterMenuLine] = useState(1) - const [copyToast, setCopyToast] = useState<{ left: number; top: number } | null>(null) - const isMac = navigator.userAgent.includes('Mac') - const copyShortcutLabel = isMac ? '⌥⌘C' : 'Ctrl+Alt+C' const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -75,17 +72,7 @@ export default function MonacoEditor({ (editorInstance, monaco) => { editorRef.current = editorInstance - setupContextualCopy({ - editorInstance, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - monaco: monaco as any, - filePath, - copyShortcutLabel, - setCopyToast, - propsRef, - copyToastTimeoutRef, - copyHintIntervalRef - }) + setupCopy(editorInstance, monaco, filePath, propsRef) // Add Cmd+S save keybinding editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { @@ -158,7 +145,7 @@ export default function MonacoEditor({ } } }, - [copyShortcutLabel, filePath, setEditorCursorLine] + [setupCopy, filePath, setEditorCursorLine] ) const handleChange = useCallback( @@ -201,19 +188,6 @@ export default function MonacoEditor({ }) }, [editorFontSize, settings]) - useEffect(() => { - const toastRef = copyToastTimeoutRef - const hintRef = copyHintIntervalRef - return () => { - if (toastRef.current !== null) { - window.clearTimeout(toastRef.current) - } - if (hintRef.current !== null) { - window.clearInterval(hintRef.current) - } - } - }, []) - useEffect(() => { const handler = (event: Event): void => { const detail = (event as CustomEvent).detail as @@ -278,14 +252,7 @@ export default function MonacoEditor({ path={filePath} /> - {copyToast ? ( -
- Context copied -
- ) : null} + {toastNode} {/* Radix context menu for line number gutter right-click */} diff --git a/src/renderer/src/components/editor/setup-contextual-copy.ts b/src/renderer/src/components/editor/setup-contextual-copy.ts index fb860d7c9..c9e0b8768 100644 --- a/src/renderer/src/components/editor/setup-contextual-copy.ts +++ b/src/renderer/src/components/editor/setup-contextual-copy.ts @@ -8,8 +8,7 @@ export function setupContextualCopy({ copyShortcutLabel, setCopyToast, propsRef, - copyToastTimeoutRef, - copyHintIntervalRef + copyToastTimeoutRef }: { editorInstance: editor.IStandaloneCodeEditor // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -20,11 +19,11 @@ export function setupContextualCopy({ propsRef: React.MutableRefObject<{ relativePath: string language: string - onSave: (content: string) => void + onSave?: (content: string) => void }> copyToastTimeoutRef: React.MutableRefObject - copyHintIntervalRef: React.MutableRefObject }): void { + let copyHintInterval: number | null = null let copyHintWidgetPosition: editor.IContentWidgetPosition | null = null let lastCopiedSelectionKey: string | null = null const copyHintNode = document.createElement('div') @@ -151,18 +150,18 @@ export function setupContextualCopy({ const startCopyHintPolling = (): void => { updateCopyHint() - if (copyHintIntervalRef.current !== null) { + if (copyHintInterval !== null) { return } - copyHintIntervalRef.current = window.setInterval(() => { + copyHintInterval = window.setInterval(() => { updateCopyHint() }, 150) } const stopCopyHintPolling = (): void => { - if (copyHintIntervalRef.current !== null) { - window.clearInterval(copyHintIntervalRef.current) - copyHintIntervalRef.current = null + if (copyHintInterval !== null) { + window.clearInterval(copyHintInterval) + copyHintInterval = null } } diff --git a/src/renderer/src/components/editor/useContextualCopySetup.tsx b/src/renderer/src/components/editor/useContextualCopySetup.tsx new file mode 100644 index 000000000..8db2abeb0 --- /dev/null +++ b/src/renderer/src/components/editor/useContextualCopySetup.tsx @@ -0,0 +1,56 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react' +import type { editor } from 'monaco-editor' +import { setupContextualCopy } from './setup-contextual-copy' + +export function useContextualCopySetup() { + const [copyToast, setCopyToast] = useState<{ left: number; top: number } | null>(null) + const copyToastTimeoutRef = useRef(null) + + const isMac = navigator.userAgent.includes('Mac') + const copyShortcutLabel = isMac ? '⌥⌘C' : 'Ctrl+Alt+C' + + useEffect(() => { + const toastRef = copyToastTimeoutRef + return () => { + if (toastRef.current !== null) { + window.clearTimeout(toastRef.current) + } + } + }, []) + + const setupCopy = useCallback( + ( + editorInstance: editor.IStandaloneCodeEditor, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + monaco: any, + filePath: string, + propsRef: React.MutableRefObject<{ + relativePath: string + language: string + onSave?: (content: string) => void + }> + ) => { + setupContextualCopy({ + editorInstance, + monaco, + filePath, + copyShortcutLabel, + setCopyToast, + propsRef, + copyToastTimeoutRef + }) + }, + [copyShortcutLabel] + ) + + const toastNode = copyToast ? ( +
+ Context copied +
+ ) : null + + return { setupCopy, toastNode } +}