From 664bbef2777925c7910c2bfad3a30c7afe349b3c Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Thu, 4 Dec 2025 23:07:48 -0800 Subject: [PATCH 1/6] Add drag-and-drop file support to terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements drag-and-drop functionality for terminal, matching macOS Terminal behavior. Users can now drag files or folders from Finder directly into the terminal to insert their paths. Features: - Automatically inserts file paths when files are dropped - Handles multiple files (space-separated) - Auto-quotes paths containing spaces - Uses full file paths from Electron File API - Works with files, folders, and multiple selections Usage: Simply drag a file from Finder and drop it into any terminal block. The file path will be inserted at the cursor position. 🤖 Generated with Claude Code Co-Authored-By: Claude --- frontend/app/view/term/term.tsx | 63 ++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index aae7fe1295..5075bf490a 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -353,8 +353,69 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termBg = computeBgStyleFromMeta(blockData?.meta); + // Handle drag and drop + const handleDragOver = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Indicate that we can accept the drop + e.dataTransfer.dropEffect = "copy"; + }, []); + + const handleDrop = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Get files from the drop + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) { + return; + } + + // Get the file path(s) - for Electron, we can get the full path + const paths = files.map((file: any) => { + // In Electron, File objects have a 'path' property with the full path + if (file.path) { + return file.path; + } + // Fallback to just the name if path is not available + return file.name; + }); + + // Insert the path(s) into the terminal + // If multiple files, separate with spaces and quote if necessary + const pathString = paths.map(path => { + // Quote paths that contain spaces + if (path.includes(" ")) { + return `"${path}"`; + } + return path; + }).join(" "); + + // Send the path to the terminal + if (model.termRef.current && pathString) { + model.sendDataToController(pathString); + } + }, [model]); + + const handleDragEnter = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + return ( -
+
{termBg &&
} From 417106bea6d739303641d65ce32cfb736a84a5cf Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Thu, 4 Dec 2025 23:11:41 -0800 Subject: [PATCH 2/6] Fix drag-and-drop to use full file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Electron's webUtils.getPathForFile() API to get the actual full file path instead of just the file name. This matches macOS Terminal behavior where dragging a file inserts its complete path. Before: CV_Document.pdf After: /Users/steven/Downloads/CV_Document.pdf 🤖 Generated with Claude Code Co-Authored-By: Claude --- frontend/app/view/term/term.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 5075bf490a..d3dc5b3650 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -361,7 +361,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => e.dataTransfer.dropEffect = "copy"; }, []); - const handleDrop = React.useCallback((e: React.DragEvent) => { + const handleDrop = React.useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -371,15 +371,24 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => return; } - // Get the file path(s) - for Electron, we can get the full path - const paths = files.map((file: any) => { - // In Electron, File objects have a 'path' property with the full path - if (file.path) { - return file.path; - } - // Fallback to just the name if path is not available - return file.name; - }); + // Get the file path(s) - Use Electron's webUtils to get real paths + let paths: string[] = []; + try { + // In Electron, we need to use webUtils.getPathForFile to get the actual path + const { webUtils } = await import("electron"); + paths = files.map((file: File) => { + try { + return webUtils.getPathForFile(file); + } catch (err) { + console.warn("Could not get path for file:", file.name, err); + return file.name; + } + }); + } catch (err) { + // If webUtils is not available (non-Electron environment), fallback to file.name + console.warn("webUtils not available, using file names only"); + paths = files.map(f => f.name); + } // Insert the path(s) into the terminal // If multiple files, separate with spaces and quote if necessary From 757dc5644c4868d7b8189e3e601b57a74a80a993 Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Thu, 4 Dec 2025 23:15:10 -0800 Subject: [PATCH 3/6] Add Electron API for getting file paths in drag-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose webUtils.getPathForFile through the Electron preload API so the renderer process can get full file system paths from File objects during drag-and-drop operations. Changes: - Added getPathForFile method to preload script using webUtils - Updated ElectronApi TypeScript interface - Simplified terminal drag-drop to use new API via getApi() This properly implements macOS Terminal-style drag-and-drop with full file paths instead of just filenames. 🤖 Generated with Claude Code Co-Authored-By: Claude --- emain/preload.ts | 3 ++- frontend/app/view/term/term.tsx | 30 +++++++++++------------------- frontend/types/custom.d.ts | 1 + 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..ee60c13604 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { contextBridge, ipcRenderer, Rectangle, WebviewTag } from "electron"; +import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; // update type in custom.d.ts (ElectronApi type) contextBridge.exposeInMainWorld("api", { @@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + getPathForFile: (file: File) => webUtils.getPathForFile(file), }); // Custom event for "new-window" diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d3dc5b3650..43be1a5bd7 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -361,7 +361,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => e.dataTransfer.dropEffect = "copy"; }, []); - const handleDrop = React.useCallback(async (e: React.DragEvent) => { + const handleDrop = React.useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -371,24 +371,16 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => return; } - // Get the file path(s) - Use Electron's webUtils to get real paths - let paths: string[] = []; - try { - // In Electron, we need to use webUtils.getPathForFile to get the actual path - const { webUtils } = await import("electron"); - paths = files.map((file: File) => { - try { - return webUtils.getPathForFile(file); - } catch (err) { - console.warn("Could not get path for file:", file.name, err); - return file.name; - } - }); - } catch (err) { - // If webUtils is not available (non-Electron environment), fallback to file.name - console.warn("webUtils not available, using file names only"); - paths = files.map(f => f.name); - } + // Get the file path(s) using the Electron API + const paths = files.map((file: File) => { + try { + // Use the exposed Electron API to get the full path + return getApi().getPathForFile(file); + } catch (err) { + console.warn("Could not get path for file:", file.name, err); + return file.name; + } + }); // Insert the path(s) into the terminal // If multiple files, separate with spaces and quote if necessary diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index db2999a500..3dfdb7c3ee 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -134,6 +134,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh + getPathForFile: (file: File) => string; // get-path-for-file }; type ElectronContextMenuItem = { From 12b35b4623706106e2a6cd15bfd5ef189f1faacd Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Thu, 4 Dec 2025 23:19:51 -0800 Subject: [PATCH 4/6] Fix: Import getApi in terminal component for drag-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added missing import of getApi from @/store/global to fix 'getApi is not defined' error when dropping files. Also added debug logging to help troubleshoot file path retrieval. 🤖 Generated with Claude Code Co-Authored-By: Claude --- frontend/app/view/term/term.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 43be1a5bd7..42833fca13 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -8,7 +8,7 @@ import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import type { TermViewModel } from "@/app/view/term/term-model"; -import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, globalStore, WOS } from "@/store/global"; +import { atoms, getApi, getOverrideConfigAtom, getSettingsPrefixAtom, globalStore, WOS } from "@/store/global"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import { ISearchOptions } from "@xterm/addon-search"; @@ -371,17 +371,23 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => return; } + console.log("Drop files:", files); + // Get the file path(s) using the Electron API const paths = files.map((file: File) => { try { // Use the exposed Electron API to get the full path - return getApi().getPathForFile(file); + const fullPath = getApi().getPathForFile(file); + console.log("File:", file.name, "-> Full path:", fullPath); + return fullPath; } catch (err) { - console.warn("Could not get path for file:", file.name, err); + console.error("Could not get path for file:", file.name, err); return file.name; } }); + console.log("Paths to insert:", paths); + // Insert the path(s) into the terminal // If multiple files, separate with spaces and quote if necessary const pathString = paths.map(path => { @@ -392,6 +398,8 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => return path; }).join(" "); + console.log("Final path string:", pathString); + // Send the path to the terminal if (model.termRef.current && pathString) { model.sendDataToController(pathString); From ee80fa8023cccec2697b6386992f5925db1d9be0 Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:46:10 -0800 Subject: [PATCH 5/6] Address CodeRabbit feedback: remove console.logs, improve error handling --- frontend/app/view/term/term.tsx | 84 ++++++++++--------- frontend/types/custom.d.ts | 2 +- ...e59df11c248c2c4b43edab0797adede-audit.json | 15 ++++ ...2cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json | 15 ++++ 4 files changed, 75 insertions(+), 41 deletions(-) create mode 100644 logs/.7472c96afe59df11c248c2c4b43edab0797adede-audit.json create mode 100644 logs/.e0987c2f02cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 42833fca13..2268e48c15 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -361,50 +361,54 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => e.dataTransfer.dropEffect = "copy"; }, []); - const handleDrop = React.useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Get files from the drop - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) { - return; - } - - console.log("Drop files:", files); - - // Get the file path(s) using the Electron API - const paths = files.map((file: File) => { - try { - // Use the exposed Electron API to get the full path - const fullPath = getApi().getPathForFile(file); - console.log("File:", file.name, "-> Full path:", fullPath); - return fullPath; - } catch (err) { - console.error("Could not get path for file:", file.name, err); - return file.name; + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Get files from the drop + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) { + return; } - }); - - console.log("Paths to insert:", paths); - // Insert the path(s) into the terminal - // If multiple files, separate with spaces and quote if necessary - const pathString = paths.map(path => { - // Quote paths that contain spaces - if (path.includes(" ")) { - return `"${path}"`; + // Get the file path(s) using the Electron API + const paths = files + .map((file: File) => { + try { + // Use the exposed Electron API to get the full path + const fullPath = getApi().getPathForFile(file); + return fullPath; + } catch (err) { + console.error("Could not get path for file:", file.name, err); + return null; + } + }) + .filter((path): path is string => path !== null); + + if (paths.length === 0) { + return; } - return path; - }).join(" "); - - console.log("Final path string:", pathString); - // Send the path to the terminal - if (model.termRef.current && pathString) { - model.sendDataToController(pathString); - } - }, [model]); + // Insert the path(s) into the terminal + // If multiple files, separate with spaces and quote if necessary + const pathString = paths + .map((path) => { + // Quote paths that contain spaces + if (path.includes(" ")) { + return `"${path}"`; + } + return path; + }) + .join(" "); + + // Send the path to the terminal + if (model.termRef.current && pathString) { + model.sendDataToController(pathString); + } + }, + [model] + ); const handleDragEnter = React.useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 3dfdb7c3ee..e10b56eb67 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -134,7 +134,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh - getPathForFile: (file: File) => string; // get-path-for-file + getPathForFile: (file: File) => string; // webUtils.getPathForFile }; type ElectronContextMenuItem = { diff --git a/logs/.7472c96afe59df11c248c2c4b43edab0797adede-audit.json b/logs/.7472c96afe59df11c248c2c4b43edab0797adede-audit.json new file mode 100644 index 0000000000..5fe62cdd89 --- /dev/null +++ b/logs/.7472c96afe59df11c248c2c4b43edab0797adede-audit.json @@ -0,0 +1,15 @@ +{ + "keep": { + "days": true, + "amount": 14 + }, + "auditLog": "/Users/steven/dev/waveterm/logs/.7472c96afe59df11c248c2c4b43edab0797adede-audit.json", + "files": [ + { + "date": 1767427944263, + "name": "/Users/steven/dev/waveterm/logs/application-2026-01-03.log", + "hash": "4852234ebb0d7b86af5881840c7f016992f13bc84fa53701120e3082f7000cbd" + } + ], + "hashType": "sha256" +} \ No newline at end of file diff --git a/logs/.e0987c2f02cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json b/logs/.e0987c2f02cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json new file mode 100644 index 0000000000..f8e56ed535 --- /dev/null +++ b/logs/.e0987c2f02cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json @@ -0,0 +1,15 @@ +{ + "keep": { + "days": true, + "amount": 14 + }, + "auditLog": "/Users/steven/dev/waveterm/logs/.e0987c2f02cc1cb106cf88cd0bfd7b7d8ea27fc9-audit.json", + "files": [ + { + "date": 1767427944265, + "name": "/Users/steven/dev/waveterm/logs/error-2026-01-03.log", + "hash": "b8f49551269d85055d4b9d19fae3079c8c61830d7ae035302ba43c8dc38a96b1" + } + ], + "hashType": "sha256" +} \ No newline at end of file From 9eac3c9ff580a77920853475830a4421394d7a3e Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:58:09 -0800 Subject: [PATCH 6/6] Add file type checking and improve quoting for drag-drop - Only intercept file drops with isFileDrop() check - Better shell character quoting with /[\s'"]/ regex - Cleaner error handling and path filtering --- frontend/app/view/term/term.tsx | 38 +++++++++++++-------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 2268e48c15..16dd8c5d20 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -354,30 +354,34 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termBg = computeBgStyleFromMeta(blockData?.meta); // Handle drag and drop + // Helper to check if drag event contains files + const isFileDrop = (e: React.DragEvent): boolean => { + return e.dataTransfer?.types?.includes("Files") ?? false; + }; + const handleDragOver = React.useCallback((e: React.DragEvent) => { + if (!isFileDrop(e)) return; e.preventDefault(); e.stopPropagation(); - // Indicate that we can accept the drop e.dataTransfer.dropEffect = "copy"; }, []); const handleDrop = React.useCallback( (e: React.DragEvent) => { + if (!isFileDrop(e)) return; e.preventDefault(); e.stopPropagation(); - // Get files from the drop const files = Array.from(e.dataTransfer.files); - if (files.length === 0) { - return; - } + if (files.length === 0) return; - // Get the file path(s) using the Electron API const paths = files .map((file: File) => { try { - // Use the exposed Electron API to get the full path const fullPath = getApi().getPathForFile(file); + if (/[\s'"]/.test(fullPath)) { + return `"${fullPath}"`; + } return fullPath; } catch (err) { console.error("Could not get path for file:", file.name, err); @@ -386,23 +390,9 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => }) .filter((path): path is string => path !== null); - if (paths.length === 0) { - return; - } - - // Insert the path(s) into the terminal - // If multiple files, separate with spaces and quote if necessary - const pathString = paths - .map((path) => { - // Quote paths that contain spaces - if (path.includes(" ")) { - return `"${path}"`; - } - return path; - }) - .join(" "); + if (paths.length === 0) return; - // Send the path to the terminal + const pathString = paths.join(" "); if (model.termRef.current && pathString) { model.sendDataToController(pathString); } @@ -411,11 +401,13 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => ); const handleDragEnter = React.useCallback((e: React.DragEvent) => { + if (!isFileDrop(e)) return; e.preventDefault(); e.stopPropagation(); }, []); const handleDragLeave = React.useCallback((e: React.DragEvent) => { + if (!isFileDrop(e)) return; e.preventDefault(); e.stopPropagation(); }, []);