Turn the Files tab into a real file manager

Rename, an in-app viewer for text and images, host-to-container drag and
drop, New folder, keyboard operation — plus the pre-existing bugs the new
surface would otherwise have been built on top of.

New Tauri commands (file_commands.rs, registered in lib.rs):

* rename_container_path — `mv -n -- <from> <parent>/<name>` through
  exec_oneshot_as, so the *exit code* is checked. exec_oneshot discards the
  status and interleaves stderr into stdout, which would have made a
  permission failure look like a success. `mv -n` on its own is not enough
  either: GNU coreutils makes its refusal to clobber silent and exits 0, so
  an explicit `test -e` on the destination is what turns a name clash into
  an error the user sees. `mv`'s own words are surfaced, since renames
  outside /workspace legitimately fail on permissions. The new name is
  validated in Rust (no `/`, no NUL, not "." / ".." / empty, ≤255 bytes) —
  it is user text going into argv, and a name with a separator would be a
  move rather than a rename.
* read_container_file — exact bytes via Docker's archive endpoint, returned
  as base64. Deliberately not exec_oneshot, which runs every chunk through
  String::from_utf8_lossy and merges stderr, so it would corrupt any
  non-UTF-8 file and could splice diagnostics into content. Base64 rather
  than Vec<u8> because Tauri serialises a byte vec as a JSON number array.
  Capped and truncation-reporting; the caller picks the cap (images get 5
  MiB against text's 1 MiB, being the kind that blows a text-sized budget)
  and Rust clamps it to 8 MiB regardless.
* create_container_directory — `mkdir` without -p, so a clash is an error
  rather than a silent success. Named for its siblings rather than the bare
  `create_directory` in the brief.

The tar-extraction half of download_container_file is now the shared
fetch_container_file() both commands use, and it abandons the transfer once
a capped read has what it needs.

Frontend:

* Single click selects, double click opens. Directory navigation moved onto
  double click too — a single click used to navigate, which made it
  impossible to select a directory in order to rename it. Rows are now
  focusable and the table is a real `grid`: Enter opens, F2 renames, arrows
  walk the rows. No outline suppression; the global :focus-visible ring is
  what shows focus.
* FileViewerModal (built on ui/Modal, the only correct dialog) renders text
  in a <pre> and images from a revocable blob: URL. tauri.conf.json's
  img-src had neither `data:` nor `blob:`, so an in-app image was blocked by
  CSP; `blob:` is added — revocable, and no megabytes of base64 in the DOM.
  The asset protocol stays disabled. Anything else gets a "Save to host"
  state instead of a broken preview, decided by extension and then by
  sniffing the bytes for NUL.
* Host drag-and-drop uses Tauri's native onDragDropEvent, mirroring
  TerminalView: HTML5 ondrop carries no paths and is blocked in the webview
  on Windows by dragDropEnabled, which the terminal needs. The listener is
  window-wide, so it routes by hit-testing the payload position (physical
  pixels, hence the devicePixelRatio divide) against the pane's rect — a
  hidden pane has a zero-size rect and never matches, which is what keeps
  this and the terminal's listener apart. enter/over/leave drive a drop
  highlight.
* Per-row Download is now "Save to host…"; directories no longer offer it.

Pre-existing bugs fixed:

* Uploaded files landed root:root with a 1970 mtime. tar::Header::new_gnu()
  zeroes uid/gid/mtime and Docker honours the header verbatim, so uploads
  were not writable by `claude`. All four single-file tar builds now go
  through build_single_file_tar() with the container user's ids, read from
  the container because entrypoint.sh remaps them to the host user on Unix
  and deliberately does not on Windows.
* Symlinked directories could not be opened: `find -printf '%y'` reports `l`.
  The listing now prints `%Y` as well, so is_directory dereferences and a
  new is_symlink carries what `%y` used to say. The row labels the link.
* upload_file_to_container had no size cap and did a synchronous fs::read on
  an async worker. Now 256 MiB (matching the terminal drop path) with the
  read and tar build in spawn_blocking, and the host mtime preserved.
* A directory passed to upload reached fs::read and produced an opaque "Is a
  directory". Rejected with an explanation instead — recursive upload is a
  larger feature than this panel needs.
* download_container_file wrote the *first tar entry*, so downloading a
  directory silently produced a garbage file. Non-regular entries are now an
  explicit error.

Tests: 46 new (33 frontend across FilesTab, useFileManager and filePreview;
12 Rust covering the find-output parser and the rename validator, neither of
which had any). 405 frontend / 297 Rust, both green.

No drag-out dependency was added — tauri-plugin-drag is not introduced and
OS drag-out is not attempted; that stays deferred, with "Save to host…" as
the way files leave the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:30:48 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit 15e05e2197
13 changed files with 1916 additions and 153 deletions
@@ -0,0 +1,350 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import FilesTab from "./FilesTab";
import type { FileContents, FileEntry, Project } from "../../../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn(async () => {});
const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (p: string, h: string, d: string) => uploadFileToContainer(p, h, d),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
}));
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
open: vi.fn(async () => null),
}));
/** The webview's window-wide native drag-drop listener, captured for driving. */
type DragPayload =
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
| { type: "leave" }
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
const unlistenDrag = vi.fn();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
dragHandler = cb;
return unlistenDrag;
},
}),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name,
path: `/workspace/${name}`,
is_directory: false,
is_symlink: false,
size: 12,
modified: "2024-05-01 10:00:00",
permissions: "644",
...extra,
});
const contents = (text: string, extra: Partial<FileContents> = {}): FileContents => ({
contents_base64: btoa(text),
truncated: false,
size: text.length,
...extra,
});
async function renderTab() {
const view = render(<FilesTab project={project} />);
await act(async () => {
await Promise.resolve();
});
return view;
}
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
async function drop(paths: string[], position = { x: 100, y: 100 }) {
await act(async () => {
await dragHandler?.({ payload: { type: "drop", position, paths } });
});
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
]);
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
toJSON: () => ({}),
} as DOMRect);
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
});
describe("FilesTab listing", () => {
it("lists /workspace once the container is running", async () => {
await renderTab();
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
expect(screen.getByText("notes.txt")).toBeTruthy();
});
it("says nothing about files while the container is stopped", async () => {
render(<FilesTab project={{ ...project, status: "stopped" } as Project} />);
expect(screen.getByText(/Start the container/)).toBeTruthy();
expect(listContainerFiles).not.toHaveBeenCalled();
});
it("labels a symlink, which no longer masquerades as a plain file", async () => {
listContainerFiles.mockResolvedValue([
entry("app", { is_directory: true, is_symlink: true }),
]);
await renderTab();
expect(screen.getByTitle("Symbolic link")).toBeTruthy();
});
});
describe("FilesTab open semantics", () => {
it("selects on a single click without navigating", async () => {
await renderTab();
listContainerFiles.mockClear();
fireEvent.click(screen.getByText("src"));
expect(listContainerFiles).not.toHaveBeenCalled();
expect(screen.getByText("src").closest("tr")?.getAttribute("aria-selected")).toBe("true");
});
it("navigates a directory on double click", async () => {
await renderTab();
listContainerFiles.mockClear();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
});
it("walks the rows with the arrow keys, which is what makes the grid role honest", async () => {
await renderTab();
const first = screen.getByText("src").closest("tr")!;
first.focus();
fireEvent.keyDown(first, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getByText("notes.txt").closest("tr"));
fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" });
expect(document.activeElement).toBe(first);
});
it("opens a directory from the keyboard with Enter", async () => {
await renderTab();
listContainerFiles.mockClear();
const row = screen.getByText("src").closest("tr")!;
expect(row.getAttribute("tabindex")).toBe("0");
await act(async () => {
fireEvent.keyDown(row, { key: "Enter" });
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
});
});
describe("FilesTab viewer", () => {
it("shows a text file's contents in a dialog", async () => {
readContainerFile.mockResolvedValue(contents("hello from the container"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(await screen.findByText("hello from the container")).toBeTruthy();
// A text file gets the text-sized budget, not the image one.
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", 1024 * 1024);
});
it("renders an image through a revocable blob URL, not a data URI", async () => {
// `data:` is absent from the app's img-src on purpose; `blob:` is what was
// added, and the object URL has to be released when the dialog closes.
listContainerFiles.mockResolvedValue([entry("logo.png", { size: 4 })]);
readContainerFile.mockResolvedValue(contents("\x89PNG"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("logo.png"));
});
const img = (await screen.findByAltText("logo.png")) as HTMLImageElement;
expect(img.getAttribute("src")).toBe("blob:mock-url");
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/logo.png", 5 * 1024 * 1024);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"));
});
it("refuses an oversized image rather than drawing a half-decoded one", async () => {
listContainerFiles.mockResolvedValue([entry("huge.png", { size: 40 * 1024 * 1024 })]);
readContainerFile.mockResolvedValue(contents("\x89PNG", { truncated: true, size: 40 * 1024 * 1024 }));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("huge.png"));
});
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
expect(screen.queryByAltText("huge.png")).toBeNull();
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
});
it("says so in words when only a prefix of a big text file came back", async () => {
readContainerFile.mockResolvedValue(
contents("first megabyte", { truncated: true, size: 5 * 1024 * 1024 }),
);
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
expect(await screen.findByText(/Showing the first/)).toBeTruthy();
expect(screen.getByText("first megabyte")).toBeTruthy();
});
it("offers Save to host for a file it cannot render", async () => {
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
readContainerFile.mockResolvedValue(contents("a\x00b"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("blob.bin"));
});
expect(await screen.findByText(/no preview for this file type/)).toBeTruthy();
});
});
describe("FilesTab rename", () => {
it("commits an inline rename on Enter and re-lists", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
fireEvent.change(input, { target: { value: "renamed.txt" } });
await act(async () => {
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.blur(input);
});
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "renamed.txt");
});
it("abandons the rename on Escape", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "nope.txt" } });
await act(async () => {
fireEvent.keyDown(input, { key: "Escape" });
});
expect(renameContainerPath).not.toHaveBeenCalled();
expect(screen.queryByLabelText("New name for notes.txt")).toBeNull();
});
it("starts a rename from the keyboard with F2", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
fireEvent.keyDown(row, { key: "F2" });
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
});
it("shows what the container said when a rename is refused", async () => {
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "x" } });
await act(async () => {
fireEvent.blur(input);
});
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
});
});
describe("FilesTab new folder", () => {
it("creates a folder under the current directory", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
const input = screen.getByLabelText("New folder name");
fireEvent.change(input, { target: { value: "assets" } });
await act(async () => {
fireEvent.blur(input);
});
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "assets");
});
});
describe("FilesTab host drag-and-drop", () => {
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
await renderTab();
listContainerFiles.mockClear();
await drop(["/host/a.png", "/host/b.png"]);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
it("drops into the directory the user has navigated to", async () => {
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
await drop(["/host/a.png"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
});
it("ignores a drop outside the pane — the listener is window-wide", async () => {
// This is the whole routing discipline: the terminal's listener is live at
// the same time, and only the hit-test keeps them apart.
await renderTab();
await drop(["/host/a.png"], { x: 5000, y: 5000 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("divides the payload position by devicePixelRatio", async () => {
// The native payload is in physical pixels; the rect is in CSS pixels.
// At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside an 800x600 pane.
const original = window.devicePixelRatio;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", { value: original, configurable: true });
});
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
await renderTab();
await act(async () => {
await dragHandler?.({
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
});
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
await act(async () => {
await dragHandler?.({ payload: { type: "leave" } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab save to host", () => {
it("copies a file out to the path the user picks", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save notes.txt to host" }));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
});
it("does not offer a directory download, which cannot work", async () => {
await renderTab();
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
});
});