From 4395975664adc0a310f1022a2914d028603df36e Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 08:30:50 +0800 Subject: [PATCH] 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 <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/terminal/drop.test.ts | 112 +++++++++++++++++++++++++++++ src/features/terminal/drop.ts | 29 ++++++++ src/features/terminal/embedded.ts | 3 + src/features/terminal/session.ts | 6 +- 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/features/terminal/drop.test.ts create mode 100644 src/features/terminal/drop.ts diff --git a/src/features/terminal/drop.test.ts b/src/features/terminal/drop.test.ts new file mode 100644 index 000000000..ab6a226c8 --- /dev/null +++ b/src/features/terminal/drop.test.ts @@ -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(); + } + }); +}); diff --git a/src/features/terminal/drop.ts b/src/features/terminal/drop.ts new file mode 100644 index 000000000..d49746c10 --- /dev/null +++ b/src/features/terminal/drop.ts @@ -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; + }); +} diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 1fed7649f..d82fbf1d5 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -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(); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index ace17df1e..7a393dd14 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -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); }