diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 30039a7544e..5b791429afe 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import '@/lib/monaco-setup' +import { setupContextualCopy } from './setup-contextual-copy' type MonacoEditorProps = { filePath: string @@ -35,6 +36,14 @@ export default function MonacoEditor({ revealMatchLength }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) + const copyToastTimeoutRef = useRef(null) + const copyHintIntervalRef = useRef(null) + const propsRef = useRef({ relativePath, language, onSave }) + + useEffect(() => { + propsRef.current = { relativePath, language, onSave } + }, [relativePath, language, onSave]) + const settings = useAppStore((s) => s.settings) const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal) const setEditorCursorLine = useAppStore((s) => s.setEditorCursorLine) @@ -43,6 +52,9 @@ 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) @@ -51,10 +63,22 @@ 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 + }) + // Add Cmd+S save keybinding editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { const value = editorInstance.getValue() - onSave(value) + propsRef.current.onSave(value) }) // Track cursor line for "copy path to line" feature @@ -95,7 +119,7 @@ export default function MonacoEditor({ editorInstance.focus() } }, - [onSave, filePath, setEditorCursorLine] + [copyShortcutLabel, filePath, setEditorCursorLine] ) const handleChange = useCallback( @@ -118,6 +142,19 @@ export default function MonacoEditor({ }) }, [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 @@ -152,7 +189,7 @@ export default function MonacoEditor({ }, [revealLine, revealColumn, revealMatchLength, setPendingEditorReveal]) return ( - <> +
+ {copyToast ? ( +
+ Context copied +
+ ) : null} {/* Radix context menu for line number gutter right-click */} @@ -228,7 +273,7 @@ export default function MonacoEditor({ - +
) } diff --git a/src/renderer/src/components/editor/selection-copy.test.ts b/src/renderer/src/components/editor/selection-copy.test.ts new file mode 100644 index 00000000000..edc750dbdf3 --- /dev/null +++ b/src/renderer/src/components/editor/selection-copy.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { formatCopiedSelectionWithContext, getContextualCopyLineRange } from './selection-copy' + +describe('formatCopiedSelectionWithContext', () => { + it('formats multi-line selections with file and line context', () => { + expect( + formatCopiedSelectionWithContext({ + relativePath: 'src/main/git/status.ts', + language: 'typescript', + selection: { + startLineNumber: 44, + startColumn: 1, + endLineNumber: 47, + endColumn: 9 + }, + selectedText: "if (line.startsWith('1 ')) {\n const parts = line.split(' ')\n}" + }) + ).toBe( + [ + 'File: src/main/git/status.ts', + 'Lines: 44-47', + '', + '```ts', + "if (line.startsWith('1 ')) {\n const parts = line.split(' ')\n}", + '```' + ].join('\n') + ) + }) + + it('treats column-1 end positions as the previous line for full-line selections', () => { + const selection = { + startLineNumber: 44, + startColumn: 1, + endLineNumber: 48, + endColumn: 1 + } + + expect(getContextualCopyLineRange(selection)).toEqual({ + startLine: 44, + endLine: 47 + }) + + expect( + formatCopiedSelectionWithContext({ + relativePath: 'src/main/git/status.ts', + language: 'typescript', + selection, + selectedText: 'line 44\nline 45\nline 46\nline 47\n' + }) + ).toContain('Lines: 44-47') + }) + + it('keeps full-line single-line selections copyable', () => { + expect( + formatCopiedSelectionWithContext({ + relativePath: 'src/main/git/status.ts', + language: 'typescript', + selection: { + startLineNumber: 44, + startColumn: 1, + endLineNumber: 45, + endColumn: 1 + }, + selectedText: 'line 44\n' + }) + ).toContain('Line: 44') + }) + + it('leaves single-line selections alone', () => { + expect( + formatCopiedSelectionWithContext({ + relativePath: 'src/main/git/status.ts', + language: 'typescript', + selection: { + startLineNumber: 44, + startColumn: 4, + endLineNumber: 44, + endColumn: 15 + }, + selectedText: 'line.startsWith' + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/selection-copy.ts b/src/renderer/src/components/editor/selection-copy.ts new file mode 100644 index 00000000000..689fe7bcd02 --- /dev/null +++ b/src/renderer/src/components/editor/selection-copy.ts @@ -0,0 +1,69 @@ +import type { IRange } from 'monaco-editor' + +type FormatCopiedSelectionArgs = { + relativePath: string + language: string + selection: IRange + selectedText: string +} + +export function formatCopiedSelectionWithContext({ + relativePath, + language, + selection, + selectedText +}: FormatCopiedSelectionArgs): string | null { + const { startLine, endLine } = getContextualCopyLineRange(selection) + const isSingleLineSelection = selection.startLineNumber === selection.endLineNumber + if (isSingleLineSelection) { + return null + } + + if (endLine < startLine) { + return null + } + + const codeFenceLanguage = getCodeFenceLanguage(language) + const codeBlock = selectedText.endsWith('\n') ? selectedText : `${selectedText}\n` + const lineLabel = startLine === endLine ? `Line: ${startLine}` : `Lines: ${startLine}-${endLine}` + + return `File: ${relativePath}\n${lineLabel}\n\n\`\`\`${codeFenceLanguage}\n${codeBlock}\`\`\`` +} + +export function getContextualCopyLineRange(selection: IRange): { + startLine: number + endLine: number +} { + return { + startLine: selection.startLineNumber, + endLine: getInclusiveEndLine(selection) + } +} + +export function getInclusiveEndLine(selection: IRange): number { + if (selection.startLineNumber === selection.endLineNumber) { + return selection.endLineNumber + } + + // Why: Monaco reports a full-line selection as ending at column 1 of the + // following line. We translate that boundary back to the last copied line so + // pasted context matches what the user actually selected. + if (selection.endColumn === 1) { + return selection.endLineNumber - 1 + } + + return selection.endLineNumber +} + +function getCodeFenceLanguage(language: string): string { + switch (language) { + case 'plaintext': + return '' + case 'typescript': + return 'ts' + case 'javascript': + return 'js' + default: + return language + } +} diff --git a/src/renderer/src/components/editor/setup-contextual-copy.ts b/src/renderer/src/components/editor/setup-contextual-copy.ts new file mode 100644 index 00000000000..fb860d7c961 --- /dev/null +++ b/src/renderer/src/components/editor/setup-contextual-copy.ts @@ -0,0 +1,262 @@ +import type { editor } from 'monaco-editor' +import { formatCopiedSelectionWithContext, getContextualCopyLineRange } from './selection-copy' + +export function setupContextualCopy({ + editorInstance, + monaco, + filePath, + copyShortcutLabel, + setCopyToast, + propsRef, + copyToastTimeoutRef, + copyHintIntervalRef +}: { + editorInstance: editor.IStandaloneCodeEditor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + monaco: any + filePath: string + copyShortcutLabel: string + setCopyToast: (toast: { left: number; top: number } | null) => void + propsRef: React.MutableRefObject<{ + relativePath: string + language: string + onSave: (content: string) => void + }> + copyToastTimeoutRef: React.MutableRefObject + copyHintIntervalRef: React.MutableRefObject +}): void { + let copyHintWidgetPosition: editor.IContentWidgetPosition | null = null + let lastCopiedSelectionKey: string | null = null + const copyHintNode = document.createElement('div') + copyHintNode.className = + 'pointer-events-none rounded-md border border-border/90 bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-[0_6px_18px_rgba(15,23,42,0.18)] backdrop-blur whitespace-nowrap' + copyHintNode.textContent = `Copy context ${copyShortcutLabel}` + copyHintNode.style.display = 'none' + const copyHintWidget: editor.IContentWidget = { + allowEditorOverflow: true, + suppressMouseDown: true, + getId: () => `orca.copy-context-hint.${filePath}`, + getDomNode: () => copyHintNode, + getPosition: () => copyHintWidgetPosition + } + editorInstance.addContentWidget(copyHintWidget) + + const showCopyToast = (): void => { + const selection = editorInstance.getSelection() + if (!selection) { + return + } + const visiblePosition = editorInstance.getScrolledVisiblePosition(selection.getEndPosition()) + const bounds = editorInstance.getContainerDomNode().getBoundingClientRect() + setCopyToast({ + left: bounds.left + (visiblePosition?.left ?? bounds.width - 120), + top: bounds.top + (visiblePosition?.top ?? 16) + (visiblePosition?.height ?? 20) + 8 + }) + if (copyToastTimeoutRef.current !== null) { + window.clearTimeout(copyToastTimeoutRef.current) + } + copyToastTimeoutRef.current = window.setTimeout(() => { + setCopyToast(null) + copyToastTimeoutRef.current = null + }, 1200) + } + + const getSelectionKey = (): string | null => { + const selection = editorInstance.getSelection() + if (!selection) { + return null + } + + return [ + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn + ].join(':') + } + + const updateCopyHint = (): void => { + const contextualCopyText = getContextualCopyText() + if (!contextualCopyText) { + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + return + } + + if (lastCopiedSelectionKey !== null && lastCopiedSelectionKey === getSelectionKey()) { + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + return + } + + const model = editorInstance.getModel() + const selection = editorInstance.getSelection() + if (!model || !selection) { + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + return + } + + const { startLine, endLine } = getContextualCopyLineRange(selection) + const startVisiblePosition = editorInstance.getScrolledVisiblePosition( + selection.getStartPosition() + ) + const endColumn = + selection.endLineNumber === endLine ? selection.endColumn : model.getLineMaxColumn(endLine) + const endVisiblePosition = editorInstance.getScrolledVisiblePosition({ + lineNumber: endLine, + column: endColumn + }) + + if (!startVisiblePosition || !endVisiblePosition) { + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + return + } + + const hintHeight = copyHintNode.offsetHeight || 28 + const verticalGap = 8 + const viewportHeight = editorInstance.getLayoutInfo().height + const selectionTop = startVisiblePosition.top + const selectionBottom = endVisiblePosition.top + endVisiblePosition.height + const spaceAbove = selectionTop + const spaceBelow = viewportHeight - selectionBottom + const placeAbove = spaceAbove >= hintHeight + verticalGap || spaceAbove >= spaceBelow + const anchorLineNumber = placeAbove ? startLine : endLine + const anchorColumn = placeAbove + ? model.getLineMaxColumn(startLine) + : selection.endLineNumber !== endLine + ? model.getLineMaxColumn(endLine) + : selection.endColumn + + copyHintWidgetPosition = { + position: { lineNumber: anchorLineNumber, column: anchorColumn }, + secondaryPosition: { + lineNumber: anchorLineNumber, + column: Math.max(1, anchorColumn - 1) + }, + preference: [ + placeAbove + ? monaco.editor.ContentWidgetPositionPreference.ABOVE + : monaco.editor.ContentWidgetPositionPreference.BELOW + ] + } + copyHintNode.style.display = 'block' + editorInstance.layoutContentWidget(copyHintWidget) + } + + const startCopyHintPolling = (): void => { + updateCopyHint() + if (copyHintIntervalRef.current !== null) { + return + } + copyHintIntervalRef.current = window.setInterval(() => { + updateCopyHint() + }, 150) + } + + const stopCopyHintPolling = (): void => { + if (copyHintIntervalRef.current !== null) { + window.clearInterval(copyHintIntervalRef.current) + copyHintIntervalRef.current = null + } + } + + const getContextualCopyText = (): string | null => { + const model = editorInstance.getModel() + const selection = editorInstance.getSelection() + if (!model || !selection || selection.isEmpty()) { + return null + } + + return formatCopiedSelectionWithContext({ + relativePath: propsRef.current.relativePath, + language: propsRef.current.language, + selection, + selectedText: model.getValueInRange(selection) + }) + } + + const copySelectionWithContext = async (): Promise => { + const copiedText = getContextualCopyText() + if (!copiedText) { + return false + } + + // Why: terminal agents only receive pasted plain text. We write the + // contextual payload at copy time so file and line metadata survives + // once the snippet leaves Orca and is pasted into a terminal. + await window.api.ui.writeClipboardText(copiedText) + // Why: once the user has copied this exact selection, surfacing the + // affordance again during the confirmation toast reads like duplicate + // UI. We keep it suppressed until the selection actually changes. + lastCopiedSelectionKey = getSelectionKey() + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + showCopyToast() + return true + } + + const isContextualCopyShortcut = (event: KeyboardEvent): boolean => { + const isMac = navigator.userAgent.includes('Mac') + const mod = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey + return mod && event.altKey && !event.shiftKey && event.key.toLowerCase() === 'c' + } + + editorInstance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.KeyC, () => { + void copySelectionWithContext() + }) + + editorInstance.onDidChangeCursorSelection(() => { + if (getSelectionKey() !== lastCopiedSelectionKey) { + lastCopiedSelectionKey = null + } + updateCopyHint() + }) + editorInstance.onDidScrollChange(() => { + updateCopyHint() + }) + editorInstance.onDidFocusEditorText(() => { + startCopyHintPolling() + }) + editorInstance.onDidBlurEditorText(() => { + stopCopyHintPolling() + copyHintNode.style.display = 'none' + copyHintWidgetPosition = null + editorInstance.layoutContentWidget(copyHintWidget) + }) + + const editorDomNode = editorInstance.getContainerDomNode() + const handleKeyDown = (event: KeyboardEvent): void => { + if (!isContextualCopyShortcut(event)) { + return + } + + // Why: Monaco and Chromium can claim some Cmd/Ctrl+Shift shortcuts before the Monaco + // command callback runs. Capturing the shortcut on the editor DOM keeps the + // contextual-copy action reliable without changing native Cmd/Ctrl+C. + event.preventDefault() + event.stopPropagation() + void copySelectionWithContext() + } + editorDomNode.addEventListener('keydown', handleKeyDown, true) + editorDomNode.addEventListener('mouseup', updateCopyHint, true) + editorDomNode.addEventListener('keyup', updateCopyHint, true) + editorInstance.onDidDispose(() => { + editorDomNode.removeEventListener('keydown', handleKeyDown, true) + editorDomNode.removeEventListener('mouseup', updateCopyHint, true) + editorDomNode.removeEventListener('keyup', updateCopyHint, true) + stopCopyHintPolling() + editorInstance.removeContentWidget(copyHintWidget) + }) + if (editorInstance.hasTextFocus()) { + startCopyHintPolling() + } else { + updateCopyHint() + } +}