2026-08-23 08:30:48 -07:00
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
|
|
|
import { useFileManager } from "./useFileManager";
|
|
|
|
|
import type { FileEntry } from "../lib/types";
|
|
|
|
|
|
|
|
|
|
const listContainerFiles = vi.fn();
|
|
|
|
|
const downloadContainerFile = vi.fn();
|
|
|
|
|
const uploadFileToContainer = vi.fn();
|
|
|
|
|
const renameContainerPath = vi.fn();
|
|
|
|
|
const createContainerDirectory = vi.fn();
|
2026-08-23 09:12:59 -07:00
|
|
|
const stageContainerFileForDrag = vi.fn();
|
2026-08-23 08:30:48 -07:00
|
|
|
|
|
|
|
|
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: vi.fn(),
|
2026-08-23 09:12:59 -07:00
|
|
|
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
2026-08-23 08:30:48 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const save = vi.fn();
|
|
|
|
|
const openDialog = vi.fn();
|
|
|
|
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
|
|
|
save: (opts: unknown) => save(opts),
|
|
|
|
|
open: (opts: unknown) => openDialog(opts),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
|
|
|
|
name,
|
|
|
|
|
path: `/workspace/${name}`,
|
|
|
|
|
is_directory: false,
|
|
|
|
|
is_symlink: false,
|
|
|
|
|
size: 10,
|
|
|
|
|
modified: "2024-01-01 00:00:00",
|
|
|
|
|
permissions: "644",
|
|
|
|
|
...extra,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("useFileManager navigation", () => {
|
|
|
|
|
it("lists a directory and remembers where it is", async () => {
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/workspace/app");
|
|
|
|
|
});
|
|
|
|
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
|
|
|
|
expect(result.current.currentPath).toBe("/workspace/app");
|
|
|
|
|
expect(result.current.entries).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("surfaces a listing failure rather than showing a stale directory", async () => {
|
|
|
|
|
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/root");
|
|
|
|
|
});
|
|
|
|
|
expect(result.current.error).toContain("Permission denied");
|
|
|
|
|
expect(result.current.currentPath).toBe("/workspace");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("goes up one level, and stops at the root", async () => {
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/workspace/app/src");
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
result.current.goUp();
|
|
|
|
|
});
|
|
|
|
|
await waitFor(() => expect(result.current.currentPath).toBe("/workspace/app"));
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/");
|
|
|
|
|
});
|
|
|
|
|
listContainerFiles.mockClear();
|
|
|
|
|
await act(async () => {
|
|
|
|
|
result.current.goUp();
|
|
|
|
|
});
|
|
|
|
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("useFileManager uploads", () => {
|
|
|
|
|
it("uploads every dropped path into the current directory, then re-lists once", async () => {
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/workspace/app");
|
|
|
|
|
});
|
|
|
|
|
listContainerFiles.mockClear();
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
|
|
|
|
|
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
|
|
|
|
|
// One refresh for the batch, not one per file.
|
|
|
|
|
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports a failed upload but still lists whatever did land", async () => {
|
|
|
|
|
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
|
|
|
|
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
|
|
|
|
});
|
|
|
|
|
expect(result.current.error).toContain("too large");
|
|
|
|
|
expect(listContainerFiles).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does nothing when the file picker is cancelled", async () => {
|
|
|
|
|
openDialog.mockResolvedValue(null);
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.uploadFile();
|
|
|
|
|
});
|
|
|
|
|
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("useFileManager rename and mkdir", () => {
|
|
|
|
|
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
|
|
|
|
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/workspace");
|
|
|
|
|
});
|
|
|
|
|
listContainerFiles.mockClear();
|
|
|
|
|
|
|
|
|
|
let ok: boolean | undefined;
|
|
|
|
|
await act(async () => {
|
|
|
|
|
ok = await result.current.renameEntry(file("a.txt"), " renamed.txt ");
|
|
|
|
|
});
|
|
|
|
|
expect(ok).toBe(true);
|
|
|
|
|
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/a.txt", "renamed.txt");
|
|
|
|
|
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the editor open and shows what the container said when a rename fails", async () => {
|
|
|
|
|
// Renames outside /workspace legitimately fail; the user needs mv's words.
|
|
|
|
|
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
let ok: boolean | undefined;
|
|
|
|
|
await act(async () => {
|
|
|
|
|
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
|
|
|
|
});
|
|
|
|
|
expect(ok).toBe(false);
|
|
|
|
|
expect(result.current.error).toContain("Permission denied");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.renameEntry(file("a.txt"), "a.txt");
|
|
|
|
|
});
|
|
|
|
|
expect(renameContainerPath).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("creates a folder under the current directory", async () => {
|
|
|
|
|
createContainerDirectory.mockResolvedValue("/workspace/app/new");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.navigate("/workspace/app");
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.createFolder(" new ");
|
|
|
|
|
});
|
|
|
|
|
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace/app", "new");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("surfaces a clash instead of silently doing nothing", async () => {
|
|
|
|
|
createContainerDirectory.mockRejectedValue("mkdir: cannot create directory 'src': File exists");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
let ok: boolean | undefined;
|
|
|
|
|
await act(async () => {
|
|
|
|
|
ok = await result.current.createFolder("src");
|
|
|
|
|
});
|
|
|
|
|
expect(ok).toBe(false);
|
|
|
|
|
expect(result.current.error).toContain("File exists");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("useFileManager save to host", () => {
|
|
|
|
|
it("writes to the path the user picked", async () => {
|
|
|
|
|
save.mockResolvedValue("/host/Downloads/a.txt");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.downloadFile(file("a.txt"));
|
|
|
|
|
});
|
|
|
|
|
expect(downloadContainerFile).toHaveBeenCalledWith(
|
|
|
|
|
"p1",
|
|
|
|
|
"/workspace/a.txt",
|
|
|
|
|
"/host/Downloads/a.txt",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports a refused download — a directory is no longer written as garbage", async () => {
|
|
|
|
|
save.mockResolvedValue("/host/Downloads/src");
|
|
|
|
|
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
|
|
|
|
|
const { result } = renderHook(() => useFileManager("p1"));
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.downloadFile(file("src", { is_directory: true }));
|
|
|
|
|
});
|
|
|
|
|
expect(result.current.error).toContain("is a folder");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-23 09:12:59 -07:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
});
|