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