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:
parent
1e77b62616
commit
410af305e3
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Reference in New Issue