Merge branch 'feat/drag-out' into integration/round-1

This commit is contained in:
2026-08-23 09:51:44 -07:00
19 changed files with 1205 additions and 26 deletions
@@ -9,6 +9,7 @@ const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt");
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -18,6 +19,13 @@ vi.mock("../../../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/** The OS-level drag. Nothing in jsdom can start one, so it is only observed. */
const startDrag = vi.fn(async () => {});
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
startDrag: (opts: unknown) => startDrag(opts),
}));
const save = vi.fn(async () => "/host/out");
@@ -78,9 +86,32 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) {
});
}
/**
* A pointer event carrying real coordinates.
*
* jsdom implements no `PointerEvent` and Testing Library's synthesized one has
* no coordinates — which is the whole gesture here, since the drag only starts
* once the pointer has travelled past the threshold. `MouseEvent` has them, and
* React dispatches on the type name either way.
*/
function pointer(el: Element, type: string, clientX: number, clientY: number) {
fireEvent(
el,
new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }),
);
}
/** Press on a row and move far enough to become a drag, leaving the button down. */
function dragRow(el: Element) {
pointer(el, "pointerdown", 10, 10);
pointer(el, "pointermove", 60, 10);
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt");
startDrag.mockResolvedValue(undefined);
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
@@ -93,6 +124,10 @@ beforeEach(() => {
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
// Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom
// would return anyway, minus the "not implemented" noise on every drag —
// `dragPreview.test.ts` covers what the fallback then produces.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
describe("FilesTab listing", () => {
@@ -348,3 +383,120 @@ describe("FilesTab save to host", () => {
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
});
});
describe("FilesTab drag-out", () => {
it("stages the file on the host and starts the native drag on that copy", async () => {
// The container path is not draggable — only the host copy is — so the
// thing handed to the OS must be what staging returned.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
expect(startDrag).toHaveBeenCalledWith(
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
);
});
it("never drags a directory, which cannot be staged as one file", async () => {
await renderTab();
dragRow(screen.getByText("src").closest("tr")!);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
expect(startDrag).not.toHaveBeenCalled();
});
it("stays a click until the pointer has actually travelled", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
pointer(row, "pointerdown", 10, 10);
pointer(row, "pointermove", 12, 11);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
});
it("says what went wrong instead of leaving a gesture that did nothing", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("too large"));
expect(startDrag).not.toHaveBeenCalled();
});
it("points at the fallback when the platform refuses the drag itself", async () => {
startDrag.mockRejectedValue("drag image not found");
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Save to host"));
});
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
// Staging is a whole-file copy, and the OS only adopts a drag while the
// button is down. Releasing mid-copy used to be — and must not be — a
// gesture that did nothing and explained nothing.
let release: (path: string) => void = () => {};
stageContainerFileForDrag.mockReturnValue(
new Promise<string>((resolve) => {
release = resolve;
}),
);
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
pointer(row, "pointerup", 60, 10);
await act(async () => {
release("/tmp/triple-c-drag-out/s1/notes.txt");
await Promise.resolve();
});
await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt"));
expect(startDrag).not.toHaveBeenCalled();
});
it("drags immediately on the retry, reusing the copy it already made", async () => {
// The instruction "drag it again" is only honest if the second attempt does
// not repeat the copy that made the first one too slow.
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1));
pointer(row, "pointerup", 60, 10);
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2));
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
});
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByLabelText("Save notes.txt to host"));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
expect(startDrag).not.toHaveBeenCalled();
});
it("still accepts a drop into the pane — the two directions coexist", async () => {
// The drag-out gesture is pointer-driven precisely so it does not need the
// HTML5 machinery that Tauri's native drop listener rules out.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
await drop(["/host/a.txt"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
});
});
@@ -1,15 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { startDrag } from "@crabnebula/tauri-plugin-drag";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import { dragPreviewIcon } from "./dragPreview";
import { formatBytes } from "./format";
interface Props {
project: Project;
}
/**
* How far the pointer must travel before a press becomes a drag. Same few
* pixels of slop as the tab strip, so a click that trembles stays a click.
*/
const DRAG_THRESHOLD = 4;
/**
* The project's file manager.
*
@@ -32,8 +40,10 @@ export default function FilesTab({ project }: Props) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
renameEntry,
createFolder,
setError,
} = useFileManager(project.id);
const running = project.status === "running";
@@ -47,6 +57,8 @@ export default function FilesTab({ project }: Props) {
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
const [dragNotice, setDragNotice] = useState<string | null>(null);
const paneRef = useRef<HTMLDivElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
@@ -61,6 +73,7 @@ export default function FilesTab({ project }: Props) {
useEffect(() => {
setSelected(null);
setRenaming(null);
setDragNotice(null);
}, [currentPath]);
useEffect(() => {
@@ -119,6 +132,112 @@ export default function FilesTab({ project }: Props) {
[navigate],
);
// Container → host drag-out.
//
// The mirror image of the drop path below, and it has the same constraint
// pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so
// `draggable` + `DataTransfer` is not available and the gesture is driven
// from pointer events into the native drag plugin — exactly the shape the tab
// strip uses, and for the same reason.
//
// What makes it more than a pointer gesture is that the file being dragged
// does not exist on the host at all: it lives in the container, and the OS
// can only drag a real host path. So every drag-out is a copy first (see
// `stageForDrag`) and a drag second, which is why the gesture has an async
// gap in the middle of something that feels instantaneous.
const dragOut = useRef<{
path: string;
x: number;
y: number;
down: boolean;
started: boolean;
} | null>(null);
// Pointer-up almost never lands on the row it started on — the pointer has
// moved off it by definition, and once the OS takes the drag the webview stops
// seeing the pointer at all, which is what makes a lost focus the only
// "the button came up" signal left.
useEffect(() => {
const release = () => {
if (dragOut.current) dragOut.current.down = false;
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
return () => {
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
};
}, []);
const beginDragOut = useCallback(
async (entry: FileEntry) => {
setDragNotice(null);
const staged = await stageForDrag(entry);
// `stageForDrag` has already put the reason in `error`.
if (!staged) return;
// The OS only adopts a drag while the button is still down, and the copy
// that just ran can easily outlast a flick of the wrist. Say so rather
// than leaving a gesture that did nothing and explained nothing — and it
// is a real instruction, not an apology: the copy is kept, so the second
// attempt starts immediately.
if (dragOut.current?.path !== entry.path || !dragOut.current.down) {
setDragNotice(entry.name);
return;
}
try {
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) });
} catch (e) {
// Drag-out is the enhancement; "Save to host…" is the path that always
// works, so a platform that refuses the drag says where to go instead.
setError(`Could not start the drag: ${e}. Use "Save to host…" instead.`);
}
},
[stageForDrag, setError],
);
/**
* Pointer wiring for one row. Directories get none of it: staging copies a
* single regular file, and a folder would only ever produce an error.
*/
const dragOutProps = (entry: FileEntry) => {
if (entry.is_directory) return {};
return {
onPointerDown: (e: React.PointerEvent<HTMLTableRowElement>) => {
if (e.button !== 0 || renaming === entry.name) return;
// The row's own controls, and the rename input, where a drag is a text
// selection.
if ((e.target as HTMLElement).closest("button, input")) return;
dragOut.current = {
path: entry.path,
x: e.clientX,
y: e.clientY,
down: true,
started: false,
};
// Deliberately no `setPointerCapture` — unlike the tab strip, which
// draws its own ghost. Here the OS has to take the pointer over, and a
// capture held in the webview is exactly what stops it.
},
onPointerMove: (e: React.PointerEvent<HTMLTableRowElement>) => {
const gesture = dragOut.current;
if (!gesture || gesture.started || !gesture.down) return;
if (gesture.path !== entry.path) return;
if (
Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD &&
Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD
) {
return;
}
gesture.started = true;
void beginDragOut(entry);
},
};
};
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
@@ -226,6 +345,11 @@ export default function FilesTab({ project }: Props) {
{busy}
</span>
)}
{!busy && dragNotice && (
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
"{dragNotice}" is ready drag it again to drop it on the desktop.
</span>
)}
<Button
onClick={() => {
setFolderDraft("");
@@ -310,6 +434,7 @@ export default function FilesTab({ project }: Props) {
aria-selected={isSelected}
onClick={() => setSelected(entry.name)}
onDoubleClick={() => openEntry(entry)}
{...dragOutProps(entry)}
onKeyDown={(e) => {
if (isRenaming) return;
if (e.key === "Enter") {
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { dragPreviewIcon } from "./dragPreview";
afterEach(() => {
vi.restoreAllMocks();
});
describe("dragPreviewIcon", () => {
it("falls back to a PNG data URL when there is no 2D context", () => {
// jsdom has no canvas, and a webview can refuse one. `startDrag` requires
// an image and the Rust side accepts nothing but a PNG data URL, so a
// fallback that is not one takes the whole drag down with it.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("falls back rather than throwing when the canvas throws", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("no canvas here");
});
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,/);
});
it("refuses a canvas that encoded nothing", () => {
// jsdom's `toDataURL` answers `data:,` — which the Rust side rejects
// outright, so returning it would be worse than not drawing at all.
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:,");
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("uses what the canvas drew when there is one", () => {
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
const drawn = "data:image/png;base64,AAAA";
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(drawn);
expect(dragPreviewIcon("notes.txt")).toBe(drawn);
// A long name is elided rather than drawn off the edge of the preview.
expect(dragPreviewIcon("a-really-quite-long-file-name-indeed.txt")).toBe(drawn);
expect(ctx.fillText).toHaveBeenLastCalledWith(
expect.stringContaining("…"),
expect.any(Number),
expect.any(Number),
);
});
});
@@ -0,0 +1,85 @@
/**
* The image the OS shows under the cursor during a drag-out.
*
* `startDrag` requires one — the plugin's `image` argument is not optional, and
* it only accepts a `data:image/png;base64,` URL — so this is drawn rather than
* shipped as an asset. Drawing it is also what keeps the colours honest: the
* palette lives in CSS custom properties, and reading them off the document is
* the only way a raw-pixel preview can still come from the design tokens rather
* than from hard-coded hexes.
*/
/**
* A 1x1 transparent PNG, used when no 2D canvas is available — jsdom has none,
* and a webview can refuse a context under memory pressure. `startDrag` needs
* *an* image, and a drag with an invisible preview is much better than no drag.
*/
const TRANSPARENT_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
/** Longest filename drawn in full; past this the middle is elided. */
const MAX_LABEL = 28;
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Keep both ends of a long name — the extension is the informative half. */
function elide(label: string): string {
if (label.length <= MAX_LABEL) return label;
const head = label.slice(0, MAX_LABEL - 12);
const tail = label.slice(-9);
return `${head}${tail}`;
}
export function dragPreviewIcon(label: string): string {
try {
const text = elide(label);
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
// bytes over IPC.
const scale = Math.min(window.devicePixelRatio || 1, 2);
const height = 24;
const padding = 8;
const canvas = document.createElement("canvas");
// Measuring needs a context, and sizing the canvas resets it — so measure
// on a throwaway pass, then size, then draw.
const probe = canvas.getContext("2d");
if (!probe) return TRANSPARENT_PNG;
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
probe.font = font;
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return TRANSPARENT_PNG;
ctx.scale(scale, scale);
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
ctx.lineWidth = 1;
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
}
ctx.font = font;
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
ctx.textBaseline = "middle";
ctx.fillText(text, padding, height / 2);
const url = canvas.toDataURL("image/png");
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
// Rust side rejects outright, taking the whole drag with it.
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
} catch {
return TRANSPARENT_PNG;
}
}