From 554b81a3bfba51f262b8c1bc9b052ef8f8a05902 Mon Sep 17 00:00:00 2001 From: Peter van Gulik Date: Tue, 30 Jun 2026 20:25:14 +0200 Subject: [PATCH 1/2] fix(webviews): simplify Monaco clipboard handling --- .../monaco-editor/monaco-editor.component.ts | 44 ++++++------------- 1 file changed, 14 insertions(+), 30 deletions(-) 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..eed75c5a 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 @@ -176,14 +176,19 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { this.valueChange.emit(this.editor?.getValue() ?? ''); } }); - const keyDisposable = this.editor.onKeyDown(event => { - void this.handleClipboardShortcut(event).catch(() => undefined); + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyC, () => { + void this.copySelection(this.editor!, false); + }); + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyX, () => { + void this.copySelection(this.editor!, true); + }); + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { + void this.pasteClipboardText(this.editor!); }); this.editorReady.emit(this.editor); this.destroyRef.onDestroy(() => { contentDisposable.dispose(); - keyDisposable.dispose(); const model = this.editor?.getModel(); if (model) { monaco.editor.setModelMarkers(model, this.markerOwner(), []); @@ -195,21 +200,7 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { }); } - private async handleClipboardShortcut(event: monaco.IKeyboardEvent) { - const editor = this.editor; - if (!editor || !usesPrimaryModifier(event) || 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') { - await this.pasteClipboardText(editor, event); - } - } - - private async copySelection(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent, cut: boolean) { + private async copySelection(editor: monaco.editor.IStandaloneCodeEditor, cut: boolean) { const model = editor.getModel(); const selections = editor.getSelections() ?? []; if (!model || selections.length === 0) { @@ -220,9 +211,6 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { ? selections.filter(selection => !selection.isEmpty()) : getFullLineRanges(model, selections); - event.preventDefault(); - event.stopPropagation(); - await writeClipboardText(ranges.map(range => model.getValueInRange(range)).join(hasSelection ? '\n' : '')); if (!cut || this.readOnly()) { return; @@ -231,25 +219,21 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { editor.executeEdits('clipboard', ranges.map(range => ({ range, text: '' }))); } - private async pasteClipboardText(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent) { + private async pasteClipboardText(editor: monaco.editor.IStandaloneCodeEditor) { if (this.readOnly()) { return; } - event.preventDefault(); - event.stopPropagation(); - const text = await readClipboardText(); if (text) { - editor.trigger('keyboard', 'paste', { text }); + const selections = editor.getSelections() ?? []; + editor.pushUndoStop(); + editor.executeEdits('clipboard', selections.map(selection => ({ range: selection, text }))); + editor.pushUndoStop(); } } } -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); From ae64486c72e604fde865fd8a8205a3be7bc2b96b Mon Sep 17 00:00:00 2001 From: Peter van Gulik Date: Wed, 1 Jul 2026 15:02:34 +0200 Subject: [PATCH 2/2] fix(webviews): scope Monaco clipboard shortcuts to the focused editor Replace editor.addCommand with per-editor onKeyDown interception. addCommand registers unscoped keybindings on the singleton standalone keybinding service, so with multiple Monaco editors mounted (formula list, preview input/output) Ctrl+C/X/V targeted the last-created editor regardless of focus and leaked on destroy. onKeyDown is scoped to the focused editor and disposed with it. Restore editor.trigger('keyboard','paste',{text}) so paste keeps multi-cursor spread and paste-on-new-line. Make the clipboard write fire-and-forget: the host bridge is only required for read (paste) in a webview, so writes no longer round-trip for an unused ack. Co-Authored-By: Claude Opus 4.8 --- .../src/webviews/modelBackedEditorProvider.ts | 17 ++---- .../monaco-editor/monaco-editor.component.ts | 48 +++++++++------ .../src/shared/utils/webview-clipboard.ts | 58 ++++++++----------- 3 files changed, 59 insertions(+), 64 deletions(-) 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 eed75c5a..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 @@ -176,19 +176,14 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { this.valueChange.emit(this.editor?.getValue() ?? ''); } }); - this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyC, () => { - void this.copySelection(this.editor!, false); - }); - this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyX, () => { - void this.copySelection(this.editor!, true); - }); - this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { - void this.pasteClipboardText(this.editor!); + const keyDisposable = this.editor.onKeyDown(event => { + void this.handleClipboardShortcut(event).catch(() => undefined); }); this.editorReady.emit(this.editor); this.destroyRef.onDestroy(() => { contentDisposable.dispose(); + keyDisposable.dispose(); const model = this.editor?.getModel(); if (model) { monaco.editor.setModelMarkers(model, this.markerOwner(), []); @@ -200,7 +195,19 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { }); } - private async copySelection(editor: monaco.editor.IStandaloneCodeEditor, cut: boolean) { + private async handleClipboardShortcut(event: monaco.IKeyboardEvent) { + const editor = this.editor; + if (!editor || !(event.ctrlKey || event.metaKey) || event.altKey) { + return; + } + 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 copySelection(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent, cut: boolean) { const model = editor.getModel(); const selections = editor.getSelections() ?? []; if (!model || selections.length === 0) { @@ -211,25 +218,28 @@ export class VlocodeMonacoEditorComponent implements AfterViewInit { ? selections.filter(selection => !selection.isEmpty()) : getFullLineRanges(model, selections); - await writeClipboardText(ranges.map(range => model.getValueInRange(range)).join(hasSelection ? '\n' : '')); - if (!cut || this.readOnly()) { - return; - } + event.preventDefault(); + event.stopPropagation(); - editor.executeEdits('clipboard', ranges.map(range => ({ range, text: '' }))); + // 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: '' }))); + } } - private async pasteClipboardText(editor: monaco.editor.IStandaloneCodeEditor) { + private async pasteClipboardText(editor: monaco.editor.IStandaloneCodeEditor, event: monaco.IKeyboardEvent) { if (this.readOnly()) { return; } + event.preventDefault(); + event.stopPropagation(); + const text = await readClipboardText(); if (text) { - const selections = editor.getSelections() ?? []; - editor.pushUndoStop(); - editor.executeEdits('clipboard', selections.map(selection => ({ range: selection, text }))); - editor.pushUndoStop(); + // Route through Monaco's paste handler to preserve multi-cursor spread and paste-on-new-line. + editor.trigger('keyboard', 'paste', { text }); } } } 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));