diff --git a/src-tauri/src/commands/native_host/clipboard.rs b/src-tauri/src/commands/native_host/clipboard.rs index c2ee2488..4bbde60c 100644 --- a/src-tauri/src/commands/native_host/clipboard.rs +++ b/src-tauri/src/commands/native_host/clipboard.rs @@ -7,9 +7,6 @@ use super::error::NativeHostError; -#[cfg(windows)] -use std::os::windows::process::CommandExt; - // ─── Existing commands (moved from native_host.rs) ────────────────────── /// Read text from the system clipboard. @@ -19,10 +16,7 @@ use std::os::windows::process::CommandExt; #[tauri::command] pub fn read_clipboard_text(app: tauri::AppHandle) -> Result { use tauri_plugin_clipboard_manager::ClipboardExt; - match app.clipboard().read_text() { - Ok(text) => Ok(text), - Err(_) => Ok(String::new()), - } + Ok(app.clipboard().read_text().unwrap_or_default()) } /// Write text to the system clipboard. @@ -75,10 +69,7 @@ pub fn read_clipboard_buffer( #[tauri::command] pub fn has_clipboard(app: tauri::AppHandle, _format: String) -> Result { use tauri_plugin_clipboard_manager::ClipboardExt; - match app.clipboard().read_text() { - Ok(text) => Ok(!text.is_empty()), - Err(_) => Ok(false), - } + Ok(app.clipboard().read_text().is_ok_and(|t| !t.is_empty())) } /// Read the macOS "Find" pasteboard text. @@ -124,12 +115,12 @@ pub fn read_clipboard_image(app: tauri::AppHandle) -> Result { - let width = image_data.width(); - let height = image_data.height(); - let rgba_bytes = image_data.rgba().to_vec(); - - let img_buffer = image::RgbaImage::from_raw(width, height, rgba_bytes) - .ok_or_else(|| NativeHostError::Other("Invalid image dimensions".to_string()))?; + let img_buffer = image::RgbaImage::from_raw( + image_data.width(), + image_data.height(), + image_data.rgba().to_vec(), + ) + .ok_or_else(|| NativeHostError::Other("Invalid image dimensions".to_string()))?; let mut png_bytes = Vec::new(); img_buffer @@ -152,17 +143,14 @@ pub fn read_clipboard_image(app: tauri::AppHandle) -> Result Result { use tauri_plugin_clipboard_manager::ClipboardExt; - match app.clipboard().read_image() { - Ok(_) => Ok(true), - Err(_) => Ok(false), - } + Ok(app.clipboard().read_image().is_ok()) } /// Trigger a paste operation by synthesizing Cmd+V / Ctrl+V. /// /// This is security-sensitive — it simulates keyboard input. -/// - macOS: Uses CGEvent API -/// - Windows: Uses SendInput API +/// - macOS: Uses AppleScript via System Events +/// - Windows: Uses SendInput API (no external process) /// - Linux: Uses xdotool (if available) #[tauri::command] pub async fn trigger_paste() -> Result<(), NativeHostError> { @@ -249,28 +237,87 @@ fn macos_trigger_paste() -> Result<(), NativeHostError> { Ok(()) } -/// Simulate a paste (Ctrl+V) on Windows via PowerShell's `SendKeys`. +/// Simulate a paste (Ctrl+V) on Windows via the Win32 `SendInput` API. /// -/// Uses `System.Windows.Forms.SendKeys::SendWait('^v')` to synthesize a -/// Ctrl+V keystroke in the currently focused window. The PowerShell process -/// is spawned with `CREATE_NO_WINDOW` to avoid a visible console window. +/// Synthesizes Ctrl key down → V key down → V key up → Ctrl key up using +/// the native `SendInput` function from `user32.dll`. This triggers the +/// browser's paste pipeline in the focused WebView2 window without spawning +/// any external processes. #[cfg(target_os = "windows")] fn windows_trigger_paste() -> Result<(), NativeHostError> { - // Use PowerShell to send Ctrl+V - let output = std::process::Command::new("powershell") - .args([ - "-Command", - "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^v')", - ]) - .creation_flags(0x08000000) // CREATE_NO_WINDOW - .output() - .map_err(|e| NativeHostError::Other(format!("Failed to trigger paste: {e}")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(NativeHostError::Other(format!( - "Failed to trigger paste: {stderr}" - ))); + const INPUT_KEYBOARD: u32 = 1; + const KEYEVENTF_KEYUP: u32 = 0x0002; + const VK_CONTROL: u16 = 0x11; + const VK_V: u16 = 0x56; + + /// Win32 `KEYBDINPUT` structure — describes a simulated keyboard event. + #[repr(C)] + struct KeybdInput { + w_vk: u16, + w_scan: u16, + dw_flags: u32, + time: u32, + dw_extra_info: usize, + } + + /// Win32 `INPUT` structure for keyboard events. + /// + /// `MOUSEINPUT` (the largest union member) is 8 bytes larger than + /// `KEYBDINPUT` on both 32-bit and 64-bit Windows. This padding ensures + /// the struct matches the Win32 `INPUT` layout when passed to `SendInput`. + #[repr(C)] + struct Input { + type_: u32, + ki: KeybdInput, + _padding: [u8; 8], + } + + extern "system" { + /// Win32 `SendInput` — sends a sequence of simulated input events. + fn SendInput(c_inputs: u32, p_inputs: *const Input, cb_size: i32) -> u32; } + + /// Create a key-down input event for the given virtual-key code. + let key_down = |vk: u16| Input { + type_: INPUT_KEYBOARD, + ki: KeybdInput { + w_vk: vk, + w_scan: 0, + dw_flags: 0, + time: 0, + dw_extra_info: 0, + }, + _padding: [0; 8], + }; + /// Create a key-up input event for the given virtual-key code. + let key_up = |vk: u16| Input { + type_: INPUT_KEYBOARD, + ki: KeybdInput { + w_vk: vk, + w_scan: 0, + dw_flags: KEYEVENTF_KEYUP, + time: 0, + dw_extra_info: 0, + }, + _padding: [0; 8], + }; + + let inputs = [ + key_down(VK_CONTROL), + key_down(VK_V), + key_up(VK_V), + key_up(VK_CONTROL), + ]; + + unsafe { + let sent = SendInput(4, inputs.as_ptr(), std::mem::size_of::() as i32); + if sent == 0 { + return Err(NativeHostError::Other( + "Failed to trigger paste: SendInput returned 0".to_string(), + )); + } + } + Ok(()) } diff --git a/src-tauri/src/window/menu.rs b/src-tauri/src/window/menu.rs index dbd03216..c1720a60 100644 --- a/src-tauri/src/window/menu.rs +++ b/src-tauri/src/window/menu.rs @@ -9,72 +9,80 @@ //! "About" item that triggers the VS Code About dialog instead of the //! system's minimal version-only dialog. -use tauri::{ - menu::{MenuBuilder, MenuItem, SubmenuBuilder}, - AppHandle, Emitter, -}; +#[cfg(target_os = "macos")] +use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; +use tauri::{AppHandle, Emitter}; /// Menu item ID for the custom About entry. const ABOUT_MENU_ID: &str = "about-vscode-dialog"; -/// Build and install the application menu. +/// Build and install the application menu (macOS only). /// /// On macOS the first submenu becomes the "application menu" (app name in /// the menu bar). We keep the standard items (Services, Hide, Quit) but /// replace the default About with a custom entry that emits a Tauri event /// so the WebView can show the VS Code About dialog. -pub fn setup(app: &tauri::App) -> tauri::Result<()> { - let app_submenu = SubmenuBuilder::new(app, "VS Codeee") - .item(&MenuItem::with_id( - app, - ABOUT_MENU_ID, - "About VS Codeee", - true, - None::<&str>, - )?) - .separator() - .services() - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .quit() - .build()?; +/// +/// On Windows/Linux the native menu is not created because its predefined +/// items (cut, copy, paste, etc.) register keyboard accelerators that +/// intercept keystrokes before they reach the WebView. In particular, +/// `paste()` calls `document.execCommand('paste')` which WebView2 blocks +/// for security, making Ctrl+V silently fail. +pub fn setup(_app: &tauri::App) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + let app_submenu = SubmenuBuilder::new(app, "VS Codeee") + .item(&MenuItem::with_id( + app, + ABOUT_MENU_ID, + "About VS Codeee", + true, + None::<&str>, + )?) + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; - let edit_submenu = SubmenuBuilder::new(app, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; + let edit_submenu = SubmenuBuilder::new(app, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; - let view_submenu = SubmenuBuilder::new(app, "View") - .text("toggle-devtools", "Toggle Developer Tools") - .build()?; + let view_submenu = SubmenuBuilder::new(app, "View") + .text("toggle-devtools", "Toggle Developer Tools") + .build()?; - let window_submenu = SubmenuBuilder::new(app, "Window") - .minimize() - .separator() - .close_window() - .build()?; + let window_submenu = SubmenuBuilder::new(app, "Window") + .minimize() + .separator() + .close_window() + .build()?; - let help_submenu = SubmenuBuilder::new(app, "Help") - .text("documentation", "Documentation") - .build()?; + let help_submenu = SubmenuBuilder::new(app, "Help") + .text("documentation", "Documentation") + .build()?; - let menu = MenuBuilder::new(app) - .item(&app_submenu) - .item(&edit_submenu) - .item(&view_submenu) - .item(&window_submenu) - .item(&help_submenu) - .build()?; + let menu = MenuBuilder::new(app) + .item(&app_submenu) + .item(&edit_submenu) + .item(&view_submenu) + .item(&window_submenu) + .item(&help_submenu) + .build()?; - app.set_menu(menu)?; + app.set_menu(menu)?; + } Ok(()) } diff --git a/src/vs/editor/contrib/clipboard/browser/clipboard.ts b/src/vs/editor/contrib/clipboard/browser/clipboard.ts index 37639936..d2a60eaa 100644 --- a/src/vs/editor/contrib/clipboard/browser/clipboard.ts +++ b/src/vs/editor/contrib/clipboard/browser/clipboard.ts @@ -33,18 +33,25 @@ const supportsCopy = (platform.isNative || document.queryCommandSupported('copy' // When loading over http, navigator.clipboard can be undefined. See https://github.com/microsoft/monaco-editor/issues/2313 const supportsPaste = (typeof navigator.clipboard === 'undefined' || browser.isFirefox) ? document.queryCommandSupported('paste') : true; +/** + * Register an editor command and return it for further chaining. + * + * @param command - The command instance to register. + * @returns The same command instance, after registration. + */ function registerCommand(command: T): T { command.register(); return command; } +/** Multi-command for the Cut operation. Registered only when the platform supports `cut`. */ export const CutAction = supportsCut ? registerCommand(new MultiCommand({ id: 'editor.action.clipboardCutAction', precondition: undefined, kbOpts: ( // Do not bind cut keybindings in the browser, // since browsers do that for us and it avoids security prompts - platform.isNative ? { + (platform.isNative || platform.isTauri) ? { primary: KeyMod.CtrlCmd | KeyCode.KeyX, win: { primary: KeyMod.CtrlCmd | KeyCode.KeyX, secondary: [KeyMod.Shift | KeyCode.Delete] }, weight: KeybindingWeight.EditorContrib @@ -75,13 +82,14 @@ export const CutAction = supportsCut ? registerCommand(new MultiCommand({ }] })) : undefined; +/** Multi-command for the Copy operation. Registered only when the platform supports `copy`. */ export const CopyAction = supportsCopy ? registerCommand(new MultiCommand({ id: 'editor.action.clipboardCopyAction', precondition: undefined, kbOpts: ( // Do not bind copy keybindings in the browser, // since browsers do that for us and it avoids security prompts - platform.isNative ? { + (platform.isNative || platform.isTauri) ? { primary: KeyMod.CtrlCmd | KeyCode.KeyC, win: { primary: KeyMod.CtrlCmd | KeyCode.KeyC, secondary: [KeyMod.CtrlCmd | KeyCode.Insert] }, weight: KeybindingWeight.EditorContrib @@ -115,13 +123,14 @@ MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.EditorContex MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.EditorContextShare, title: nls.localize2('share', "Share"), group: '11_share', order: -1, when: ContextKeyExpr.and(ContextKeyExpr.notEquals('resourceScheme', 'output'), EditorContextKeys.editorTextFocus) }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { submenu: MenuId.ExplorerContextShare, title: nls.localize2('share', "Share"), group: '11_share', order: -1 }); +/** Multi-command for the Paste operation. Registered only when the platform supports `paste`. */ export const PasteAction = supportsPaste ? registerCommand(new MultiCommand({ id: 'editor.action.clipboardPasteAction', precondition: undefined, kbOpts: ( // Do not bind paste keybindings in the browser, // since browsers do that for us and it avoids security prompts - platform.isNative ? { + (platform.isNative || platform.isTauri) ? { primary: KeyMod.CtrlCmd | KeyCode.KeyV, win: { primary: KeyMod.CtrlCmd | KeyCode.KeyV, secondary: [KeyMod.Shift | KeyCode.Insert] }, linux: { primary: KeyMod.CtrlCmd | KeyCode.KeyV, secondary: [KeyMod.Shift | KeyCode.Insert] }, @@ -153,6 +162,16 @@ export const PasteAction = supportsPaste ? registerCommand(new MultiCommand({ }] })) : undefined; +/** + * Editor action that copies the current selection to the clipboard with + * syntax highlighting preserved (rich text / HTML). + * + * Sets {@link CopyOptions.forceCopyWithSyntaxHighlighting} to `true` before + * invoking the clipboard copy so that the EditContext layer formats the + * copied text as HTML instead of plain text. On native platforms a fallback + * path writes plain-text data if the `execCommand('copy')` event never fires + * (known Electron/Tauri race condition). + */ class ExecCommandCopyWithSyntaxHighlightingAction extends EditorAction { constructor() { @@ -191,6 +210,18 @@ class ExecCommandCopyWithSyntaxHighlightingAction extends EditorAction { } } +/** + * Execute a clipboard copy with a workaround for a known race condition + * where `document.execCommand('copy')` does not fire the `copy` event. + * + * If the copy event never fires (detected via + * {@link CopyOptions.electronBugWorkaroundCopyEventHasFired}), the method + * falls back to writing plain-text content directly through the clipboard + * service, bypassing the browser's clipboard pipeline. + * + * @param editor - The active code editor whose selection should be copied. + * @param clipboardService - The clipboard service used as a fallback write path. + */ function executeClipboardCopyWithWorkaround(editor: IActiveCodeEditor, clipboardService: IClipboardService) { // !!!!! // This is a workaround for what we think is an Electron bug where @@ -208,6 +239,21 @@ function executeClipboardCopyWithWorkaround(editor: IActiveCodeEditor, clipboard } } +/** + * Register two implementations on a cut/copy multi-command: + * + * 1. **code-editor** (priority 10000) — handles the case where a code editor + * has text focus. Executes `execCommand('cut'|'copy')` on the editor's DOM + * node, with a special case for Edit Context mode where `cut` is performed + * by first copying via `execCommand('copy')` then triggering the editor's + * `Handler.Cut` to remove the selection. + * 2. **generic-dom** (priority 0) — fallback that calls `execCommand` on the + * active document for any other focused element. + * + * @param target - The multi-command to register implementations on, or `undefined` + * if the platform does not support the command (no-op). + * @param browserCommand - The `document.execCommand` identifier: `'cut'` or `'copy'`. + */ function registerExecCommandImpl(target: MultiCommand | undefined, browserCommand: 'cut' | 'copy'): void { if (!target) { return; @@ -261,6 +307,14 @@ function registerExecCommandImpl(target: MultiCommand | undefined, browserComman }); } +/** + * Notify the native Edit Context layer that a copy operation is about to occur, + * so it can prepare the clipboard data (e.g. HTML with syntax highlighting). + * + * No-op when Edit Context is disabled for the given editor. + * + * @param editor - The code editor that is about to execute a copy. + */ function logCopyCommand(editor: ICodeEditor) { const editContextEnabled = editor.getOption(EditorOption.effectiveEditContext); if (editContextEnabled) { @@ -305,8 +359,8 @@ if (PasteAction) { } else { logService.trace('registerExecCommandImpl (triggerPaste undefined)'); } - if (platform.isWeb) { - logService.trace('registerExecCommandImpl (Paste handling on web)'); + if (platform.isWeb || platform.isTauri) { + logService.trace('registerExecCommandImpl (Paste handling on web/tauri)'); // Use the clipboard service if document.execCommand('paste') was not successful return (async () => { const clipboardText = await clipboardService.readText(); @@ -339,6 +393,28 @@ if (PasteAction) { PasteAction.addImplementation(0, 'generic-dom', (accessor: ServicesAccessor, args: unknown) => { const logService = accessor.get(ILogService); logService.trace('registerExecCommandImpl (addImplementation generic-dom for : paste)'); + if (platform.isTauri) { + // The PasteAction keybinding intercepts Ctrl+V before it reaches the + // browser, so the native paste event never fires on non-editor elements + // (e.g. xterm.js terminal). Read the clipboard directly and dispatch a + // synthetic paste event so that listeners like xterm.js can process it. + const clipboardService = accessor.get(IClipboardService); + return (async () => { + const text = await clipboardService.readText(); + if (text) { + const target = getActiveDocument().activeElement; + if (target) { + const dt = new DataTransfer(); + dt.setData('text/plain', text); + target.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dt, + bubbles: true, + cancelable: true, + })); + } + } + })(); + } const triggerPaste = accessor.get(IClipboardService).triggerPaste(getActiveWindow().vscodeWindowId); return triggerPaste ?? false; }); diff --git a/src/vs/workbench/services/clipboard/tauri-browser/clipboardService.ts b/src/vs/workbench/services/clipboard/tauri-browser/clipboardService.ts index 6abd1b3e..94ac46cd 100644 --- a/src/vs/workbench/services/clipboard/tauri-browser/clipboardService.ts +++ b/src/vs/workbench/services/clipboard/tauri-browser/clipboardService.ts @@ -68,9 +68,12 @@ export class TauriClipboardService extends Disposable implements IClipboardServi /** @inheritDoc IClipboardService.triggerPaste */ triggerPaste(_targetWindowId: number): Promise | undefined { - // TODO(Phase 3): pass targetWindowId to nativeHostService once multi-window paste is supported - this.logService.trace('TauriClipboardService#triggerPaste'); - return this.nativeHostService.triggerPaste(); + // Returning undefined causes the PasteAction handler to fall through to + // the direct clipboard-read + editor.trigger() path (same as the web + // fallback), which avoids the re-entrancy issue where SendInput-synthesized + // Ctrl+V is re-caught by the same PasteAction keybinding. + this.logService.trace('TauriClipboardService#triggerPaste (disabled — using direct clipboard read)'); + return undefined; } /**