diff --git a/packages/vscode-extension/src/webviews/modelBackedEditorProvider.ts b/packages/vscode-extension/src/webviews/modelBackedEditorProvider.ts index 8dfde51c..16c56af5 100644 --- a/packages/vscode-extension/src/webviews/modelBackedEditorProvider.ts +++ b/packages/vscode-extension/src/webviews/modelBackedEditorProvider.ts @@ -262,22 +262,17 @@ export abstract class ModelBackedEditorProvider< private async handleClipboardMessage(webview: vscode.Webview, message: EditorMessage): Promise { const requestId = typeof message.requestId === 'string' ? message.requestId : undefined; - if (!requestId) { - return; - } try { - if (message.operation === 'read') { - webview.postMessage({ type: 'clipboardResponse', requestId, text: await vscode.env.clipboard.readText() }); - return; - } if (message.operation === 'write') { + // Fire-and-forget: the webview does not await a response for writes. await vscode.env.clipboard.writeText(typeof message.text === 'string' ? message.text : ''); - webview.postMessage({ type: 'clipboardResponse', requestId }); - return; + } else if (message.operation === 'read' && requestId) { + webview.postMessage({ type: 'clipboardResponse', requestId, text: await vscode.env.clipboard.readText() }); } - webview.postMessage({ type: 'clipboardResponse', requestId, error: 'Unsupported clipboard operation.' }); } catch (error) { - webview.postMessage({ type: 'clipboardResponse', requestId, error: getErrorMessage(error) }); + if (requestId) { + webview.postMessage({ type: 'clipboardResponse', requestId, error: getErrorMessage(error) }); + } } } diff --git a/packages/vscode-webviews/src/shared/components/monaco-editor/monaco-editor.component.ts b/packages/vscode-webviews/src/shared/components/monaco-editor/monaco-editor.component.ts index 80cb18ee..925b2086 100644 --- a/packages/vscode-webviews/src/shared/components/monaco-editor/monaco-editor.component.ts +++ b/packages/vscode-webviews/src/shared/components/monaco-editor/monaco-editor.component.ts @@ -197,19 +197,17 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { private async handleClipboardShortcut(event: monaco.IKeyboardEvent) { const editor = this.editor; - if (!editor || !usesPrimaryModifier(event) || event.altKey) { + if (!editor || !(event.ctrlKey || event.metaKey) || event.altKey) { return; } - - const key = event.browserEvent.key.toLowerCase(); - if (key === 'c' || key === 'x') { - await this.copySelection(editor, event, key === 'x'); - } else if (key === 'v') { + if (event.keyCode === monaco.KeyCode.KeyC || event.keyCode === monaco.KeyCode.KeyX) { + this.copySelection(editor, event, event.keyCode === monaco.KeyCode.KeyX); + } else if (event.keyCode === monaco.KeyCode.KeyV) { await this.pasteClipboardText(editor, event); } } - private async copySelection(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent, cut: boolean) { + private copySelection(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent, cut: boolean) { const model = editor.getModel(); const selections = editor.getSelections() ?? []; if (!model || selections.length === 0) { @@ -223,12 +221,11 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { event.preventDefault(); event.stopPropagation(); - await writeClipboardText(ranges.map(range => model.getValueInRange(range)).join(hasSelection ? '\n' : '')); - if (!cut || this.readOnly()) { - return; + // The clipboard text is read from the model before the cut edit, so the write can be fire-and-forget. + writeClipboardText(ranges.map(range => model.getValueInRange(range)).join(hasSelection ? '\n' : '')); + if (cut && !this.readOnly()) { + editor.executeEdits('clipboard', ranges.map(range => ({ range, text: '' }))); } - - editor.executeEdits('clipboard', ranges.map(range => ({ range, text: '' }))); } private async pasteClipboardText(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent) { @@ -241,15 +238,12 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { const text = await readClipboardText(); if (text) { + // Route through Monaco's paste handler to preserve multi-cursor spread and paste-on-new-line. editor.trigger('keyboard', 'paste', { text }); } } } -function usesPrimaryModifier(event: monaco.IKeyboardEvent) { - return event.metaKey || event.ctrlKey; -} - function getFullLineRanges(model: monaco.editor.ITextModel, selections: readonly monaco.Selection[]) { const lineNumbers = [...new Set(selections.map(selection => selection.positionLineNumber))] .sort((left, right) => left - right); diff --git a/packages/vscode-webviews/src/shared/utils/webview-clipboard.ts b/packages/vscode-webviews/src/shared/utils/webview-clipboard.ts index a32cf810..ea2a6e43 100644 --- a/packages/vscode-webviews/src/shared/utils/webview-clipboard.ts +++ b/packages/vscode-webviews/src/shared/utils/webview-clipboard.ts @@ -2,8 +2,6 @@ export interface WebviewApi { postMessage(message: unknown): void; } -type ClipboardOperation = 'read' | 'write'; - interface ClipboardResponse { error?: string; requestId: string; @@ -11,7 +9,7 @@ interface ClipboardResponse { type: 'clipboardResponse'; } -interface PendingRequest { +interface PendingRead { reject(error: Error): void; resolve(text: string): void; timeout: number; @@ -20,7 +18,7 @@ interface PendingRequest { let vscodeApi: WebviewApi | undefined; let nextRequestId = 0; let listening = false; -const pending = new Map(); +const pendingReads = new Map(); export function registerWebviewApi(api: WebviewApi | undefined) { vscodeApi = api; @@ -30,55 +28,47 @@ export function registerWebviewApi(api: WebviewApi | undefined) { } } -export async function readClipboardText(): Promise { - return requestClipboard('read'); -} - -export async function writeClipboardText(text: string): Promise { - await requestClipboard('write', text); -} - -async function requestClipboard(operation: 'read'): Promise; -async function requestClipboard(operation: 'write', text: string): Promise; -async function requestClipboard(operation: ClipboardOperation, text = ''): Promise { +/** + * Write text to the clipboard. Fire-and-forget: the caller never needs the host + * to confirm the write, so no response is awaited. `navigator.clipboard` is used + * as a fallback when running outside a VS Code webview (e.g. the browser preview). + */ +export function writeClipboardText(text: string): void { if (vscodeApi) { - return requestVsCodeClipboard(operation, text); + vscodeApi.postMessage({ type: 'clipboard', operation: 'write', text }); + } else { + void navigator.clipboard?.writeText(text); } - return requestBrowserClipboard(operation, text); } -function requestVsCodeClipboard(operation: ClipboardOperation, text: string): Promise { +/** + * Read text from the clipboard. Inside a VS Code webview this must round-trip to + * the extension host because `navigator.clipboard.readText()` is blocked there. + */ +export async function readClipboardText(): Promise { + if (!vscodeApi) { + return navigator.clipboard ? navigator.clipboard.readText() : ''; + } const requestId = `clipboard-${Date.now()}-${++nextRequestId}`; return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { - pending.delete(requestId); + pendingReads.delete(requestId); reject(new Error('Timed out waiting for VS Code clipboard response.')); }, 3000); - pending.set(requestId, { reject, resolve, timeout }); - vscodeApi?.postMessage({ type: 'clipboard', requestId, operation, text }); + pendingReads.set(requestId, { reject, resolve, timeout }); + vscodeApi?.postMessage({ type: 'clipboard', operation: 'read', requestId }); }); } -async function requestBrowserClipboard(operation: ClipboardOperation, text: string): Promise { - if (!navigator.clipboard) { - throw new Error('Clipboard API is not available.'); - } - if (operation === 'read') { - return navigator.clipboard.readText(); - } - await navigator.clipboard.writeText(text); - return ''; -} - function handleClipboardResponse(message: unknown) { if (!isClipboardResponse(message)) { return; } - const request = pending.get(message.requestId); + const request = pendingReads.get(message.requestId); if (!request) { return; } - pending.delete(message.requestId); + pendingReads.delete(message.requestId); window.clearTimeout(request.timeout); if (message.error) { request.reject(new Error(message.error));