diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index db8802ffb..957898828 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -8,6 +8,7 @@ import { useRef, useState, type CSSProperties, + type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type Ref, @@ -34,6 +35,12 @@ import { computeScrollOffsetFromDragDelta, computeVerticalScrollbarGeometry, } from "./web-desktop-scrollbar.math"; +import { + extractTerminalDropPaths, + isTerminalDragLeaveOutside, + isTerminalFileDrag, + prepareDroppedPathsForTerminal, +} from "../terminal/drop/terminal-file-drop"; export interface TerminalEmulatorHandle { writeOutput: (data: TerminalOutputData) => void; @@ -269,6 +276,8 @@ export default function TerminalEmulator({ const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false); const [isScrollVisible, setIsScrollVisible] = useState(false); const [isScrollActive, setIsScrollActive] = useState(false); + const [isDropActive, setIsDropActive] = useState(false); + const dropActiveTimeoutRef = useRef | null>(null); const domBridgeRef = useRef(null); useDOMImperativeHandle( @@ -759,6 +768,110 @@ export default function TerminalEmulator({ setIsHandleHovered(false); }, []); + const clearDropActiveTimeout = useCallback(() => { + if (dropActiveTimeoutRef.current === null) { + return; + } + clearTimeout(dropActiveTimeoutRef.current); + dropActiveTimeoutRef.current = null; + }, []); + + const clearTerminalDropActive = useCallback(() => { + clearDropActiveTimeout(); + setIsDropActive(false); + }, [clearDropActiveTimeout]); + + const keepTerminalDropActive = useCallback(() => { + clearDropActiveTimeout(); + setIsDropActive(true); + dropActiveTimeoutRef.current = setTimeout(() => { + dropActiveTimeoutRef.current = null; + setIsDropActive(false); + }, 180); + }, [clearDropActiveTimeout]); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + return () => {}; + } + + const handleDragEnter = (event: DragEvent) => { + if (!isTerminalFileDrag(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + keepTerminalDropActive(); + }; + + const handleDragOver = (event: DragEvent) => { + if (!isTerminalFileDrag(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + keepTerminalDropActive(); + }; + + const handleDrop = (event: DragEvent) => { + if (!isTerminalFileDrag(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearTerminalDropActive(); + + const paths = extractTerminalDropPaths(event.dataTransfer); + if (paths.length === 0) { + return; + } + + runtimeRef.current?.focus(); + mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths)); + }; + + root.addEventListener("dragenter", handleDragEnter, { capture: true }); + root.addEventListener("dragover", handleDragOver, { capture: true }); + root.addEventListener("drop", handleDrop, { capture: true }); + window.addEventListener("dragend", clearTerminalDropActive); + window.addEventListener("drop", clearTerminalDropActive); + + return () => { + root.removeEventListener("dragenter", handleDragEnter, { capture: true }); + root.removeEventListener("dragover", handleDragOver, { capture: true }); + root.removeEventListener("drop", handleDrop, { capture: true }); + window.removeEventListener("dragend", clearTerminalDropActive); + window.removeEventListener("drop", clearTerminalDropActive); + clearDropActiveTimeout(); + }; + }, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]); + + const handleRootDragLeave = useCallback( + (event: ReactDragEvent) => { + if (!isTerminalFileDrag(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if ( + !isTerminalDragLeaveOutside({ + currentTarget: event.currentTarget, + relatedTarget: event.relatedTarget, + }) + ) { + return; + } + clearTerminalDropActive(); + }, + [clearTerminalDropActive], + ); + const rootDivStyle = useMemo( () => ({ position: "relative", @@ -774,6 +887,19 @@ export default function TerminalEmulator({ }), [xtermTheme.background], ); + const dropOverlayStyle = useMemo( + () => ({ + position: "absolute", + inset: 0, + zIndex: 9, + border: "1px solid rgba(78, 161, 255, 0.72)", + backgroundColor: "rgba(78, 161, 255, 0.16)", + opacity: isDropActive ? 1 : 0, + pointerEvents: "none", + transition: "opacity 120ms ease-out", + }), + [isDropActive], + ); const handleContainerStyle = useMemo( () => ({ position: "absolute", @@ -822,8 +948,10 @@ export default function TerminalEmulator({ style={rootDivStyle} onPointerDown={handleRootPointerDown} onContextMenu={handleRootContextMenu} + onDragLeave={handleRootDragLeave} >
+
{scrollbarGeometry.isVisible ? (
Promise; } +export interface DesktopWebUtilsBridge { + getPathForFile?: (file: File) => string; +} + export interface DesktopMenuBridge { showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise; } @@ -107,6 +111,7 @@ export interface DesktopHostBridge { dialog?: DesktopDialogBridge; notification?: DesktopNotificationBridge; opener?: DesktopOpenerBridge; + webUtils?: DesktopWebUtilsBridge; menu?: DesktopMenuBridge; browser?: DesktopBrowserBridge; } diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts new file mode 100644 index 000000000..d5a51327b --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + extractTerminalDropPaths, + isTerminalDragLeaveOutside, + isTerminalFileDrag, + prepareDroppedPathForTerminal, + prepareDroppedPathsForTerminal, +} from "./terminal-file-drop"; + +function setDesktopBridge(bridge: unknown): void { + Object.defineProperty(window, "paseoDesktop", { + configurable: true, + value: bridge, + }); +} + +function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer { + return { + types: input.types ?? [], + files: input.files ?? [], + } as unknown as DataTransfer; +} + +afterEach(() => { + vi.restoreAllMocks(); + delete (window as unknown as { paseoDesktop?: unknown }).paseoDesktop; +}); + +describe("terminal file drop", () => { + it("detects file drags", () => { + expect(isTerminalFileDrag(dataTransfer({ types: ["Files"] }))).toBe(true); + expect(isTerminalFileDrag(dataTransfer({ types: ["text/plain"] }))).toBe(false); + expect(isTerminalFileDrag(null)).toBe(false); + }); + + it("keeps drag highlight active when moving between terminal children", () => { + const root = document.createElement("div"); + const child = document.createElement("div"); + const outside = document.createElement("div"); + root.appendChild(child); + + expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false); + expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true); + expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true); + }); + + it("extracts paths through Electron webUtils", () => { + const file = new File(["image"], "photo.png", { type: "image/png" }); + const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png"); + setDesktopBridge({ + webUtils: { getPathForFile }, + }); + + expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ + "/Users/me/Desktop/photo.png", + ]); + expect(getPathForFile).toHaveBeenCalledWith(file); + }); + + it("falls back to legacy Electron file paths", () => { + const file = new File(["image"], "photo.png", { type: "image/png" }); + Object.defineProperty(file, "path", { + configurable: true, + value: "/tmp/legacy-photo.png", + }); + setDesktopBridge({ + webUtils: { + getPathForFile: vi.fn(() => { + throw new Error("not available"); + }), + }, + }); + + expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([ + "/tmp/legacy-photo.png", + ]); + }); + + it("drops browser files that have no filesystem path", () => { + const file = new File(["image"], "photo.png", { type: "image/png" }); + + expect(extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }))).toEqual([]); + }); + + it("prepares POSIX paths with conservative escaping", () => { + setDesktopBridge({ platform: "darwin" }); + + expect(prepareDroppedPathForTerminal("/tmp/my image.png")).toBe("'/tmp/my image.png'"); + expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'"); + expect(prepareDroppedPathForTerminal("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'"); + }); + + it("prepares Windows paths with space quoting", () => { + setDesktopBridge({ platform: "win32" }); + + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png")).toBe( + "C:\\Users\\me\\photo.png", + ); + expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png")).toBe( + '"C:\\Users\\me\\photo one.png"', + ); + }); + + it("joins multiple dropped paths for one terminal input", () => { + setDesktopBridge({ platform: "darwin" }); + + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + "'/tmp/a.png' '/tmp/b c.png'", + ); + }); +}); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts new file mode 100644 index 000000000..df0a97700 --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -0,0 +1,102 @@ +interface DesktopFilePathBridge { + platform?: string; + webUtils?: { + getPathForFile?: (file: File) => string; + }; +} + +const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; + +function getDesktopBridge(): DesktopFilePathBridge | null { + if (typeof window === "undefined") { + return null; + } + const bridge = (window as unknown as { paseoDesktop?: DesktopFilePathBridge }).paseoDesktop; + return bridge && typeof bridge === "object" ? bridge : null; +} + +function getLegacyFilePath(file: File): string | null { + const path = Reflect.get(file, "path"); + return typeof path === "string" && path.length > 0 ? path : null; +} + +function getFilePath(file: File): string | null { + const bridge = getDesktopBridge(); + const getPathForFile = bridge?.webUtils?.getPathForFile; + if (typeof getPathForFile === "function") { + try { + const path = getPathForFile(file); + if (typeof path === "string" && path.length > 0) { + return path; + } + } catch { + // Fall through to the legacy Electron File.path property if present. + } + } + return getLegacyFilePath(file); +} + +export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean { + return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files")); +} + +export function isTerminalDragLeaveOutside(input: { + currentTarget: EventTarget | null; + relatedTarget: EventTarget | null; +}): boolean { + if (!(input.currentTarget instanceof Node) || !(input.relatedTarget instanceof Node)) { + return true; + } + return !input.currentTarget.contains(input.relatedTarget); +} + +export function extractTerminalDropPaths(dataTransfer: DataTransfer | null): string[] { + if (!dataTransfer) { + return []; + } + + const paths: string[] = []; + for (const file of Array.from(dataTransfer.files)) { + const path = getFilePath(file); + if (path) { + paths.push(path); + } + } + return paths; +} + +function escapeNonWindowsPath(path: string): string { + let nextPath = path; + if (nextPath.includes("\\")) { + nextPath = nextPath.replace(/\\/g, "\\\\"); + } + + nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, ""); + + if (nextPath.includes("'") && nextPath.includes('"')) { + return `$'${nextPath.replace(/'/g, "\\'")}'`; + } + if (nextPath.includes("'")) { + return `'${nextPath.replace(/'/g, "\\'")}'`; + } + return `'${nextPath}'`; +} + +function escapeWindowsPath(path: string): string { + if (!path.includes(" ")) { + return path; + } + return `"${path}"`; +} + +export function prepareDroppedPathForTerminal(path: string): string { + const platform = getDesktopBridge()?.platform; + if (platform === "win32") { + return escapeWindowsPath(path); + } + return escapeNonWindowsPath(path); +} + +export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { + return paths.map(prepareDroppedPathForTerminal).join(" "); +} diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 66922e0bc..6999fa9c2 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; type EventHandler = (payload: unknown) => void; @@ -55,6 +55,9 @@ contextBridge.exposeInMainWorld("paseoDesktop", { opener: { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, + webUtils: { + getPathForFile: (file: File) => webUtils.getPathForFile(file), + }, menu: { showContextMenu: (input?: Record) => ipcRenderer.invoke("paseo:menu:showContextMenu", input),