Big Paste Handler Cleanup (#2535)

Handle both types of paste data. Write utility functions to normalize
paste events to {text; image}[]. Fix duplication issue (call
preventDefault() early). Handle multiple image pasting (by adding a
slight delay). Convert Ctrl:Shift:v to use a *native paste* which allows
capturing of images!
This commit is contained in:
Mike Sawka 2025-11-07 18:15:49 -08:00 committed by GitHub
parent 4b6a3ed330
commit a9db209218
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 221 additions and 232 deletions

View file

@ -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<T extends ViewModel>`.
- 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<T>`, `PrimitiveAtom<T>`, `WritableAtom<T>` 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<HTMLDivElement>;
ref?: React.RefObject<HTMLDivElement>;
className?: string;
noGrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
@ -150,7 +144,7 @@ type HeaderInput = {
value: string;
className?: string;
isDisabled?: boolean;
ref?: React.MutableRefObject<HTMLInputElement>;
ref?: React.RefObject<HTMLInputElement>;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;

View file

@ -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 || ""));
});

View file

@ -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"

View file

@ -206,7 +206,7 @@ type SubMenuProps = {
};
visibleSubMenus: { [key: string]: any };
hoveredItems: string[];
subMenuRefs: React.MutableRefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>;
subMenuRefs: React.RefObject<{ [key: string]: React.RefObject<HTMLDivElement> }>;
handleMouseEnterItem: (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
parentKey: string | null,

View file

@ -70,7 +70,7 @@ interface InputProps {
autoSelect?: boolean;
disabled?: boolean;
isNumber?: boolean;
inputRef?: React.MutableRefObject<any>;
inputRef?: React.RefObject<any>;
manageFocus?: (isFocused: boolean) => void;
}

View file

@ -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;
}

View file

@ -612,6 +612,11 @@ class RpcApiType {
return client.wshRpcCall("writeappfile", data, opts);
}
// command "writetempfile" [call]
WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise<string> {
return client.wshRpcCall("writetempfile", data, opts);
}
// command "wshactivity" [call]
WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("wshactivity", data, opts);

View file

@ -23,7 +23,7 @@ type CSVRow = {
};
interface CSVViewProps {
parentRef: React.MutableRefObject<HTMLDivElement>;
parentRef: React.RefObject<HTMLDivElement>;
content: string;
filename: string;
readonly: boolean;

View file

@ -152,11 +152,11 @@ export class PreviewModel implements ViewModel {
openFileModal: PrimitiveAtom<boolean>;
openFileModalDelay: PrimitiveAtom<boolean>;
openFileError: PrimitiveAtom<string>;
openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>;
openFileModalGiveFocusRef: React.RefObject<() => boolean>;
markdownShowToc: PrimitiveAtom<boolean>;
monacoRef: React.MutableRefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
monacoRef: React.RefObject<MonacoTypes.editor.IStandaloneCodeEditor>;
showHiddenFiles: PrimitiveAtom<boolean>;
refreshVersion: PrimitiveAtom<number>;

View file

@ -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<TermWrap> = { current: null };
termRef: React.RefObject<TermWrap> = { current: null };
blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>;
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<void> {
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();

View file

@ -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<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
@ -47,6 +45,11 @@ export const MIME_TO_EXT: Record<string, string> = {
"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<string, string> = {
* 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<string> {
export async function createTempFileFromBlob(blob: Blob): Promise<string> {
// 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<string>((resolve, reject) => {
const arrayBuffer = await new Promise<ArrayBuffer>((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<void> {
export async function extractAllClipboardData(e?: ClipboardEvent): Promise<Array<{ text?: string; image?: Blob }>> {
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;
}
}

View file

@ -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<void> {
await handleImagePasteBlobUtil(blob, TabRpcClient, (text) => {
this.terminal.paste(text);
});
}
async handleImagePaste(item: ClipboardItem, mimeType: string): Promise<void> {
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<void> {
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);
}
}
}

View file

@ -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<HTMLDivElement>;
ref?: React.RefObject<HTMLDivElement>;
className?: string;
noGrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
@ -217,7 +218,7 @@ declare global {
value: string;
className?: string;
isDisabled?: boolean;
ref?: React.MutableRefObject<HTMLInputElement>;
ref?: React.RefObject<HTMLInputElement>;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;

View file

@ -463,6 +463,12 @@ declare global {
data64: string;
};
// wshrpc.CommandWriteTempFileData
type CommandWriteTempFileData = {
filename: string;
data64: string;
};
// wconfig.ConfigError
type ConfigError = {
file: string;

View file

@ -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)

View file

@ -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"`

View file

@ -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 {