Accept dropped files in the terminal (#1173)
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type DragEvent as ReactDragEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
type Ref,
|
type Ref,
|
||||||
@@ -34,6 +35,12 @@ import {
|
|||||||
computeScrollOffsetFromDragDelta,
|
computeScrollOffsetFromDragDelta,
|
||||||
computeVerticalScrollbarGeometry,
|
computeVerticalScrollbarGeometry,
|
||||||
} from "./web-desktop-scrollbar.math";
|
} from "./web-desktop-scrollbar.math";
|
||||||
|
import {
|
||||||
|
extractTerminalDropPaths,
|
||||||
|
isTerminalDragLeaveOutside,
|
||||||
|
isTerminalFileDrag,
|
||||||
|
prepareDroppedPathsForTerminal,
|
||||||
|
} from "../terminal/drop/terminal-file-drop";
|
||||||
|
|
||||||
export interface TerminalEmulatorHandle {
|
export interface TerminalEmulatorHandle {
|
||||||
writeOutput: (data: TerminalOutputData) => void;
|
writeOutput: (data: TerminalOutputData) => void;
|
||||||
@@ -269,6 +276,8 @@ export default function TerminalEmulator({
|
|||||||
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
|
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
|
||||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||||
|
const [isDropActive, setIsDropActive] = useState(false);
|
||||||
|
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||||
useDOMImperativeHandle(
|
useDOMImperativeHandle(
|
||||||
@@ -759,6 +768,110 @@ export default function TerminalEmulator({
|
|||||||
setIsHandleHovered(false);
|
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<HTMLDivElement>) => {
|
||||||
|
if (!isTerminalFileDrag(event.dataTransfer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (
|
||||||
|
!isTerminalDragLeaveOutside({
|
||||||
|
currentTarget: event.currentTarget,
|
||||||
|
relatedTarget: event.relatedTarget,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTerminalDropActive();
|
||||||
|
},
|
||||||
|
[clearTerminalDropActive],
|
||||||
|
);
|
||||||
|
|
||||||
const rootDivStyle = useMemo<CSSProperties>(
|
const rootDivStyle = useMemo<CSSProperties>(
|
||||||
() => ({
|
() => ({
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -774,6 +887,19 @@ export default function TerminalEmulator({
|
|||||||
}),
|
}),
|
||||||
[xtermTheme.background],
|
[xtermTheme.background],
|
||||||
);
|
);
|
||||||
|
const dropOverlayStyle = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
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<CSSProperties>(
|
const handleContainerStyle = useMemo<CSSProperties>(
|
||||||
() => ({
|
() => ({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -822,8 +948,10 @@ export default function TerminalEmulator({
|
|||||||
style={rootDivStyle}
|
style={rootDivStyle}
|
||||||
onPointerDown={handleRootPointerDown}
|
onPointerDown={handleRootPointerDown}
|
||||||
onContextMenu={handleRootContextMenu}
|
onContextMenu={handleRootContextMenu}
|
||||||
|
onDragLeave={handleRootDragLeave}
|
||||||
>
|
>
|
||||||
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
||||||
|
<div style={dropOverlayStyle} />
|
||||||
{scrollbarGeometry.isVisible ? (
|
{scrollbarGeometry.isVisible ? (
|
||||||
<div style={SCROLLBAR_CONTAINER_STYLE}>
|
<div style={SCROLLBAR_CONTAINER_STYLE}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export interface DesktopOpenerBridge {
|
|||||||
openUrl?: (url: string) => Promise<void>;
|
openUrl?: (url: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopWebUtilsBridge {
|
||||||
|
getPathForFile?: (file: File) => string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopMenuBridge {
|
export interface DesktopMenuBridge {
|
||||||
showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise<void>;
|
showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -107,6 +111,7 @@ export interface DesktopHostBridge {
|
|||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
notification?: DesktopNotificationBridge;
|
notification?: DesktopNotificationBridge;
|
||||||
opener?: DesktopOpenerBridge;
|
opener?: DesktopOpenerBridge;
|
||||||
|
webUtils?: DesktopWebUtilsBridge;
|
||||||
menu?: DesktopMenuBridge;
|
menu?: DesktopMenuBridge;
|
||||||
browser?: DesktopBrowserBridge;
|
browser?: DesktopBrowserBridge;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||||
|
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -55,6 +55,9 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
opener: {
|
opener: {
|
||||||
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
|
openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url),
|
||||||
},
|
},
|
||||||
|
webUtils: {
|
||||||
|
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||||
|
},
|
||||||
menu: {
|
menu: {
|
||||||
showContextMenu: (input?: Record<string, unknown>) =>
|
showContextMenu: (input?: Record<string, unknown>) =>
|
||||||
ipcRenderer.invoke("paseo:menu:showContextMenu", input),
|
ipcRenderer.invoke("paseo:menu:showContextMenu", input),
|
||||||
|
|||||||
Reference in New Issue
Block a user