Files
Triple-C/app/src/components/projects/home/dragPreview.ts
T
shadow-testandClaude Opus 5 1c834a0b08 Drag a file out of the Files tab onto the host desktop
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
2026-08-23 09:12:59 -07:00

86 lines
3.2 KiB
TypeScript

/**
* 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;
}
}