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.
This commit is contained in:
Jinjing 2026-04-11 18:09:08 -07:00 committed by GitHub
parent 1e77b62616
commit 410af305e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 90 additions and 51 deletions

View File

@ -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({
}}
/>
</div>
{toastNode}
</div>
)
}

View File

@ -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}

View File

@ -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<editor.IStandaloneCodeEditor | null>(null)
const copyToastTimeoutRef = useRef<number | null>(null)
const copyHintIntervalRef = useRef<number | null>(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 ? (
<div
className="pointer-events-none fixed z-50 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-sm"
style={{ left: copyToast.left, top: copyToast.top }}
>
Context copied
</div>
) : null}
{toastNode}
{/* Radix context menu for line number gutter right-click */}
<DropdownMenu open={gutterMenuOpen} onOpenChange={setGutterMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>

View File

@ -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<number | null>
copyHintIntervalRef: React.MutableRefObject<number | null>
}): 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
}
}

View File

@ -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<number | null>(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 ? (
<div
className="pointer-events-none fixed z-50 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-sm"
style={{ left: copyToast.left, top: copyToast.top }}
>
Context copied
</div>
) : null
return { setupCopy, toastNode }
}