diff --git a/aiprompts/view-prompt.md b/aiprompts/view-prompt.md index 4a7f2ad5a5..b88ba17bff 100644 --- a/aiprompts/view-prompt.md +++ b/aiprompts/view-prompt.md @@ -7,7 +7,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each ### Key Concepts 1. **ViewModel Structure** - - Implements the `ViewModel` interface. - Defines: - `viewType`: Unique block type identifier. @@ -19,14 +18,12 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`. 2. **ViewComponent Structure** - - A **React function component** implementing `ViewComponentProps`. - Uses `blockId`, `blockRef`, `contentRef`, and `model` as props. - Retrieves ViewModel state using Jotai atoms. - Returns JSX for rendering. 3. **Header Elements (`HeaderElem[]`)** - - Can include: - **Icons (`IconButtonDecl`)**: Clickable buttons. - **Text (`HeaderText`)**: Metadata or status. @@ -34,13 +31,11 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - **Menu Buttons (`MenuButton`)**: Dropdowns. 4. **Jotai Atoms for State Management** - - Use `atom`, `PrimitiveAtom`, `WritableAtom` for dynamic properties. - `splitAtom` for managing lists of atoms. - Read settings from `globalStore` and override with block metadata. 5. **Metadata vs. Global Config** - - **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`). - **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files. - **Cascading Behavior**: @@ -50,7 +45,6 @@ Wave Terminal uses a modular ViewModel system to define interactive blocks. Each - Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden). 6. **Useful Helper Functions** - - To avoid repetitive boilerplate, use these global utilities from `global.ts`: - `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata. - `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides. @@ -139,7 +133,7 @@ type HeaderTextButton = { type HeaderText = { elemtype: "text"; text: string; - ref?: React.MutableRefObject; + ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; @@ -150,7 +144,7 @@ type HeaderInput = { value: string; className?: string; isDisabled?: boolean; - ref?: React.MutableRefObject; + ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void; diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 88e4e24b32..7ae95ef6c7 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -400,6 +400,10 @@ export function initIpcHandlers() { incrementTermCommandsRun(); }); + electron.ipcMain.on("native-paste", (event) => { + event.sender.paste(); + }); + electron.ipcMain.on("open-builder", (event, appId?: string) => { fireAndForget(() => createBuilderWindow(appId || "")); }); diff --git a/emain/preload.ts b/emain/preload.ts index 28e1b17808..d511b28403 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -62,6 +62,7 @@ contextBridge.exposeInMainWorld("api", { setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), incrementTermCommands: () => ipcRenderer.send("increment-term-commands"), + nativePaste: () => ipcRenderer.send("native-paste"), }); // Custom event for "new-window" diff --git a/frontend/app/element/flyoutmenu.tsx b/frontend/app/element/flyoutmenu.tsx index 502421f302..7f327c6fb5 100644 --- a/frontend/app/element/flyoutmenu.tsx +++ b/frontend/app/element/flyoutmenu.tsx @@ -206,7 +206,7 @@ type SubMenuProps = { }; visibleSubMenus: { [key: string]: any }; hoveredItems: string[]; - subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject }>; + subMenuRefs: React.RefObject<{ [key: string]: React.RefObject }>; handleMouseEnterItem: ( event: React.MouseEvent, parentKey: string | null, diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx index 83dcb11d15..189a732ac8 100644 --- a/frontend/app/element/input.tsx +++ b/frontend/app/element/input.tsx @@ -70,7 +70,7 @@ interface InputProps { autoSelect?: boolean; disabled?: boolean; isNumber?: boolean; - inputRef?: React.MutableRefObject; + inputRef?: React.RefObject; manageFocus?: (isFocused: boolean) => void; } diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index 6dc25cb32f..e8df6fd79a 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -82,7 +82,7 @@ interface TypeAheadModalProps { onSelect?: (_: string) => void; onClickBackdrop?: () => void; onKeyDown?: (_) => void; - giveFocusRef?: React.MutableRefObject<() => boolean>; + giveFocusRef?: React.RefObject<() => boolean>; autoFocus?: boolean; selectIndex?: number; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8171f727a6..ee7d5a87e0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -612,6 +612,11 @@ class RpcApiType { return client.wshRpcCall("writeappfile", data, opts); } + // command "writetempfile" [call] + WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writetempfile", data, opts); + } + // command "wshactivity" [call] WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { return client.wshRpcCall("wshactivity", data, opts); diff --git a/frontend/app/view/preview/csvview.tsx b/frontend/app/view/preview/csvview.tsx index 9cb889ac1e..2c49f46770 100644 --- a/frontend/app/view/preview/csvview.tsx +++ b/frontend/app/view/preview/csvview.tsx @@ -23,7 +23,7 @@ type CSVRow = { }; interface CSVViewProps { - parentRef: React.MutableRefObject; + parentRef: React.RefObject; content: string; filename: string; readonly: boolean; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index a68585c8ae..b6aa8313ba 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -152,11 +152,11 @@ export class PreviewModel implements ViewModel { openFileModal: PrimitiveAtom; openFileModalDelay: PrimitiveAtom; openFileError: PrimitiveAtom; - openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; + openFileModalGiveFocusRef: React.RefObject<() => boolean>; markdownShowToc: PrimitiveAtom; - monacoRef: React.MutableRefObject; + monacoRef: React.RefObject; showHiddenFiles: PrimitiveAtom; refreshVersion: PrimitiveAtom; diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index cb03bfb259..0a5c39ad86 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -1,7 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 - import { BlockNodeModel } from "@/app/block/blocktypes"; import { appHandleKeyDown } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; @@ -15,6 +14,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { atoms, getAllBlockComponentModels, + getApi, getBlockComponentModel, getBlockMetaKeyAtom, getConnStatusAtom, @@ -29,22 +29,15 @@ import * as keyutil from "@/util/keyutil"; import { boundNumber, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; -import { - computeTheme, - createTempFileFromBlob, - DefaultTermTheme, - handleImagePasteBlob as handleImagePasteBlobUtil, - supportsImageInput as supportsImageInputUtil, -} from "./termutil"; -import { TermWrap } from "./termwrap"; import { getBlockingCommand } from "./shellblocking"; +import { computeTheme, DefaultTermTheme } from "./termutil"; +import { TermWrap } from "./termwrap"; export class TermViewModel implements ViewModel { - viewType: string; nodeModel: BlockNodeModel; connected: boolean; - termRef: React.MutableRefObject = { current: null }; + termRef: React.RefObject = { current: null }; blockAtom: jotai.Atom; termMode: jotai.Atom; blockId: string; @@ -398,51 +391,6 @@ export class TermViewModel implements ViewModel { RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } - async handlePaste() { - try { - const clipboardItems = await navigator.clipboard.read(); - - for (const item of clipboardItems) { - // Check for images first - const imageTypes = item.types.filter((type) => type.startsWith("image/")); - if (imageTypes.length > 0 && this.supportsImageInput()) { - const blob = await item.getType(imageTypes[0]); - await this.handleImagePasteBlob(blob); - return; - } - - // Handle text - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const text = await blob.text(); - this.termRef.current?.terminal.paste(text); - return; - } - } - } catch (err) { - console.error("Paste error:", err); - // Fallback to text-only paste - try { - const text = await navigator.clipboard.readText(); - if (text) { - this.termRef.current?.terminal.paste(text); - } - } catch (fallbackErr) { - console.error("Fallback paste error:", fallbackErr); - } - } - } - - supportsImageInput(): boolean { - return supportsImageInputUtil(); - } - - async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { - this.termRef.current?.terminal.paste(text); - }); - } - setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; @@ -559,22 +507,26 @@ export class TermViewModel implements ViewModel { const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline"); const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true; if (shiftEnterNewlineEnabled) { - this.sendDataToController("\u001b\n"); + this.sendDataToController("\n"); event.preventDefault(); event.stopPropagation(); return false; } } if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - this.handlePaste(); event.preventDefault(); event.stopPropagation(); + getApi().nativePaste(); + // this.termRef.current?.pasteHandler(); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { - const sel = this.termRef.current?.terminal.getSelection(); - navigator.clipboard.writeText(sel); event.preventDefault(); event.stopPropagation(); + const sel = this.termRef.current?.terminal.getSelection(); + if (!sel) { + return false; + } + navigator.clipboard.writeText(sel); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) { event.preventDefault(); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index a57c81baed..3f82b14ba2 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 export const DefaultTermTheme = "default-dark"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import base64 from "base64-js"; import { colord } from "colord"; function applyTransparencyToColor(hexColor: string, transparency: number): string { @@ -10,7 +13,7 @@ function applyTransparencyToColor(hexColor: string, transparency: number): strin } // returns (theme, bgcolor, transparency (0 - 1.0)) -function computeTheme( +export function computeTheme( fullConfig: FullConfigType, themeName: string, termTransparency: number @@ -33,11 +36,6 @@ function computeTheme( return [themeCopy, bgcolor]; } -export { computeTheme }; - -import { RpcApi } from "@/app/store/wshclientapi"; -import { WshClient } from "@/app/store/wshclient"; - export const MIME_TO_EXT: Record = { "image/png": "png", "image/jpeg": "jpg", @@ -47,6 +45,11 @@ export const MIME_TO_EXT: Record = { "image/bmp": "bmp", "image/svg+xml": "svg", "image/tiff": "tiff", + "image/heic": "heic", + "image/heif": "heif", + "image/avif": "avif", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", }; /** @@ -55,45 +58,38 @@ export const MIME_TO_EXT: Record = { * and returns the file path. * * @param blob - The Blob to save - * @param client - The WshClient for RPC calls * @returns The path to the created temporary file * @throws Error if blob is too large (>5MB) or data URL is invalid */ -export async function createTempFileFromBlob(blob: Blob, client: WshClient): Promise { +export async function createTempFileFromBlob(blob: Blob): Promise { // Check size limit (5MB) if (blob.size > 5 * 1024 * 1024) { throw new Error("Image too large (>5MB)"); } // Get file extension from MIME type - const ext = MIME_TO_EXT[blob.type] || "png"; + if (!blob.type.startsWith("image/") || !MIME_TO_EXT[blob.type]) { + throw new Error(`Unsupported or invalid image type: ${blob.type}`); + } + const ext = MIME_TO_EXT[blob.type]; // Generate unique filename with timestamp and random component const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const filename = `waveterm_paste_${timestamp}_${random}.${ext}`; - // Get platform-appropriate temp file path from backend - const tempPath = await RpcApi.GetTempDirCommand(client, { filename }); - - // Convert blob to base64 using FileReader - const dataUrl = await new Promise((resolve, reject) => { + const arrayBuffer = await new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); + reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = reject; - reader.readAsDataURL(blob); + reader.readAsArrayBuffer(blob); }); - // Extract base64 data from data URL (remove "data:image/png;base64," prefix) - const parts = dataUrl.split(","); - if (parts.length < 2) { - throw new Error("Invalid data URL format"); - } - const base64Data = parts[1]; + const base64Data = base64.fromByteArray(new Uint8Array(arrayBuffer)); - // Write image to temp file - await RpcApi.FileWriteCommand(client, { - info: { path: tempPath }, + // Write image to temp file and get path + const tempPath = await RpcApi.WriteTempFileCommand(TabRpcClient, { + filename, data64: base64Data, }); @@ -101,34 +97,100 @@ export async function createTempFileFromBlob(blob: Blob, client: WshClient): Pro } /** - * Checks if image input is supported. - * Images will be saved as temp files and the path will be pasted. - * Claude Code and other AI tools can then read the file. + * Extracts text or image data from a clipboard item. + * Prioritizes images over text - if an image is found, only the image is returned. * - * @returns true if image input is supported + * @param item - Either a DataTransferItem or ClipboardItem + * @returns Object with either text or image, or null if neither could be extracted */ -export function supportsImageInput(): boolean { - return true; +export async function extractClipboardData( + item: DataTransferItem | ClipboardItem +): Promise<{ text?: string; image?: Blob } | null> { + // Check if it's a DataTransferItem (has 'kind' property) + if ("kind" in item) { + const dataTransferItem = item as DataTransferItem; + + // Check for image first + if (dataTransferItem.type.startsWith("image/")) { + const blob = dataTransferItem.getAsFile(); + if (blob) { + return { image: blob }; + } + } + + // If not an image, try text + if (dataTransferItem.kind === "string") { + return new Promise((resolve) => { + dataTransferItem.getAsString((text) => { + resolve(text ? { text } : null); + }); + }); + } + + return null; + } + + // It's a ClipboardItem + const clipboardItem = item as ClipboardItem; + + // Check for image first + const imageTypes = clipboardItem.types.filter((type) => type.startsWith("image/")); + if (imageTypes.length > 0) { + const blob = await clipboardItem.getType(imageTypes[0]); + return { image: blob }; + } + + // If not an image, try text + const textType = clipboardItem.types.find((t) => ["text/plain", "text/html", "text/rtf"].includes(t)); + if (textType) { + const blob = await clipboardItem.getType(textType); + const text = await blob.text(); + return text ? { text } : null; + } + + return null; } /** - * Handles pasting an image blob by creating a temp file and pasting its path. + * Extracts all clipboard data from a ClipboardEvent using multiple fallback methods. + * Tries ClipboardEvent.clipboardData.items first, then Clipboard API, then simple getData(). * - * @param blob - The image blob to paste - * @param client - The WshClient for RPC calls - * @param pasteFn - Function to paste the file path into the terminal + * @param e - The ClipboardEvent (optional) + * @returns Array of objects containing text and/or image data */ -export async function handleImagePasteBlob( - blob: Blob, - client: WshClient, - pasteFn: (text: string) => void -): Promise { +export async function extractAllClipboardData(e?: ClipboardEvent): Promise> { + const results: Array<{ text?: string; image?: Blob }> = []; + try { - const tempPath = await createTempFileFromBlob(blob, client); - // Paste the file path (like iTerm2 does when you copy a file) - // Claude Code will read the file and display it as [Image #N] - pasteFn(tempPath + " "); + // First try using ClipboardEvent.clipboardData.items + if (e?.clipboardData?.items) { + for (let i = 0; i < e.clipboardData.items.length; i++) { + const data = await extractClipboardData(e.clipboardData.items[i]); + if (data) { + results.push(data); + } + } + return results; + } + + // Fallback: Try Clipboard API + const clipboardItems = await navigator.clipboard.read(); + for (const item of clipboardItems) { + const data = await extractClipboardData(item); + if (data) { + results.push(data); + } + } + return results; } catch (err) { - console.error("Error pasting image:", err); + console.error("Clipboard read error:", err); + // Final fallback: simple text paste + if (e?.clipboardData) { + const text = e.clipboardData.getData("text/plain"); + if (text) { + results.push({ text }); + } + } + return results; } } diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index ed1d0f71d2..dc150711e0 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -18,18 +18,15 @@ import { Terminal } from "@xterm/xterm"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; -import { - createTempFileFromBlob, - handleImagePasteBlob as handleImagePasteBlobUtil, - supportsImageInput as supportsImageInputUtil, -} from "./termutil"; import { FitAddon } from "./fitaddon"; +import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; +export const SupportsImageInput = true; // detect webgl support function detectWebGLSupport(): boolean { @@ -441,74 +438,11 @@ export class TermWrap { this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); - let pasteEventHandler = async (e: ClipboardEvent) => { - this.pasteActive = true; - - try { - // First try using ClipboardEvent.clipboardData (works in Electron) - if (e.clipboardData && e.clipboardData.items) { - const items = e.clipboardData.items; - - // Check for images first - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (item.type.startsWith("image/")) { - if (this.supportsImageInput()) { - e.preventDefault(); - const blob = item.getAsFile(); - if (blob) { - await this.handleImagePasteBlob(blob); - return; - } - } - } - } - - // Handle text - const text = e.clipboardData.getData("text/plain"); - if (text) { - this.terminal.paste(text); - return; - } - } - - // Fallback: Try Clipboard API for newer browsers - const clipboardItems = await navigator.clipboard.read(); - for (const item of clipboardItems) { - const imageTypes = item.types.filter((type) => type.startsWith("image/")); - if (imageTypes.length > 0 && this.supportsImageInput()) { - await this.handleImagePaste(item, imageTypes[0]); - return; - } - - if (item.types.includes("text/plain")) { - const blob = await item.getType("text/plain"); - const text = await blob.text(); - this.terminal.paste(text); - return; - } - } - } catch (err) { - console.error("Paste error:", err); - // Final fallback to simple text paste - if (e.clipboardData) { - const text = e.clipboardData.getData("text/plain"); - if (text) { - this.terminal.paste(text); - } - } - } finally { - setTimeout(() => { - this.pasteActive = false; - }, 30); - } - }; - pasteEventHandler = pasteEventHandler.bind(this); - this.connectElem.addEventListener("paste", pasteEventHandler, true); + const pasteHandler = this.pasteHandler.bind(this); + this.connectElem.addEventListener("paste", pasteHandler, true); this.toDispose.push({ dispose: () => { - this.connectElem.removeEventListener("paste", pasteEventHandler, true); + this.connectElem.removeEventListener("paste", pasteHandler, true); }, }); } @@ -642,36 +576,19 @@ export class TermWrap { return; } - // Paste Deduplication - // xterm.js paste() method triggers onData event, causing handleTermData to be called twice: - // 1. From our paste handler (pasteActive=true) - // 2. From xterm.js onData (pasteActive=false) - // We allow the first call and block the second duplicate - const DEDUP_WINDOW_MS = 50; - const now = Date.now(); - const timeSinceLastPaste = now - this.lastPasteTime; - if (this.pasteActive) { - // First paste event - record it and allow through - this.pasteActive = false; - this.lastPasteData = data; - this.lastPasteTime = now; if (this.multiInputCallback) { this.multiInputCallback(data); } - } else if (timeSinceLastPaste < DEDUP_WINDOW_MS && data === this.lastPasteData && this.lastPasteData) { - // Second paste event with same data within time window - this is a duplicate, block it - dlog("Blocked duplicate paste data:", data); - this.lastPasteData = ""; // Clear to allow same data to be pasted later - return; } // IME Deduplication (for Capslock input method switching) // When switching input methods with Capslock during composition, some systems send the // composed text twice. We allow the first send and block subsequent duplicates. + const IMEDedupWindowMs = 50; + const now = Date.now(); const timeSinceCompositionEnd = now - this.lastCompositionEnd; - - if (timeSinceCompositionEnd < DEDUP_WINDOW_MS && data === this.lastComposedText && this.lastComposedText) { + if (timeSinceCompositionEnd < IMEDedupWindowMs && data === this.lastComposedText && this.lastComposedText) { if (!this.firstDataAfterCompositionSent) { // First send after composition - allow it but mark as sent this.firstDataAfterCompositionSent = true; @@ -781,26 +698,6 @@ export class TermWrap { } } - supportsImageInput(): boolean { - return supportsImageInputUtil(); - } - - async handleImagePasteBlob(blob: Blob): Promise { - await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => { - this.terminal.paste(text); - }); - } - - async handleImagePaste(item: ClipboardItem, mimeType: string): Promise { - try { - const blob = await item.getType(mimeType); - // Reuse the existing handleImagePasteBlob logic - await this.handleImagePasteBlob(blob); - } catch (err) { - console.error("Error processing image:", err); - } - } - handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; @@ -842,4 +739,34 @@ export class TermWrap { }); }, 5000); } + + async pasteHandler(e?: ClipboardEvent): Promise { + this.pasteActive = true; + e?.preventDefault(); + e?.stopPropagation(); + + try { + const clipboardData = await extractAllClipboardData(e); + let firstImage = true; + for (const data of clipboardData) { + if (data.image && SupportsImageInput) { + if (!firstImage) { + await new Promise((r) => setTimeout(r, 150)); + } + const tempPath = await createTempFileFromBlob(data.image); + this.terminal.paste(tempPath + " "); + firstImage = false; + } + if (data.text) { + this.terminal.paste(data.text); + } + } + } catch (err) { + console.error("Paste error:", err); + } finally { + setTimeout(() => { + this.pasteActive = false; + }, 30); + } + } } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index a5e24baae9..43078bba60 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -123,6 +123,7 @@ declare global { setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open closeBuilderWindow: () => void; // close-builder-window incrementTermCommands: () => void; // increment-term-commands + nativePaste: () => void; // native-paste }; type ElectronContextMenuItem = { @@ -206,7 +207,7 @@ declare global { type HeaderText = { elemtype: "text"; text: string; - ref?: React.MutableRefObject; + ref?: React.RefObject; className?: string; noGrow?: boolean; onClick?: (e: React.MouseEvent) => void; @@ -217,7 +218,7 @@ declare global { value: string; className?: string; isDisabled?: boolean; - ref?: React.MutableRefObject; + ref?: React.RefObject; onChange?: (e: React.ChangeEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: (e: React.FocusEvent) => void; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5b751a68ce..bed436226c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -463,6 +463,12 @@ declare global { data64: string; }; + // wshrpc.CommandWriteTempFileData + type CommandWriteTempFileData = { + filename: string; + data64: string; + }; + // wconfig.ConfigError type ConfigError = { file: string; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 883760c8c5..18485f525e 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -731,6 +731,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, return err } +// command "writetempfile", wshserver.WriteTempFileCommand +func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts) + return resp, err +} + // command "wshactivity", wshserver.WshActivityCommand func WshActivityCommand(w *wshutil.WshRpc, data map[string]int, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "wshactivity", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a639bf0f0c..6666785ea6 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -84,6 +84,7 @@ const ( Command_FileShareCapability = "filesharecapability" Command_FileRestoreBackup = "filerestorebackup" Command_GetTempDir = "gettempdir" + Command_WriteTempFile = "writetempfile" Command_EventPublish = "eventpublish" Command_EventRecv = "eventrecv" @@ -224,6 +225,7 @@ type WshRpcInterface interface { FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error) FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error GetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error) + WriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error @@ -628,6 +630,11 @@ type CommandGetTempDirData struct { FileName string `json:"filename,omitempty"` } +type CommandWriteTempFileData struct { + FileName string `json:"filename"` + Data64 string `json:"data64"` +} + type CommandRemoteStreamTarData struct { Path string `json:"path"` Opts *FileCopyOpts `json:"opts,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 6e2c32a576..46d38f8266 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -468,6 +468,30 @@ func (ws *WshServer) GetTempDirCommand(ctx context.Context, data wshrpc.CommandG return tempDir, nil } +func (ws *WshServer) WriteTempFileCommand(ctx context.Context, data wshrpc.CommandWriteTempFileData) (string, error) { + if data.FileName == "" { + return "", fmt.Errorf("filename is required") + } + name := filepath.Base(data.FileName) + if name == "" || name == "." || name == ".." { + return "", fmt.Errorf("invalid filename") + } + tempDir, err := os.MkdirTemp("", "waveterm-") + if err != nil { + return "", fmt.Errorf("error creating temp directory: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return "", fmt.Errorf("error decoding base64 data: %w", err) + } + tempPath := filepath.Join(tempDir, name) + err = os.WriteFile(tempPath, decoded, 0600) + if err != nil { + return "", fmt.Errorf("error writing temp file: %w", err) + } + return tempPath, nil +} + func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil {