fix(terminal): paste dropped file paths at the prompt instead of opening them (#605)

Dropping a file on a terminal fell through to the window-wide drop, which opened it in the editor or asked to open a folder as a project. It now pastes the shell-quoted paths at the prompt, separated by spaces and with no newline, so nothing runs until the user presses Enter.

Each terminal, including the Run console, accepts only drops inside its own host. Windows paths use double quotes. Remote windows keep the existing drop, since a local path means nothing on the remote host. Verified on macOS with a simulated drop; a native Finder drag and Windows quoting were not run.

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-01 08:30:50 +08:00
1 джерело 0bf597c2fd
коміт 4395975664
4 files changed
+149 -1

No files matched your search

+112
Переглянути файл
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { commands } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { isRemoteWindow } from "@/platform/remote";
import { handleFileDrop } from "@/workbench/fileDrop";
import { droppedPathsText, installTerminalDrop } from "./drop";
vi.mock("@/platform/remote", () => ({ isRemoteWindow: vi.fn(() => false) }));
const initialOs = usePlatform.getState().os;
const originalElementFromPoint = document.elementFromPoint;
beforeEach(() => {
vi.stubGlobal("__TAURI_INTERNALS__", {});
usePlatform.setState({ os: "macos" });
});
afterEach(() => {
document.body.replaceChildren();
document.elementFromPoint = originalElementFromPoint;
usePlatform.setState({ os: initialOs });
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.mocked(isRemoteWindow).mockReturnValue(false);
});
describe("terminal drop", () => {
it.each(["macos", "linux"])("quotes POSIX paths and joins files with spaces on %s", (os) => {
usePlatform.setState({ os });
expect(droppedPathsText(["/plain/file.ts"])).toBe("/plain/file.ts");
expect(droppedPathsText(["/a b/c.png", "/plain", "C:/dir/e f"])).toBe("'/a b/c.png' /plain 'C:/dir/e f'");
expect(droppedPathsText(["/it's here"])).toBe("'/it'\\''s here'");
});
it("uses double quotes for Windows paths with spaces or shell metacharacters", () => {
usePlatform.setState({ os: "windows" });
expect(droppedPathsText(["C:\\plain\\file.ts", "C:\\a b\\c.png", "C:\\it's here\\"])).toBe(
'C:\\plain\\file.ts "C:\\a b\\c.png" "C:\\it\'s here\\"',
);
expect(droppedPathsText(["C:\\a&b\\c.png"])).toBe('"C:\\a&b\\c.png"');
});
it("pastes only into the host under the cursor without executing, regardless of registration order", async () => {
const list = vi.spyOn(commands, "clipboardFileList");
const hosts = [document.createElement("div"), document.createElement("div")];
const targets = hosts.map((host, i) => {
host.dataset.terminalSession = `s${i}`;
const child = document.createElement("span");
host.append(child);
return child;
});
const elsewhere = document.createElement("div");
document.body.replaceChildren(...hosts, elsewhere);
document.elementFromPoint = ((x: number) => targets[x] ?? elsewhere) as typeof document.elementFromPoint;
const pasted = [vi.fn(), vi.fn()] as const;
const remove = hosts.map((host, i) => installTerminalDrop(host, pasted[i]!));
try {
await handleFileDrop(["/a b", "/plain"], 1, 1);
expect(pasted[0]).not.toHaveBeenCalled();
expect(pasted[1].mock.calls).toEqual([["'/a b' /plain"]]);
await handleFileDrop(["/first"], 0, 1);
expect(pasted[0].mock.calls).toEqual([["/first"]]);
await handleFileDrop([], 1, 1);
await handleFileDrop(["/img/pic.png"], 9, 9);
expect(pasted[1]).toHaveBeenCalledOnce();
expect(list).not.toHaveBeenCalled();
remove.forEach((dispose) => dispose());
await handleFileDrop(["/late"], 1, 1);
expect(pasted[1]).toHaveBeenCalledOnce();
} finally {
remove.forEach((dispose) => dispose());
}
});
it("ignores detached and hidden sessions when dropping on an embedded Run console", async () => {
const detached = document.createElement("div");
detached.dataset.terminalSession = "detached";
const hidden = document.createElement("div");
hidden.dataset.terminalSession = "hidden";
hidden.hidden = true;
const runHost = document.createElement("div");
const child = document.createElement("span");
runHost.append(child);
document.body.replaceChildren(hidden, runHost);
document.elementFromPoint = () => child;
const pasted = [vi.fn(), vi.fn(), vi.fn()] as const;
const remove = [detached, hidden, runHost].map((host, i) => installTerminalDrop(host, pasted[i]!));
try {
await handleFileDrop(["/run input"], 1, 1);
expect(pasted[0]).not.toHaveBeenCalled();
expect(pasted[1]).not.toHaveBeenCalled();
expect(pasted[2].mock.calls).toEqual([["'/run input'"]]);
} finally {
remove.forEach((dispose) => dispose());
}
});
it("leaves local path drops unhandled in a remote window", () => {
const host = document.createElement("div");
document.body.append(host);
document.elementFromPoint = () => host;
const paste = vi.fn();
const remove = installTerminalDrop(host, paste);
try {
vi.mocked(isRemoteWindow).mockReturnValue(true);
expect(handleFileDrop(["/local/file"], 1, 1)).toBeNull();
expect(paste).not.toHaveBeenCalled();
} finally {
remove();
}
});
});
+29
Переглянути файл
@@ -0,0 +1,29 @@
/**
* Files dropped from the OS onto a terminal: the path is pasted at the prompt
* (shell-quoted when needed), separated by spaces, instead of the window-wide
* drop (open in the editor, "Open project in" for a folder) or the browser /
* shell falling through to a system dialog. Images are treated the same way:
* showing the path is the expected behaviour.
*/
import { formatArgs } from "@/platform/commandLine";
import { usePlatform } from "@/platform/platformInfo";
import { isRemoteWindow } from "@/platform/remote";
import { registerFileDropHandler } from "@/workbench/fileDrop";
/** Shell-quoted paths for the prompt; the user presses Enter to execute. */
export function droppedPathsText(paths: readonly string[]): string {
if (usePlatform.getState().os === "windows")
return paths.map((p) => (/[\s&|<>()^]/.test(p) ? `"${p}"` : p)).join(" ");
return formatArgs(paths);
}
/** Registers the terminal's drop handler; the disposer unregisters it. */
export function installTerminalDrop(host: HTMLElement, paste: (text: string) => void): () => void {
return registerFileDropHandler((paths, x, y) => {
if (!paths.length || isRemoteWindow()) return false;
const el = document.elementFromPoint(x, y);
if (!el || !host.contains(el)) return false;
paste(droppedPathsText(paths));
return true;
});
}
+3
Переглянути файл
@@ -4,6 +4,7 @@
* and size go to the owner instead of a terminal PTY (a Run console emulating
* a terminal feeds its process).
*/
import { installTerminalDrop } from "./drop";
import { installTerminalPaste } from "./paste";
import { Terminal, type IDisposable } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
@@ -85,6 +86,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
}),
];
const removePaste = installTerminalPaste(host, (text) => term.paste(text));
const removeDrop = installTerminalDrop(host, (text) => term.paste(text));
term.open(host);
if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]);
@@ -112,6 +114,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
processResize.cancel();
observer.disconnect();
removePaste();
removeDrop();
for (const d of disposables) d.dispose();
live.delete(term);
term.dispose();
+5 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { fitScheduler, settledResize } from "./fit";
import { installTerminalDrop } from "./drop";
import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
@@ -276,7 +277,10 @@ export class TerminalSession {
if (atBottom !== this.snapshot.follow) this.update({ follow: atBottom });
}),
);
this.disposables.push({ dispose: installTerminalPaste(this.host, (text) => this.paste(text)) });
this.disposables.push(
{ dispose: installTerminalPaste(this.host, (text) => this.paste(text)) },
{ dispose: installTerminalDrop(this.host, (text) => this.paste(text)) },
);
this.host.addEventListener("mousedown", this.onMiddleClick);
}