The Files tab could accept a drop but never produce one: getting a file
out meant "Save to host…" and a file picker. This adds the other
direction.
Two constraints shape it. `dragDropEnabled` is on — TerminalView needs
it, since the native drag-drop event is the only one carrying dropped
file paths — and it blocks HTML5 drag inside the webview, so `draggable`
plus `DataTransfer.setData("DownloadURL", …)` was never available. The
gesture is therefore pointer events into `tauri-plugin-drag`, the same
shape and the same reason as the tab strip's drag. And the file being
dragged does not exist on the host at all: it lives in a container, and
the OS can only drag a real host path.
So a drag-out is a copy first and a drag second.
`stage_container_file_for_drag` materialises the file into
`<os-temp>/triple-c-drag-out/<session>/<slot>/<name>` through the same
`fetch_container_file` the download and the viewer use, keeps the
original filename (a dropped `tmp1234` is not a file anyone wants), and
caps at the 256 MiB an upload already caps at, naming "Save to host…" in
the refusal. The path comes from Tauri's path API rather than `/tmp`,
because on Windows it is neither.
The staging directory has a lifecycle, because whole files accumulating
in the host temp dir would be the disk problem this project just fixed,
in a new place: cleared on exit inside the existing teardown (still
guarded on the main window), and reaped at startup for whatever a crash
left behind.
The copy is also an async gap in the middle of a gesture that feels
instantaneous, and the OS only adopts a drag while the button is still
down. Small files beat the pointer; large ones do not — so the staged
path is cached per entry (keyed on size and mtime, so an edited file
re-stages) and the pane says the copy is ready and to drag again, which
is an instruction rather than an apology because the retry is immediate.
A per-file slot keeps `a/notes.txt` and `b/notes.txt` from becoming the
same host path.
"Save to host…" stays exactly as it was. Drag-out is the enhancement;
a platform that refuses `startDrag` says so and points back at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
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),
|
|
);
|
|
});
|
|
});
|