Give the Files tab back its uploads and downloads
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
This commit is contained in:
@@ -144,15 +144,16 @@ export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
|
||||
from a terminal in the container, or take a backup and open it on the host.
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Open it from a terminal in the container,
|
||||
or take a backup and open it on the host.
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
@@ -7,6 +7,8 @@ const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
@@ -14,6 +16,8 @@ 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),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -175,9 +179,15 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
// The way out is named, and it is not a host path this pane could write:
|
||||
// a terminal inside the container, or a backup.
|
||||
expect(screen.getByText(/take a backup/)).toBeTruthy();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
@@ -392,3 +402,73 @@ describe("FilesTab grid semantics", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,26 @@ const PARENT_ROW = "..";
|
||||
/**
|
||||
* The project's file browser.
|
||||
*
|
||||
* Container-side only: it lists, opens, renames and creates folders inside the
|
||||
* container, and it does no host filesystem I/O at all. A file gets *into* a
|
||||
* container by being dropped onto the Terminal tab, and a whole tree comes back
|
||||
* out through "Back up container" in the project's Workspace settings. Four
|
||||
* successive audits found that host paths crossing IPC were where the criticals
|
||||
* lived; those two paths are the ones that survived, and this pane is not one
|
||||
* of them.
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -52,6 +65,10 @@ export default function FilesTab({ project }: Props) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPath,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
@@ -305,6 +322,16 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -501,6 +528,34 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPath === entry.path}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{savingPath === entry.path ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -6,12 +6,16 @@ import type { FileEntry } from "../lib/types";
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
readContainerFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -45,6 +49,8 @@ const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("useFileManager navigation", () => {
|
||||
@@ -292,3 +298,226 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both of these actions are *dialog-driven from Rust* — the hook passes a
|
||||
* project and a directory and gets back an answer, and there is deliberately no
|
||||
* host path anywhere in this file. What is worth pinning is the vocabulary of
|
||||
* that answer, because two of its values look like failure and are not: `null`
|
||||
* means the user dismissed the picker, and `0` bytes means an empty file was
|
||||
* saved successfully.
|
||||
*/
|
||||
describe("useFileManager saving to the host", () => {
|
||||
it("treats a zero-byte save as a success", async () => {
|
||||
// The bug this exists for: `if (!bytes) return` reads a genuine
|
||||
// zero-length file — an empty `.gitkeep`, a truncated log — as a
|
||||
// dismissal, so the file lands on the host and the app says nothing at
|
||||
// all. The sentinel is `null`, and only `null`.
|
||||
downloadContainerFile.mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("empty.txt"));
|
||||
});
|
||||
expect(result.current.completed).toContain("empty.txt");
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says nothing at all when the dialog is dismissed", async () => {
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
expect(result.current.completed).toBeNull();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file in a refusal", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("secret.txt"));
|
||||
});
|
||||
expect(toastText()).toContain("secret.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager uploading from the host", () => {
|
||||
it("uploads into the directory on screen and shows the result", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
|
||||
failures: [],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
expect(result.current.completed).toContain("2 files");
|
||||
// The directory is named. `target` is captured at click time and the
|
||||
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
|
||||
// in front of a grid those files are not in.
|
||||
expect(result.current.completed).toContain("/workspace/app");
|
||||
// The new files are only on screen if the listing was asked for again.
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
});
|
||||
|
||||
it("reports every file that failed, not just a count", async () => {
|
||||
// "3 of 5 uploaded" without naming the two is not a report — the user
|
||||
// cannot tell which ones to retry, or why.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/ok.txt"],
|
||||
failures: [
|
||||
"/home/j/Pictures is a folder — upload its files individually.",
|
||||
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(2);
|
||||
expect(toastText()).toContain("is a folder");
|
||||
expect(toastText()).toContain("too large");
|
||||
// A partial batch still succeeded partially, and the pane must show it.
|
||||
expect(result.current.completed).toContain("1 file");
|
||||
});
|
||||
|
||||
it("does not refresh when the picker was dismissed", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
expect(result.current.completed).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a refusal that happened before the picker once, not per file", async () => {
|
||||
// No container, not running, or a directory this pane may not write to.
|
||||
// There is no selection yet, so there is nothing to enumerate.
|
||||
uploadFilesToContainer.mockRejectedValueOnce(
|
||||
"Start the project before uploading files — it runs inside the running container.",
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(1);
|
||||
expect(toastText()).toContain("Start the project");
|
||||
});
|
||||
|
||||
it("does not drag the pane back when the user navigated during the upload", async () => {
|
||||
// The same rule rename and new-folder follow: a slow operation must not
|
||||
// relist a directory the user has already left.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/other");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(result.current.currentPath).toBe("/workspace/other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager transfer state", () => {
|
||||
it("marks an upload in flight for as long as it runs", async () => {
|
||||
// Without this the button stays live: a second click opens a second OS
|
||||
// dialog and runs a second concurrent exec, and a slow transfer looks
|
||||
// exactly like a click that did nothing.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.uploading).toBe(false);
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
release({ uploaded: [], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the upload flag when the transfer fails", async () => {
|
||||
// The `catch` returns early, so without a `finally` the button is disabled
|
||||
// for the rest of the session — the failure mode is a pane that can never
|
||||
// upload again, with no error left on screen to explain it.
|
||||
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("marks only the row being saved, and clears it on failure", async () => {
|
||||
let release: (v: unknown) => void = () => {};
|
||||
downloadContainerFile.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
let saving: Promise<void>;
|
||||
act(() => {
|
||||
saving = result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
// The path, not a boolean — the rest of the pane stays usable.
|
||||
expect(result.current.savingPath).toBe("/workspace/a.txt");
|
||||
await act(async () => {
|
||||
release(10);
|
||||
await saving;
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
|
||||
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("b.txt"));
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
|
||||
// And on dismissal, which is the path that actually needs the `finally`:
|
||||
// the dismissal check `return`s from inside the `try`, so a clear placed
|
||||
// after the block instead is skipped and the row reads "Saving…" for the
|
||||
// rest of the session with nothing running behind it.
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("c.txt"));
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* ## Where failures are reported
|
||||
@@ -13,8 +14,8 @@ import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — rename, create folder — goes to
|
||||
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* Every **transient operation** failure — rename, create folder, upload, save
|
||||
* to host — goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
@@ -42,6 +43,20 @@ export function useFileManager(projectId: string) {
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
/**
|
||||
* Which host transfers are in flight.
|
||||
*
|
||||
* Both actions open an OS dialog and can then run for a long time on a large
|
||||
* file, with nothing on screen to say so. Without this the buttons stay live:
|
||||
* a second click opens a second dialog and runs a second concurrent exec
|
||||
* against the same file, and a multi-gigabyte save is indistinguishable from
|
||||
* a click that did nothing.
|
||||
*
|
||||
* `savingPath` rather than a boolean, so only the row being saved is
|
||||
* disabled — the pane stays usable while a big file is written.
|
||||
*/
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingPath, setSavingPath] = useState<string | null>(null);
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -154,6 +169,76 @@ export function useFileManager(projectId: string) {
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy host files into the directory on screen.
|
||||
*
|
||||
* The picker is opened by **Rust**, not here — `upload_files_to_container`
|
||||
* shows it, reads what the user chose and never lets a host path near IPC.
|
||||
* So this passes a directory and gets back an outcome; `null` means the user
|
||||
* dismissed the dialog, which is not a failure and says nothing.
|
||||
*
|
||||
* One dialog can select several files and they need not agree, hence two
|
||||
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
|
||||
* which two is not a report. The listing is refreshed once, at the end, and
|
||||
* only if the user is still looking at the directory that was targeted.
|
||||
*/
|
||||
const uploadFiles = useCallback(async () => {
|
||||
const target = currentPathRef.current;
|
||||
let outcome;
|
||||
setUploading(true);
|
||||
try {
|
||||
outcome = await commands.uploadFilesToContainer(projectId, target);
|
||||
} catch (e) {
|
||||
// A failure *before* the picker: no container, not running, or a
|
||||
// directory this pane may not write to. One toast, not one per file.
|
||||
report("Could not upload", e);
|
||||
return;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
if (!outcome) return;
|
||||
for (const failure of outcome.failures) {
|
||||
useAppState.getState().pushToast({ kind: "error", message: failure });
|
||||
}
|
||||
if (outcome.uploaded.length > 0) {
|
||||
// The directory is named, not implied. `target` is captured at click
|
||||
// time and the picker is a modal OS dialog — the user has all the time in
|
||||
// the world to browse somewhere else while it is open, and the files land
|
||||
// where they started. "Uploaded 2 files." in front of a grid that does not
|
||||
// contain them is a worse answer than no message at all.
|
||||
const count = outcome.uploaded.length;
|
||||
setCompleted(
|
||||
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
||||
);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
}
|
||||
}, [projectId, navigate, report]);
|
||||
|
||||
/**
|
||||
* Save one file out to the host, with Rust opening the save dialog.
|
||||
*
|
||||
* No refresh: nothing in the container changed. The save dialog is also what
|
||||
* asks about overwriting an existing host file, which is why the backend has
|
||||
* no collision handling of its own to get wrong. `null` is a dismissal.
|
||||
*/
|
||||
const saveToHost = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
setSavingPath(entry.path);
|
||||
try {
|
||||
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
||||
// `0` is a real answer — an empty file saved is a success — so this
|
||||
// tests for the dismissal sentinel, not for falsiness.
|
||||
if (bytes === null) return;
|
||||
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}"`, e);
|
||||
} finally {
|
||||
setSavingPath(null);
|
||||
}
|
||||
},
|
||||
[projectId, report],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
@@ -168,5 +253,10 @@ export function useFileManager(projectId: string) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
/** A host transfer is in flight — see the state declarations above. */
|
||||
uploading,
|
||||
savingPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* payload position inside my rect? A hidden pane is `display:none` and so
|
||||
* has a zero-size rect, which is what stops two panes both claiming the
|
||||
* same drop. `TerminalView` is the only pane that takes dropped files
|
||||
* today — the Files pane is container-side only — but the routing is what
|
||||
* keeps it honest when a second one appears.
|
||||
* today — the Files pane copies files through buttons and a backend-opened
|
||||
* dialog, not through a drop — but the routing is what keeps it honest when
|
||||
* a second one appears.
|
||||
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
||||
* document-wide, no geometry, no z-order. While a modal or a blocking
|
||||
* overlay is on screen anywhere, every drop is refused.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -69,6 +69,25 @@ export const stopAudioBridge = (sessionId: string) =>
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
/**
|
||||
* Save one container file to the host.
|
||||
*
|
||||
* The **backend** opens the save dialog, so this call cannot name a place on
|
||||
* the host — that is the point (see `pick_save_path` in `file_commands.rs`).
|
||||
* Paths do come *back* inside error text; what is closed is the inbound
|
||||
* direction.
|
||||
* Resolves to the number of bytes written, or `null` if the user dismissed the
|
||||
* dialog. Zero bytes is a success: an empty file is a file.
|
||||
*/
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string) =>
|
||||
invoke<number | null>("download_container_file", { projectId, containerPath });
|
||||
/**
|
||||
* Upload host files into `containerDir`, with the backend opening the file
|
||||
* picker. Resolves to `null` if the user dismissed it, otherwise to what
|
||||
* happened — one dialog can select several files and they need not all succeed.
|
||||
*/
|
||||
export const uploadFilesToContainer = (projectId: string, containerDir: string) =>
|
||||
invoke<UploadOutcome | null>("upload_files_to_container", { projectId, containerDir });
|
||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
||||
|
||||
@@ -365,6 +365,22 @@ export interface FileEntry {
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one upload dialog's worth of files did — mirrors `UploadOutcome` in
|
||||
* `commands/file_commands.rs`.
|
||||
*
|
||||
* Two lists rather than a count and a flag, because one dialog can select
|
||||
* several files and they do not have to agree: a folder among the selection, or
|
||||
* a file over the size ceiling, must not cost the user the ones either side of
|
||||
* it. Each `failures` entry is already a finished sentence naming its file.
|
||||
*/
|
||||
export interface UploadOutcome {
|
||||
/** In-container paths, in the order they landed. */
|
||||
uploaded: string[];
|
||||
/** One sentence per file that did not. */
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
/** A file read out of the container for the in-app viewer. */
|
||||
export interface FileContents {
|
||||
/** Base64 — a byte array would cross IPC as JSON numbers. */
|
||||
|
||||
Reference in New Issue
Block a user