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
This commit is contained in:
@@ -8,6 +8,7 @@ const downloadContainerFile = vi.fn();
|
||||
const uploadFileToContainer = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
const stageContainerFileForDrag = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
@@ -17,6 +18,7 @@ vi.mock("../lib/tauri-commands", () => ({
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: vi.fn(),
|
||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||
}));
|
||||
|
||||
const save = vi.fn();
|
||||
@@ -209,3 +211,82 @@ describe("useFileManager save to host", () => {
|
||||
expect(result.current.error).toContain("is a folder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager drag-out staging", () => {
|
||||
it("copies the file onto the host and hands back the host path", async () => {
|
||||
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||
await act(async () => {
|
||||
staged = await result.current.stageForDrag(file("a.txt"));
|
||||
});
|
||||
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/a.txt");
|
||||
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||
// The note is transient — it must not still be sitting there afterwards.
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
|
||||
it("reuses the copy on a second drag of the same entry", async () => {
|
||||
// The whole point of the cache: the copy is the slow half of the gesture,
|
||||
// and a retry after a drag the OS missed has to be immediate.
|
||||
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let second: { hostPath: string; cached: boolean } | null = null;
|
||||
await act(async () => {
|
||||
await result.current.stageForDrag(file("a.txt"));
|
||||
second = await result.current.stageForDrag(file("a.txt"));
|
||||
});
|
||||
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
|
||||
expect(second).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: true });
|
||||
});
|
||||
|
||||
it("re-stages once the entry has changed underneath it", async () => {
|
||||
// Keyed on size and mtime, so a file edited in the container is copied
|
||||
// again rather than dragged out at its old contents.
|
||||
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stageForDrag(file("a.txt", { size: 10 }));
|
||||
await result.current.stageForDrag(file("a.txt", { size: 4096 }));
|
||||
});
|
||||
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces a refused staging instead of returning a path that is not there", async () => {
|
||||
stageContainerFileForDrag.mockRejectedValue(
|
||||
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||
await act(async () => {
|
||||
staged = await result.current.stageForDrag(file("huge.bin"));
|
||||
});
|
||||
|
||||
expect(staged).toBeNull();
|
||||
expect(result.current.error).toContain("too large to drag out");
|
||||
expect(result.current.error).toContain("Save to host");
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
|
||||
it("does not cache a failure, so a retry actually retries", async () => {
|
||||
stageContainerFileForDrag.mockRejectedValueOnce("Container not running");
|
||||
stageContainerFileForDrag.mockResolvedValueOnce("/tmp/triple-c-drag-out/s1/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let staged: { hostPath: string; cached: boolean } | null = null;
|
||||
await act(async () => {
|
||||
await result.current.stageForDrag(file("a.txt"));
|
||||
staged = await result.current.stageForDrag(file("a.txt"));
|
||||
});
|
||||
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
|
||||
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
@@ -83,6 +83,44 @@ export function useFileManager(projectId: string) {
|
||||
[projectId, currentPath, navigate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Host paths already copied out this session, keyed by the entry they came
|
||||
* from. Size and mtime are in the key, so an entry that changed since the
|
||||
* last listing re-stages rather than dragging a stale copy.
|
||||
*/
|
||||
const stagedRef = useRef(new Map<string, string>());
|
||||
|
||||
/**
|
||||
* Copy an entry onto the host so the OS can drag it, and return the absolute
|
||||
* host path — or `null`, having set `error`, if it could not be staged.
|
||||
*
|
||||
* `cached` is what the caller needs to tell a gesture that will feel
|
||||
* instantaneous from one that has a whole-file copy in front of it: the copy
|
||||
* is the slow half of a drag-out, and the OS only picks a drag up while the
|
||||
* button is still down.
|
||||
*/
|
||||
const stageForDrag = useCallback(
|
||||
async (entry: FileEntry): Promise<{ hostPath: string; cached: boolean } | null> => {
|
||||
const key = `${entry.path}|${entry.size}|${entry.modified}`;
|
||||
const cached = stagedRef.current.get(key);
|
||||
if (cached) return { hostPath: cached, cached: true };
|
||||
|
||||
setError(null);
|
||||
setBusy(`Preparing "${entry.name}"…`);
|
||||
try {
|
||||
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
|
||||
stagedRef.current.set(key, hostPath);
|
||||
return { hostPath, cached: false };
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({ multiple: true, directory: false });
|
||||
@@ -145,6 +183,7 @@ export function useFileManager(projectId: string) {
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
stageForDrag,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user