fix: improve contextual copy affordance in monaco (#272)

This commit is contained in:
Jinjing
2026-04-03 14:37:40 -07:00
committed by GitHub
parent 1cabc35e45
commit ccc1fa7b8b
4 changed files with 464 additions and 4 deletions
@@ -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<editor.IStandaloneCodeEditor | null>(null)
const copyToastTimeoutRef = useRef<number | null>(null)
const copyHintIntervalRef = useRef<number | null>(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 (
<>
<div className="relative h-full">
<Editor
height="100%"
language={language}
@@ -182,6 +219,14 @@ 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}
{/* Radix context menu for line number gutter right-click */}
<DropdownMenu open={gutterMenuOpen} onOpenChange={setGutterMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
@@ -228,7 +273,7 @@ export default function MonacoEditor({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
</div>
)
}
@@ -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()
})
})
@@ -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
}
}
@@ -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<number | null>
copyHintIntervalRef: React.MutableRefObject<number | null>
}): 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<boolean> => {
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()
}
}