mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: improve contextual copy affordance in monaco (#272)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user