Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -9,7 +9,6 @@ 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),
|
||||
@@ -19,19 +18,6 @@ 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 —
|
||||
* including its `onEvent` channel, which is how the plugin reports that the
|
||||
* gesture ended and therefore how the pane knows to start accepting drops
|
||||
* again. `endDragOut` below drives it.
|
||||
*/
|
||||
type DragCallback = (payload: { result: "Dropped" | "Cancelled" }) => void;
|
||||
const startDrag = vi.fn(async (_opts: unknown, _onEvent?: DragCallback) => {});
|
||||
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
|
||||
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -103,39 +89,6 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the pane the OS finished with the drag it started — what the plugin's
|
||||
* `onEvent` channel does for real. Until this arrives the pane deliberately
|
||||
* ignores drops, because a drag-out released back over the app arrives as one.
|
||||
*/
|
||||
function endDragOut(result: "Dropped" | "Cancelled" = "Dropped") {
|
||||
const onEvent = startDrag.mock.calls.at(-1)?.[1];
|
||||
act(() => {
|
||||
onEvent?.({ result });
|
||||
});
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
@@ -153,8 +106,6 @@ function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
||||
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"),
|
||||
@@ -167,10 +118,6 @@ 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", () => {
|
||||
@@ -503,191 +450,6 @@ describe("FilesTab save to host", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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"] }),
|
||||
// The `onEvent` channel: without it, "the drag finished" is unobservable
|
||||
// and a drag released back over the pane reads as a host drop.
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
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(toastText()).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(toastText()).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 to host… — notes.txt"));
|
||||
});
|
||||
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());
|
||||
// The OS is done with it — anything arriving now is a genuine host drop.
|
||||
endDragOut();
|
||||
|
||||
await drop(["/host/a.txt"]);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drag-out released back over the app", () => {
|
||||
it("does not re-import its own staged copy while the drag is in flight", async () => {
|
||||
// The damaging case, and the reason this is HIGH: the staged copy is keyed
|
||||
// off the *last listing*, so uploading it back is not even idempotent — a
|
||||
// file an agent rewrote since then would be replaced by a stale snapshot.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses the staged copy after the drag has ended", async () => {
|
||||
// Second line of defence, and the one that survives a platform whose
|
||||
// `onEvent` never arrives: the path is known to be ours, exactly.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
endDragOut("Cancelled");
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads the rest of a mixed drop, minus our own copy", async () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
endDragOut();
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt", "/host/real.png"]);
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/real.png", "/workspace");
|
||||
});
|
||||
|
||||
it("does not offer to accept files during its own export", async () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "enter", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
|
||||
// …and it comes back once the gesture is over.
|
||||
endDragOut();
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drop hit test", () => {
|
||||
it("uploads nothing when a dialog is covering the pane", async () => {
|
||||
// The pane still has its rect underneath the viewer's `fixed inset-0`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, 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 { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
|
||||
@@ -8,32 +7,12 @@ import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||
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;
|
||||
|
||||
/**
|
||||
* Belt and braces for the in-flight drag-out flag.
|
||||
*
|
||||
* The flag is cleared by the drag plugin's own `onEvent` channel, which fires
|
||||
* `Dropped` or `Cancelled` for every gesture the OS finishes. A platform that
|
||||
* never fires it would leave the flag stuck and this pane deaf to drops, so it
|
||||
* also times out. Long enough that a deliberate, slow drag across two monitors
|
||||
* is not cut short; short enough that a wedged flag heals within one coffee
|
||||
* sip. The staged-path filter below is the real protection either way — this
|
||||
* only decides how long the *hint* stays suppressed.
|
||||
*/
|
||||
const DRAG_OUT_WATCHDOG_MS = 30_000;
|
||||
|
||||
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
@@ -73,8 +52,6 @@ export default function FilesTab({ project }: Props) {
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
stageForDrag,
|
||||
isStagedHostPath,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
} = useFileManager(project.id);
|
||||
@@ -90,8 +67,6 @@ 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);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
@@ -108,7 +83,6 @@ export default function FilesTab({ project }: Props) {
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setRenaming(null);
|
||||
setDragNotice(null);
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,153 +234,6 @@ export default function FilesTab({ project }: Props) {
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
// 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);
|
||||
|
||||
/**
|
||||
* A drag-out the OS has taken and not yet finished.
|
||||
*
|
||||
* Without this, releasing a drag-out back over the Files pane fed the app its
|
||||
* own export as if it were a host drop: the staged copy was uploaded straight
|
||||
* back over the container file it came from. Not even idempotent — the staged
|
||||
* copy is cached against the *last listing*, so a file rewritten in the
|
||||
* container since then was replaced by a minutes-old snapshot. The `enter`
|
||||
* and `over` branches consult it too, so the pane does not offer to accept
|
||||
* files during its own export.
|
||||
*
|
||||
* Cleared from the drag plugin's `onEvent` channel, which reports `Dropped`
|
||||
* or `Cancelled` when the gesture ends — the installed
|
||||
* `@crabnebula/tauri-plugin-drag` (2.1.0) takes it as `startDrag`'s second
|
||||
* argument. `startDrag`'s own promise is *not* the signal: on some platforms
|
||||
* it resolves as soon as the OS adopts the drag, i.e. while it is still in
|
||||
* flight. See `DRAG_OUT_WATCHDOG_MS` for what happens if `onEvent` never
|
||||
* arrives.
|
||||
*/
|
||||
const dragOutInFlight = useRef(false);
|
||||
const dragOutWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const endDragOut = useCallback(() => {
|
||||
dragOutInFlight.current = false;
|
||||
if (dragOutWatchdog.current !== null) {
|
||||
clearTimeout(dragOutWatchdog.current);
|
||||
dragOutWatchdog.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => endDragOut, [endDragOut]);
|
||||
|
||||
// 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 reported the reason through the toast host.
|
||||
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;
|
||||
}
|
||||
|
||||
dragOutInFlight.current = true;
|
||||
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
|
||||
try {
|
||||
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }, () =>
|
||||
endDragOut(),
|
||||
);
|
||||
} catch (e) {
|
||||
endDragOut();
|
||||
// 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.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: 'Could not start the drag — use "Save to host…" instead.',
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[stageForDrag, endDragOut],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -418,10 +245,6 @@ export default function FilesTab({ project }: Props) {
|
||||
// point? (Not "is that element mine": chrome painted over a pane — a toast,
|
||||
// a button — is not something that swallows a drop, and treating it as such
|
||||
// made permanent dead zones.)
|
||||
//
|
||||
// Two further filters sit in front of it, both about our own drag-out:
|
||||
// `dragOutInFlight`, and the staged-path check, which is exact because
|
||||
// `useFileManager` remembers every host path it staged.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
@@ -435,14 +258,11 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
setDragOver(
|
||||
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
|
||||
);
|
||||
setDragOver(isDropTarget(paneRef.current, payload.position));
|
||||
return;
|
||||
}
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
if (dragOutInFlight.current) return;
|
||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
||||
// the file just never appears in the listing.
|
||||
@@ -460,10 +280,7 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
// Anything we staged for a drag-out is our own copy of a file that is
|
||||
// already in the container; re-importing it would overwrite the
|
||||
// original with a snapshot.
|
||||
const paths = (payload.paths ?? []).filter((path) => !isStagedHostPath(path));
|
||||
const paths = payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
await uploadPaths(paths);
|
||||
});
|
||||
@@ -475,7 +292,7 @@ export default function FilesTab({ project }: Props) {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [running, uploadPaths, isStagedHostPath]);
|
||||
}, [running, uploadPaths]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
@@ -518,11 +335,7 @@ export default function FilesTab({ project }: Props) {
|
||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
||||
* every completion notice used to go by in silence.
|
||||
*/
|
||||
const liveText = busy
|
||||
? busy
|
||||
: dragNotice
|
||||
? `"${dragNotice}" is ready — drag it again to drop it on the desktop.`
|
||||
: (completed ?? "");
|
||||
const liveText = busy ? busy : (completed ?? "");
|
||||
|
||||
return (
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
@@ -568,7 +381,7 @@ export default function FilesTab({ project }: Props) {
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — upload, rename, mkdir,
|
||||
save-to-host, staging — goes to `ToastHost` instead, which is above
|
||||
save-to-host — goes to `ToastHost` instead, which is above
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
@@ -665,7 +478,6 @@ export default function FilesTab({ project }: Props) {
|
||||
setActiveRow(entry.name);
|
||||
}}
|
||||
onDoubleClick={() => openEntry(entry)}
|
||||
{...dragOutProps(entry)}
|
||||
onKeyDown={(e) => {
|
||||
if (isRenaming) return;
|
||||
if (e.key === "Enter") {
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
|
||||
/**
|
||||
* Which write to `status` is the newest — the same "is this still mine?"
|
||||
* guard `useDiskUsage` and `useContainerMigration` use around their async
|
||||
* guard `useContainerMigration` uses around its async
|
||||
* writes, and needed here for a reason that is easy to miss.
|
||||
*
|
||||
* There are two sources of truth for this row and only one of them is
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user