Skip to content

Commit bb46dcd

Browse files
JustHereToHelpsebastiangeraldes
authored andcommitted
feat: drag and drop file paths into terminal (wavetermdev#2857)
Fixes wavetermdev#746, fixes wavetermdev#2813 Drag a file from Finder into a terminal and it pastes the quoted path. Uses `webUtils.getPathForFile()` through a preload bridge since Electron 32 killed `File.path`. Handles spaces in filenames. Needs app restart after install (preload change).
1 parent 25854f2 commit bb46dcd

4 files changed

Lines changed: 155 additions & 8 deletions

File tree

emain/preload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { contextBridge, ipcRenderer, Rectangle, WebviewTag } from "electron";
4+
import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron";
55

66
// update type in custom.d.ts (ElectronApi type)
77
contextBridge.exposeInMainWorld("api", {
@@ -66,6 +66,7 @@ contextBridge.exposeInMainWorld("api", {
6666
incrementTermCommands: () => ipcRenderer.send("increment-term-commands"),
6767
nativePaste: () => ipcRenderer.send("native-paste"),
6868
doRefresh: () => ipcRenderer.send("do-refresh"),
69+
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
6970
showOpenDialog: (options: {
7071
title?: string;
7172
defaultPath?: string;

frontend/app/view/term/termutil.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import * as TermTypes from "@xterm/xterm";
5+
46
export const DefaultTermTheme = "default-dark";
57
export const DefaultLightTermTheme = "light-default";
68
import { getResolvedTheme } from "@/app/hook/usetheme";
@@ -141,9 +143,8 @@ export async function extractClipboardData(item: ClipboardItem): Promise<GenClip
141143
if (!html) {
142144
return null;
143145
}
144-
const tempDiv = document.createElement("div");
145-
tempDiv.innerHTML = html;
146-
const text = tempDiv.textContent || "";
146+
const doc = new DOMParser().parseFromString(html, "text/html");
147+
const text = doc.body.textContent || "";
147148
return text ? { text } : null;
148149
}
149150

@@ -269,9 +270,8 @@ export async function extractDataTransferItems(items: DataTransferItemList): Pro
269270
resolve([]);
270271
return;
271272
}
272-
const tempDiv = document.createElement("div");
273-
tempDiv.innerHTML = html;
274-
const text = tempDiv.textContent || "";
273+
const doc = new DOMParser().parseFromString(html, "text/html");
274+
const text = doc.body.textContent || "";
275275
resolve(text ? [{ text }] : []);
276276
});
277277
});
@@ -327,3 +327,70 @@ export async function extractAllClipboardData(e?: ClipboardEvent): Promise<Array
327327
return results;
328328
}
329329
}
330+
331+
332+
/**
333+
* Converts terminal buffer lines to text, properly handling wrapped lines.
334+
* Wrapped lines (long lines split across multiple buffer rows) are concatenated
335+
* without adding newlines between them, while preserving actual line breaks.
336+
*
337+
* @param buffer - The xterm.js buffer to extract lines from
338+
* @param startIndex - Starting buffer index (inclusive, 0-based)
339+
* @param endIndex - Ending buffer index (exclusive, 0-based)
340+
* @returns Array of logical lines (with wrapped lines concatenated)
341+
*/
342+
export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, endIndex: number): string[] {
343+
const lines: string[] = [];
344+
let currentLine = "";
345+
let isFirstLine = true;
346+
347+
// Clamp indices to valid buffer range to avoid out-of-bounds access on the
348+
// underlying circular buffer, which could return stale/wrong data.
349+
const clampedStart = Math.max(0, Math.min(startIndex, buffer.length));
350+
const clampedEnd = Math.max(0, Math.min(endIndex, buffer.length));
351+
352+
for (let i = clampedStart; i < clampedEnd; i++) {
353+
const line = buffer.getLine(i);
354+
if (line) {
355+
const lineText = line.translateToString(true);
356+
// If this line is wrapped (continuation of previous line), concatenate without newline
357+
if (line.isWrapped && !isFirstLine) {
358+
currentLine += lineText;
359+
} else {
360+
// This is a new logical line
361+
if (!isFirstLine) {
362+
lines.push(currentLine);
363+
}
364+
currentLine = lineText;
365+
isFirstLine = false;
366+
}
367+
}
368+
}
369+
370+
// Don't forget the last line
371+
if (!isFirstLine) {
372+
lines.push(currentLine);
373+
}
374+
375+
// Trim trailing blank lines only when the requested range extends to the
376+
// actual end of the buffer. A terminal allocates a fixed number of rows
377+
// (e.g. 80) but only the first few may contain real content; the rest are
378+
// empty placeholder rows. We strip those so callers don't receive a wall
379+
// of empty strings.
380+
//
381+
// Crucially, if the caller requested a specific sub-range (e.g. lines
382+
// 100-150) and lines 140-150 happen to be blank, those blanks are
383+
// intentional and must NOT be removed. We only trim when the range
384+
// reaches the very end of the buffer.
385+
if (clampedEnd >= buffer.length) {
386+
while (lines.length > 0 && lines[lines.length - 1] === "") {
387+
lines.pop();
388+
}
389+
}
390+
391+
return lines;
392+
}
393+
394+
export function quoteForPosixShell(filePath: string): string {
395+
return "'" + filePath.replace(/'/g, "'\\''") + "'";
396+
}

frontend/app/view/term/termwrap.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RpcApi } from "@/app/store/wshclientapi";
44
import { TabRpcClient } from "@/app/store/wshrpcutil";
55
import {
66
atoms,
7+
getApi,
78
getOverrideConfigAtom,
89
getSettingsKeyAtom,
910
globalStore,
@@ -28,7 +29,7 @@ import { FitAddon } from "./fitaddon";
2829
import { ROLLING_INTERVAL_MS } from "./sessionhistory-capture";
2930
import { ObjectService } from "@/app/store/services";
3031
import { windowsToWslPath } from "@/util/pathutil";
31-
import { createTempFileFromBlob, extractAllClipboardData } from "./termutil";
32+
import { createTempFileFromBlob, extractAllClipboardData, quoteForPosixShell } from "./termutil";
3233
import {
3334
loadInitialTerminalData,
3435
runProcessIdleTimeout,
@@ -269,6 +270,38 @@ export class TermWrap {
269270
const ligaturesAddon = new LigaturesAddon();
270271
this.terminal.loadAddon(ligaturesAddon);
271272
}
273+
274+
const dragoverHandler = (e: DragEvent) => {
275+
e.preventDefault();
276+
if (e.dataTransfer) {
277+
e.dataTransfer.dropEffect = "copy";
278+
}
279+
};
280+
const dropHandler = (e: DragEvent) => {
281+
e.preventDefault();
282+
if (!e.dataTransfer || e.dataTransfer.files.length === 0) {
283+
return;
284+
}
285+
const paths: string[] = [];
286+
for (let i = 0; i < e.dataTransfer.files.length; i++) {
287+
const file = e.dataTransfer.files[i];
288+
const filePath = getApi().getPathForFile(file);
289+
if (filePath) {
290+
paths.push(quoteForPosixShell(filePath));
291+
}
292+
}
293+
if (paths.length > 0) {
294+
this.terminal.paste(paths.join(" ") + " ");
295+
}
296+
};
297+
this.connectElem.addEventListener("dragover", dragoverHandler);
298+
this.connectElem.addEventListener("drop", dropHandler);
299+
this.toDispose.push({
300+
dispose: () => {
301+
this.connectElem.removeEventListener("dragover", dragoverHandler);
302+
this.connectElem.removeEventListener("drop", dropHandler);
303+
},
304+
});
272305
this.handleResize();
273306
const pasteHandler = this.pasteHandler.bind(this);
274307
this.connectElem.addEventListener("paste", pasteHandler, true);

frontend/types/custom.d.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ declare global {
131131
eventType: "did-navigate" | "did-navigate-in-page" | "will-navigate",
132132
isMainFrame?: boolean
133133
) => void;
134+
getZoomFactor: () => number; // get-zoom-factor
135+
showWorkspaceAppMenu: (workspaceId: string) => void; // workspace-appmenu-show
136+
showBuilderAppMenu: (builderId: string) => void; // builder-appmenu-show
137+
showContextMenu: (workspaceId: string, menu: ElectronContextMenuItem[]) => void; // contextmenu-show
138+
onContextMenuClick: (callback: (id: string | null) => void) => void; // contextmenu-click
139+
onNavigate: (callback: (url: string) => void) => void;
140+
onIframeNavigate: (callback: (url: string) => void) => void;
141+
downloadFile: (path: string) => void; // download
142+
openExternal: (url: string) => void; // open-external
143+
onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; // fullscreen-change
144+
onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change
145+
onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; // app-update-status
146+
getUpdaterStatus: () => UpdaterStatus; // get-app-update-status
147+
getUpdaterChannel: () => string; // get-updater-channel
148+
installAppUpdate: () => void; // install-app-update
149+
onMenuItemAbout: (callback: () => void) => void; // menu-item-about
150+
updateWindowControlsOverlay: (rect: Dimensions) => void; // update-window-controls-overlay
151+
onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; // reinject-key
152+
setWebviewFocus: (focusedId: number) => void; // webview-focus, focusedId is the getWebContentsId of the webview
153+
registerGlobalWebviewKeys: (keys: string[]) => void; // register-global-webview-keys
154+
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; // control-shift-state-update
155+
createWorkspace: () => void; // create-workspace
156+
switchWorkspace: (workspaceId: string) => void; // switch-workspace
157+
deleteWorkspace: (workspaceId: string) => void; // delete-workspace
158+
setActiveTab: (tabId: string) => void; // set-active-tab
159+
createTab: () => void; // create-tab
160+
closeTab: (workspaceId: string, tabId: string, confirmClose: boolean) => Promise<boolean>; // close-tab
161+
setWindowInitStatus: (status: "ready" | "wave-ready") => void; // set-window-init-status
162+
onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; // wave-init
163+
onBuilderInit: (callback: (initOpts: BuilderInitOpts) => void) => void; // builder-init
164+
sendLog: (log: string) => void; // fe-log
165+
onQuicklook: (filePath: string) => void; // quicklook
166+
openNativePath(filePath: string): void; // open-native-path
167+
captureScreenshot(rect: Electron.Rectangle): Promise<string>; // capture-screenshot
168+
setKeyboardChordMode: () => void; // set-keyboard-chord-mode
169+
clearWebviewStorage: (webContentsId: number) => Promise<void>; // clear-webview-storage
170+
setWaveAIOpen: (isOpen: boolean) => void; // set-waveai-open
171+
closeBuilderWindow: () => void; // close-builder-window
172+
incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => void; // increment-term-commands
173+
nativePaste: () => void; // native-paste
174+
openBuilder: (appId?: string) => void; // open-builder
175+
setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid
176+
doRefresh: () => void; // do-refresh
177+
getPathForFile: (file: File) => string; // webUtils.getPathForFile
178+
saveTextFile: (fileName: string, content: string) => Promise<boolean>; // save-text-file
179+
setIsActive: () => Promise<void>; // set-is-active
134180
};
135181

136182
type ElectronContextMenuItem = {

0 commit comments

Comments
 (0)