Fix HIGH and MEDIUM frontend defects
Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../../../lib/types";
|
||||
import { readContainerFile } from "../../../lib/tauri-commands";
|
||||
import Button from "../../ui/Button";
|
||||
@@ -40,11 +40,28 @@ type Preview =
|
||||
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
|
||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
||||
|
||||
/**
|
||||
* The object URL currently on screen.
|
||||
*
|
||||
* This used to be an effect-local variable revoked from the effect's own
|
||||
* cleanup, which runs *before* the replacement effect body — so switching
|
||||
* entries (or any re-run of the effect for the same entry) released the URL
|
||||
* the `<img>` was still pointing at, and a blank image was the result until
|
||||
* the new read landed. If the new read failed, it stayed blank. So the
|
||||
* hand-over is explicit instead: a URL is revoked only once its replacement
|
||||
* exists, and unmount is what releases the last one.
|
||||
*/
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
/** Release the previous URL now that something else is on screen. */
|
||||
const replaceObjectUrl = (next: string | null) => {
|
||||
const previous = objectUrlRef.current;
|
||||
objectUrlRef.current = next;
|
||||
if (previous && previous !== next) URL.revokeObjectURL(previous);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Tracked separately from `preview` so cleanup can revoke it without
|
||||
// depending on which state the component ended up in.
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -58,16 +75,20 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
// A truncated image is not a smaller image, it is a broken one.
|
||||
if (result.truncated) {
|
||||
setPreview({ kind: "too-large" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setPreview({ kind: "image", url: objectUrl });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// The replacement is in hand, so the previous one can go.
|
||||
setPreview({ kind: "image", url });
|
||||
replaceObjectUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksBinary(bytes)) {
|
||||
setPreview({ kind: "unsupported" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,6 +99,7 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
shownBytes: bytes.length,
|
||||
trueSize: result.size,
|
||||
});
|
||||
replaceObjectUrl(null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setPreview({ kind: "error", message: String(e) });
|
||||
}
|
||||
@@ -85,10 +107,19 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [projectId, entry.name, entry.path]);
|
||||
|
||||
// The bytes are released when the dialog goes, which is the whole reason the
|
||||
// preview is a `blob:` URL rather than a `data:` one.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
@@ -143,7 +174,18 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
||||
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
|
||||
</p>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]">
|
||||
{/* Focusable, and its own scroll container, because a megabyte of
|
||||
text in an unfocusable `<pre>` is reachable by mouse wheel and by
|
||||
nothing else — no PageDown, no arrows, no keyboard at all. A
|
||||
scrollable region needs an accessible name to be worth landing
|
||||
on, hence the role and label. No `focus:outline-none`: the global
|
||||
`:focus-visible` ring is what says where the caret went. */}
|
||||
<pre
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={`${entry.name} contents`}
|
||||
className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]"
|
||||
>
|
||||
{preview.text}
|
||||
</pre>
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,7 @@ const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/n
|
||||
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),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -22,12 +22,29 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||
}));
|
||||
|
||||
/** The OS-level drag. Nothing in jsdom can start one, so it is only observed. */
|
||||
const startDrag = vi.fn(async () => {});
|
||||
/**
|
||||
* 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) => startDrag(opts),
|
||||
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../../../store/appState", () => ({
|
||||
useAppState: { getState: () => ({ pushToast }) },
|
||||
}));
|
||||
|
||||
const toastText = () =>
|
||||
pushToast.mock.calls
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const save = vi.fn(async () => "/host/out");
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: (o: unknown) => save(o),
|
||||
@@ -107,6 +124,32 @@ function dragRow(el: Element) {
|
||||
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. */
|
||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||
|
||||
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
|
||||
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
||||
let pending: unknown;
|
||||
act(() => {
|
||||
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
|
||||
});
|
||||
return pending as Promise<void> | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dragHandler = null;
|
||||
@@ -184,7 +227,9 @@ describe("FilesTab open semantics", () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
expect(row.getAttribute("tabindex")).toBe("0");
|
||||
// Not a tab stop — `..` holds the grid's single one until the arrows move
|
||||
// it — but still focusable and still openable from the keyboard.
|
||||
expect(row.getAttribute("tabindex")).toBe("-1");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
@@ -261,7 +306,7 @@ describe("FilesTab viewer", () => {
|
||||
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" }));
|
||||
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 () => {
|
||||
@@ -273,7 +318,7 @@ describe("FilesTab rename", () => {
|
||||
|
||||
it("abandons the rename on Escape", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
|
||||
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 () => {
|
||||
@@ -290,16 +335,22 @@ describe("FilesTab rename", () => {
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows what the container said when a rename is refused", async () => {
|
||||
it("reports a refused rename where it can be seen, not three hundred rows down", async () => {
|
||||
// The inline `error` div is the first child of the *scrolling* list, so
|
||||
// deep in a directory this used to be a rename box that stayed open and
|
||||
// said nothing. `ToastHost` is fixed, above the modal layer, and persists.
|
||||
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
|
||||
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");
|
||||
expect(toastText()).toContain("Permission denied");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
// The editor stays open, because the rename did not happen.
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,14 +424,14 @@ 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" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
|
||||
});
|
||||
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();
|
||||
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +446,9 @@ describe("FilesTab drag-out", () => {
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -428,7 +482,7 @@ describe("FilesTab drag-out", () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("too large"));
|
||||
await waitFor(() => expect(toastText()).toContain("too large"));
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -437,7 +491,7 @@ describe("FilesTab drag-out", () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Save to host"));
|
||||
await waitFor(() => expect(toastText()).toContain("Save to host"));
|
||||
});
|
||||
|
||||
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
|
||||
@@ -483,7 +537,7 @@ describe("FilesTab drag-out", () => {
|
||||
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 notes.txt to host"));
|
||||
fireEvent.click(screen.getByLabelText("Save to host… — notes.txt"));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
@@ -495,8 +549,278 @@ describe("FilesTab drag-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`
|
||||
// portal, which is exactly why a rect alone was the wrong test.
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not paint the hint under a dialog either", async () => {
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid focus", () => {
|
||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||
// stops and Tab could not get out of the list.
|
||||
await renderTab();
|
||||
expect(gridRows()).toHaveLength(3); // .. , src, notes.txt
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "ArrowDown" });
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("src");
|
||||
expect(document.activeElement).toBe(tabStops()[0]);
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "End" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("notes.txt");
|
||||
fireEvent.keyDown(tabStops()[0], { key: "Home" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
});
|
||||
|
||||
it("keeps focus inside the grid after Enter opens a directory", async () => {
|
||||
// Rows are keyed by name, so navigating unmounts the focused `<tr>` — and
|
||||
// nothing used to re-focus, which ejected the user to `<body>`.
|
||||
await renderTab();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
row.focus();
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("index.ts", { path: "/workspace/src/index.ts" }),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect((document.activeElement as HTMLElement).closest("tr[data-file-row]")).toBeTruthy();
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts focus back on the row after a rename is abandoned", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
fireEvent.keyDown(row, { key: "F2" });
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "notes.txt"),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows a committed rename to the row's new name", async () => {
|
||||
// Explicit, because `clearAllMocks` clears calls but not implementations,
|
||||
// and an earlier test in this file leaves this one rejecting.
|
||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||
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: "renamed.txt" } });
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("renamed.txt"),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "renamed.txt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid semantics", () => {
|
||||
it("names its columns", async () => {
|
||||
await renderTab();
|
||||
for (const name of ["Name", "Size", "Modified", "Actions"]) {
|
||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("says folder or file in words, not in hue and a hidden emoji", async () => {
|
||||
await renderTab();
|
||||
const dir = screen.getByText("src").closest("tr")!;
|
||||
const plain = screen.getByText("notes.txt").closest("tr")!;
|
||||
expect(dir.textContent).toContain("Folder");
|
||||
expect(plain.textContent).toContain("File");
|
||||
});
|
||||
|
||||
it("keeps the visible label inside the accessible name (WCAG 2.5.3)", async () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain("Rename");
|
||||
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
|
||||
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// A `role="status"` node inserted already carrying its text is frequently
|
||||
// not announced at all, which is how every one of these went by in silence.
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
expect(live.textContent).toBe("");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
// Same node throughout — it is never unmounted.
|
||||
expect(screen.getByRole("status")).toBe(live);
|
||||
expect(live.textContent).toContain("Uploaded 1 item");
|
||||
});
|
||||
|
||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||
// The one failure that does *not* go to the toast host: it is on screen,
|
||||
// in context, and there is nothing for it to scroll behind.
|
||||
listContainerFiles.mockRejectedValue("Permission denied");
|
||||
await renderTab();
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab overwrite prompt", () => {
|
||||
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog.textContent).toContain("notes.txt");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
|
||||
"p1",
|
||||
"/host/notes.txt",
|
||||
"/workspace",
|
||||
true,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads nothing more on Skip", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the blanket answers only when files are queued behind this one", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
|
||||
await pending;
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 { isDropTarget } from "../../../lib/dropTarget";
|
||||
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";
|
||||
|
||||
@@ -18,6 +21,22 @@ interface Props {
|
||||
*/
|
||||
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 = "..";
|
||||
|
||||
/**
|
||||
* The project's file manager.
|
||||
*
|
||||
@@ -26,6 +45,17 @@ const DRAG_THRESHOLD = 4;
|
||||
* moved directory navigation onto double click too — a single click used to
|
||||
* navigate, which made it impossible to select a directory in order to rename
|
||||
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
|
||||
*
|
||||
* ## Focus, and why it is a roving tabindex
|
||||
*
|
||||
* Every row used to be `tabIndex={0}`, which made a 400-entry directory about
|
||||
* twelve hundred tab stops — Tab could not get *out* of the list, let alone
|
||||
* past it — and rows are keyed by name, so navigating unmounted the focused
|
||||
* `<tr>` and dropped focus to `<body>`: Enter on a directory ejected you from
|
||||
* the grid, arrows dead, Tab restarting from the top of the document. So
|
||||
* exactly one row carries `tabIndex={0}` (the *active* row), the arrows move
|
||||
* it, and a single effect below is responsible for putting focus back on a
|
||||
* sensible row after anything that re-renders the list.
|
||||
*/
|
||||
export default function FilesTab({ project }: Props) {
|
||||
const {
|
||||
@@ -34,6 +64,9 @@ export default function FilesTab({ project }: Props) {
|
||||
loading,
|
||||
error,
|
||||
busy,
|
||||
completed,
|
||||
conflict,
|
||||
resolveConflict,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
@@ -41,9 +74,9 @@ export default function FilesTab({ project }: Props) {
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
stageForDrag,
|
||||
isStagedHostPath,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
setError,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
@@ -59,6 +92,8 @@ export default function FilesTab({ project }: Props) {
|
||||
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);
|
||||
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -87,14 +122,106 @@ export default function FilesTab({ project }: Props) {
|
||||
if (creatingFolder) folderInputRef.current?.focus();
|
||||
}, [creatingFolder]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roving tabindex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every row's key, in visual order. The parent row is a row like any other. */
|
||||
const rowKeys = useMemo(
|
||||
() => [
|
||||
...(currentPath !== "/" ? [PARENT_ROW] : []),
|
||||
...entries.map((entry) => entry.name),
|
||||
],
|
||||
[currentPath, entries],
|
||||
);
|
||||
|
||||
/**
|
||||
* The active row, resolved against what is actually on screen. Keeping the
|
||||
* *intent* in state and resolving it at render time means a rename or a
|
||||
* deletion cannot leave the grid with no tab stop at all.
|
||||
*/
|
||||
const active = activeRow && rowKeys.includes(activeRow) ? activeRow : rowKeys[0];
|
||||
|
||||
const rowElement = useCallback((key: string): HTMLElement | undefined => {
|
||||
// Matched on the dataset rather than a selector, because a file name is
|
||||
// user data and can contain quotes, brackets and backslashes.
|
||||
const rows = paneRef.current?.querySelectorAll<HTMLElement>("tr[data-file-row]") ?? [];
|
||||
return Array.from(rows).find((row) => row.dataset.fileRow === key);
|
||||
}, []);
|
||||
|
||||
const focusRow = useCallback(
|
||||
(key: string) => {
|
||||
setActiveRow(key);
|
||||
rowElement(key)?.focus();
|
||||
},
|
||||
[rowElement],
|
||||
);
|
||||
|
||||
/**
|
||||
* Where focus should land the next time the grid re-renders, if it is loose.
|
||||
* `key` is a preference, not a promise — the row may not exist any more (a
|
||||
* rename that failed, a navigation into a different directory), in which case
|
||||
* the first row takes it.
|
||||
*/
|
||||
const wantFocus = useRef<{ key: string | null } | null>(null);
|
||||
|
||||
/**
|
||||
* The single place that decides where focus goes after the list changes.
|
||||
*
|
||||
* Runs after a navigation (rows are keyed by name, so the focused `<tr>` is
|
||||
* gone), after a rename commits or is abandoned, and after Escape. It never
|
||||
* *steals* focus: if the user has moved on to a button or the breadcrumb it
|
||||
* drops the request instead, so a background re-list cannot yank the caret
|
||||
* out from under them.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (renaming !== null) return; // the rename input owns focus
|
||||
const want = wantFocus.current;
|
||||
if (!want) return;
|
||||
wantFocus.current = null;
|
||||
|
||||
const focused = document.activeElement as HTMLElement | null;
|
||||
const loose =
|
||||
!focused ||
|
||||
focused === document.body ||
|
||||
focused === document.documentElement ||
|
||||
!!focused.closest?.("tr[data-file-row]");
|
||||
if (!loose) return;
|
||||
|
||||
const key = want.key && rowKeys.includes(want.key) ? want.key : rowKeys[0];
|
||||
if (key !== undefined) focusRow(key);
|
||||
}, [rowKeys, renaming, focusRow]);
|
||||
|
||||
/** Arrow / Home / End movement over the rows. */
|
||||
const moveActive = useCallback(
|
||||
(from: string, to: 1 | -1 | "first" | "last") => {
|
||||
if (rowKeys.length === 0) return;
|
||||
const i = rowKeys.indexOf(from);
|
||||
const next =
|
||||
to === "first"
|
||||
? 0
|
||||
: to === "last"
|
||||
? rowKeys.length - 1
|
||||
: Math.min(rowKeys.length - 1, Math.max(0, (i < 0 ? 0 : i) + to));
|
||||
focusRow(rowKeys[next]);
|
||||
},
|
||||
[rowKeys, focusRow],
|
||||
);
|
||||
|
||||
const startRename = useCallback((entry: FileEntry) => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
setRenameDraft(entry.name);
|
||||
setRenaming(entry.name);
|
||||
// Whichever way the rename ends, focus comes back to this row unless the
|
||||
// commit renames it — `commitRename` overwrites the preference below.
|
||||
wantFocus.current = { key: entry.name };
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
const renamedTo = renameDraft.trim();
|
||||
wantFocus.current = { key: renamedTo || entry.name };
|
||||
const done = await renameEntry(entry, renameDraft);
|
||||
if (done) setRenaming(null);
|
||||
},
|
||||
@@ -102,36 +229,37 @@ export default function FilesTab({ project }: Props) {
|
||||
);
|
||||
|
||||
const commitFolder = useCallback(async () => {
|
||||
const created = folderDraft.trim();
|
||||
const done = await createFolder(folderDraft);
|
||||
if (done) {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
wantFocus.current = { key: created || null };
|
||||
}
|
||||
}, [createFolder, folderDraft]);
|
||||
|
||||
/**
|
||||
* Arrow keys walk the rows. `aria-selected` is only meaningful on a row
|
||||
* inside a `grid`, and a grid is expected to be arrow-navigable — so the
|
||||
* roles below and this handler come as a pair.
|
||||
*/
|
||||
const moveFocus = useCallback((from: HTMLElement, delta: 1 | -1) => {
|
||||
const rows = Array.from(
|
||||
paneRef.current?.querySelectorAll<HTMLElement>('tr[tabindex="0"]') ?? [],
|
||||
);
|
||||
const i = rows.indexOf(from);
|
||||
const next = rows[i + delta];
|
||||
next?.focus();
|
||||
}, []);
|
||||
|
||||
/** Double click / Enter: directories navigate, files open the viewer. */
|
||||
const openEntry = useCallback(
|
||||
(entry: FileEntry) => {
|
||||
if (entry.is_directory) navigate(entry.path);
|
||||
else setViewing(entry);
|
||||
if (entry.is_directory) {
|
||||
// The new listing's first row is `..`, which is the sensible landing
|
||||
// place: it is where you go to undo the step you just took.
|
||||
wantFocus.current = { key: null };
|
||||
navigate(entry.path);
|
||||
} else {
|
||||
setViewing(entry);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const openParent = useCallback(() => {
|
||||
// Coming back up, the directory just left is the interesting row.
|
||||
const leaving = currentPath.split("/").filter(Boolean).pop() ?? null;
|
||||
wantFocus.current = { key: leaving };
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
// Container → host drag-out.
|
||||
//
|
||||
// The mirror image of the drop path below, and it has the same constraint
|
||||
@@ -153,6 +281,38 @@ export default function FilesTab({ project }: Props) {
|
||||
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
|
||||
@@ -175,7 +335,7 @@ export default function FilesTab({ project }: Props) {
|
||||
async (entry: FileEntry) => {
|
||||
setDragNotice(null);
|
||||
const staged = await stageForDrag(entry);
|
||||
// `stageForDrag` has already put the reason in `error`.
|
||||
// `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
|
||||
@@ -188,15 +348,24 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragOutInFlight.current = true;
|
||||
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
|
||||
try {
|
||||
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) });
|
||||
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.
|
||||
setError(`Could not start the drag: ${e}. Use "Save to host…" instead.`);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: 'Could not start the drag — use "Save to host…" instead.',
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[stageForDrag, setError],
|
||||
[stageForDrag, endDragOut],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -244,23 +413,17 @@ export default function FilesTab({ project }: Props) {
|
||||
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
||||
// native payload carries real file *paths*. The listener is window-wide, so
|
||||
// routing is a hit-test of the physical-pixel payload position against this
|
||||
// pane's rect — a hidden pane has a zero-size rect and never matches, which
|
||||
// is what keeps this and the terminal's listener from both firing.
|
||||
// routing is `isDropTarget` — the rect hit test, in CSS pixels, *plus* the
|
||||
// z-order and "is anything modal on screen" questions a rect cannot answer.
|
||||
//
|
||||
// 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;
|
||||
let cancelled = false;
|
||||
|
||||
const insideThisPane = (pos: { x: number; y: number }): boolean => {
|
||||
const rect = paneRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return false;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const x = pos.x / dpr;
|
||||
const y = pos.y / dpr;
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
const payload = event.payload;
|
||||
@@ -269,13 +432,19 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
setDragOver(insideThisPane(payload.position));
|
||||
setDragOver(
|
||||
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
if (!insideThisPane(payload.position)) return;
|
||||
const paths = payload.paths ?? [];
|
||||
if (dragOutInFlight.current) return;
|
||||
if (!isDropTarget(paneRef.current, payload.position)) 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));
|
||||
if (paths.length === 0) return;
|
||||
await uploadPaths(paths);
|
||||
});
|
||||
@@ -287,7 +456,7 @@ export default function FilesTab({ project }: Props) {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [running, uploadPaths]);
|
||||
}, [running, uploadPaths, isStagedHostPath]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
@@ -322,6 +491,20 @@ export default function FilesTab({ project }: Props) {
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`;
|
||||
|
||||
const headerClass = "px-2 py-1.5 font-medium text-[var(--text-secondary)]";
|
||||
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* 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 ?? "");
|
||||
|
||||
return (
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
@@ -331,7 +514,10 @@ export default function FilesTab({ project }: Props) {
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
onClick={() => {
|
||||
wantFocus.current = { key: null };
|
||||
navigate(crumb.path);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||
>
|
||||
{crumb.label}
|
||||
@@ -340,16 +526,9 @@ export default function FilesTab({ project }: Props) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex-1" />
|
||||
{busy && (
|
||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{busy}
|
||||
</span>
|
||||
)}
|
||||
{!busy && dragNotice && (
|
||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
"{dragNotice}" is ready — drag it again to drop it on the desktop.
|
||||
</span>
|
||||
)}
|
||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{liveText}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFolderDraft("");
|
||||
@@ -367,6 +546,11 @@ export default function FilesTab({ project }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* 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
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -379,9 +563,25 @@ export default function FilesTab({ project }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<table role="grid" aria-label="Files" className="w-full text-xs">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col" className={`${headerClass} px-4 text-left`}>
|
||||
Name
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Size
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-left`}>
|
||||
Modified
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creatingFolder && (
|
||||
<tr>
|
||||
<tr role="row">
|
||||
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
@@ -404,21 +604,28 @@ export default function FilesTab({ project }: Props) {
|
||||
)}
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
tabIndex={0}
|
||||
role="row"
|
||||
data-file-row={PARENT_ROW}
|
||||
tabIndex={active === PARENT_ROW ? 0 : -1}
|
||||
aria-label="Parent directory"
|
||||
onDoubleClick={goUp}
|
||||
onClick={() => setActiveRow(PARENT_ROW)}
|
||||
onDoubleClick={openParent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
openParent();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
|
||||
moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
|
||||
<span className="sr-only">Folder, </span>
|
||||
..
|
||||
</td>
|
||||
<td role="gridcell" colSpan={3} />
|
||||
@@ -430,9 +637,14 @@ export default function FilesTab({ project }: Props) {
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
tabIndex={0}
|
||||
role="row"
|
||||
data-file-row={entry.name}
|
||||
tabIndex={active === entry.name ? 0 : -1}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setSelected(entry.name)}
|
||||
onClick={() => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
}}
|
||||
onDoubleClick={() => openEntry(entry)}
|
||||
{...dragOutProps(entry)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -440,13 +652,17 @@ export default function FilesTab({ project }: Props) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
openEntry(entry);
|
||||
} else if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
startRename(entry);
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
|
||||
moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(entry.name, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className={rowClass(isSelected)}
|
||||
@@ -476,6 +692,14 @@ export default function FilesTab({ project }: Props) {
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{/* Directory-ness was carried by hue and an
|
||||
`aria-hidden` emoji, i.e. by nothing at all for a
|
||||
screen reader. The emoji stays hidden — it reads
|
||||
as "file folder" in some voices and as nothing in
|
||||
others — and the word is what is announced. */}
|
||||
<span className="sr-only">
|
||||
{entry.is_directory ? "Folder, " : "File, "}
|
||||
</span>
|
||||
{entry.is_directory && <span aria-hidden="true">📁 </span>}
|
||||
<span>{entry.name}</span>
|
||||
{entry.is_symlink && (
|
||||
@@ -498,8 +722,14 @@ export default function FilesTab({ project }: Props) {
|
||||
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
|
||||
{!isRenaming && (
|
||||
<>
|
||||
{/* WCAG 2.5.3: the accessible name has to *contain*
|
||||
the visible label, so the row context is appended
|
||||
rather than substituted. "Rename notes.txt" used
|
||||
to be the whole name, which left a voice-control
|
||||
user saying "click Rename" at a button that had
|
||||
no such name. */}
|
||||
<Button
|
||||
aria-label={`Rename ${entry.name}`}
|
||||
aria-label={`Rename — ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(entry);
|
||||
@@ -509,7 +739,7 @@ export default function FilesTab({ project }: Props) {
|
||||
</Button>
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save ${entry.name} to host`}
|
||||
aria-label={`Save to host… — ${entry.name}`}
|
||||
className="ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -526,7 +756,7 @@ export default function FilesTab({ project }: Props) {
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<tr role="row">
|
||||
<td
|
||||
role="gridcell"
|
||||
colSpan={4}
|
||||
@@ -554,6 +784,15 @@ export default function FilesTab({ project }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<OverwriteConfirmModal
|
||||
name={conflict.name}
|
||||
directory={conflict.directory}
|
||||
remaining={conflict.remaining}
|
||||
onChoose={resolveConflict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { OverwriteChoice } from "../../../lib/uploadErrors";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
|
||||
interface Props {
|
||||
/** Bare name of the file that is already there. */
|
||||
name: string;
|
||||
/** Container directory it is going into. */
|
||||
directory: string;
|
||||
/** How many more files are queued behind this one. */
|
||||
remaining: number;
|
||||
onChoose: (choice: OverwriteChoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That name is taken — replace it?"
|
||||
*
|
||||
* This exists because the backend stopped overwriting silently, and a raw
|
||||
* error string would have been a worse answer than the old silent clobber: it
|
||||
* tells the user their drop failed without telling them it *can* succeed. The
|
||||
* dialog names the file and the directory, because a drop is aimed with a
|
||||
* mouse and "notes.txt" alone does not say which `notes.txt`.
|
||||
*
|
||||
* The blanket answers only appear when there is something to apply them to — a
|
||||
* single-file drop with "Replace all" on it invites the reflex of clicking the
|
||||
* widest button for no benefit.
|
||||
*
|
||||
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
|
||||
* destructive answer has to be chosen explicitly.
|
||||
*/
|
||||
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
|
||||
const footer = (
|
||||
<>
|
||||
{remaining > 0 && (
|
||||
<>
|
||||
<Button size="md" onClick={() => onChoose("skip-all")}>
|
||||
Skip all
|
||||
</Button>
|
||||
<Button size="md" onClick={() => onChoose("replace-all")}>
|
||||
Replace all
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="md" onClick={() => onChoose("skip")}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
|
||||
Replace
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="A file with that name is already there"
|
||||
description={directory}
|
||||
onClose={() => onChoose("skip")}
|
||||
footer={footer}
|
||||
widthClassName="w-[30rem]"
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">{name}</span> already exists in{" "}
|
||||
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
|
||||
copy, and that cannot be undone from here.
|
||||
</p>
|
||||
{remaining > 0 && (
|
||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">
|
||||
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,80 @@ describe("AuthBridgeRow", () => {
|
||||
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* The two halves of this row disagree about *when*, not about *what*.
|
||||
*
|
||||
* `set_auth_bridge_enabled` resolves with a status sampled as it returned;
|
||||
* the poller's event carries one sampled afterwards. Writing the awaited
|
||||
* value unconditionally therefore rolls the row back in time whenever the
|
||||
* two overlap — the row says "Watching" while a port is bound, which is the
|
||||
* exact silent failure the event subscription was added to end. These two
|
||||
* hold the ordering down from both the resolve and the reject side.
|
||||
*/
|
||||
describe("a pushed event outranks an older awaited result", () => {
|
||||
/** A toggle that will not settle until the test says so. */
|
||||
function deferToggle() {
|
||||
let settle!: (s: AuthBridgeStatus) => void;
|
||||
let fail!: (e: unknown) => void;
|
||||
setAuthBridgeEnabled.mockImplementation(
|
||||
() =>
|
||||
new Promise<AuthBridgeStatus>((resolve, reject) => {
|
||||
settle = resolve;
|
||||
fail = reject;
|
||||
}),
|
||||
);
|
||||
return { settle: (s: AuthBridgeStatus) => settle(s), fail: (e: unknown) => fail(e) };
|
||||
}
|
||||
|
||||
const BRIDGING: AuthBridgeStatus = {
|
||||
enabled: true,
|
||||
active_ports: [{ port: 54545, family: "v4", bridged_at: "", ipv6_warning: null }],
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
async function startToggleThenPush() {
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
|
||||
|
||||
// The poller binds a port while the command is still in flight.
|
||||
emit!({ project_id: "p1", status: BRIDGING });
|
||||
expect(await screen.findByText("Bridging 1 port")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
it("keeps the newer state when the command settles with the older one", async () => {
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
// …and only now returns the snapshot it took *before* that port existed.
|
||||
toggle.settle({ enabled: true, active_ports: [], conflicts: [] });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(),
|
||||
);
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByText("127.0.0.1:54545")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Watching")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let the rollback undo a status pushed while it was failing", async () => {
|
||||
// The command failed, so the error belongs on screen — but the bridge
|
||||
// demonstrably came up, and reverting the switch to off would contradict
|
||||
// the port listed right beside it.
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
toggle.fail("bridge probe timed out");
|
||||
|
||||
expect(await screen.findByText(/probe timed out/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the switch back if the command rejects", async () => {
|
||||
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
getAuthBridgeStatus,
|
||||
@@ -77,13 +77,35 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Which write to `status` is the newest — the same "is this still mine?"
|
||||
* guard `useDiskUsage` and `useContainerMigration` use around their 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
|
||||
* ordered. `set_auth_bridge_enabled` resolves with a status *sampled at the
|
||||
* moment it returned*; the poller's `auth-bridge-changed` event carries one
|
||||
* sampled later. Awaiting the command therefore hands back a value that may
|
||||
* already be historical, and writing it unconditionally is how the row ends
|
||||
* up saying "Watching" while a port is in fact bound — the failure mode the
|
||||
* event subscription exists to prevent, reintroduced one line below it.
|
||||
*
|
||||
* So every write claims a generation and only lands if it still holds it.
|
||||
* A pushed event always claims a fresh one, which is what makes it win over
|
||||
* an older awaited result no matter which order the two arrive in.
|
||||
*/
|
||||
const generation = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++generation.current;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
getAuthBridgeStatus(projectId)
|
||||
.then((s) => {
|
||||
if (!cancelled) setStatus(s);
|
||||
// The initial fetch races the poller exactly like the toggle does: an
|
||||
// event can land first and describe a bridge this reply predates.
|
||||
if (!cancelled && generation.current === mine) setStatus(s);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
@@ -98,6 +120,9 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||
if (event.payload.project_id !== projectId) return;
|
||||
// A pushed status is the most recent observation that exists, so it
|
||||
// claims the newest generation and invalidates anything still in flight.
|
||||
generation.current += 1;
|
||||
setStatus(event.payload.status);
|
||||
})
|
||||
.then((un) => {
|
||||
@@ -116,13 +141,24 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Optimistic, so the switch responds even though enabling has to await a
|
||||
// container probe. The command's return value replaces it either way.
|
||||
// container probe. It claims a generation like every other write, so a
|
||||
// pushed event that lands mid-flight supersedes it rather than being
|
||||
// undone by the settle below.
|
||||
const mine = ++generation.current;
|
||||
setStatus((s) => (s ? { ...s, enabled: next } : s));
|
||||
try {
|
||||
setStatus(await setAuthBridgeEnabled(projectId, next));
|
||||
const settled = await setAuthBridgeEnabled(projectId, next);
|
||||
// Stale by the time it arrived: the poller has already told us
|
||||
// something newer, and `settled` predates it.
|
||||
if (generation.current !== mine) return;
|
||||
setStatus(settled);
|
||||
} catch (e) {
|
||||
setStatus((s) => (s ? { ...s, enabled: !next } : s));
|
||||
// The error is reported either way — the command really did fail — but
|
||||
// the rollback must not resurrect the pre-toggle value over a status
|
||||
// the poller pushed while the command was failing.
|
||||
setError(String(e));
|
||||
if (generation.current !== mine) return;
|
||||
setStatus((s) => (s ? { ...s, enabled: !next } : s));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,58 @@ describe("RuntimeSection — VPN support toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `scope="project"` on the settings editor is one prop with no visible owner,
|
||||
* and deleting it fails silently in the worst possible direction: the editor
|
||||
* falls back to `"global"`, every three-state control collapses to an on/off
|
||||
* switch, and a field the project is *inheriting* as on renders flat Off. The
|
||||
* user then reads a lie and, worse, flipping that switch writes a deliberate
|
||||
* `false` that overrides the global On they thought they were looking at.
|
||||
*
|
||||
* Nothing asserted the prop was passed, so these go through what is rendered
|
||||
* rather than through props — a switch where a select belongs is exactly the
|
||||
* regression, and it is visible from the outside.
|
||||
*/
|
||||
describe("RuntimeSection — Claude Code settings are edited at project scope", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("gives every setting the third Global state a project can inherit through", () => {
|
||||
renderSection();
|
||||
const focus = screen.getByLabelText("Focus mode") as HTMLSelectElement;
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
});
|
||||
|
||||
it("renders an untouched setting as inheriting, not as Off", () => {
|
||||
// `claude_code_settings: null` means "this project has no opinion", which
|
||||
// is not the same instruction as off. At global scope the same field is a
|
||||
// plain unchecked switch — indistinguishable from a user who turned it
|
||||
// off, and the reason the missing prop would never be noticed.
|
||||
renderSection({ claude_code_settings: null });
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe(
|
||||
"global",
|
||||
);
|
||||
expect(screen.queryByRole("switch", { name: "Focus mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stored project override visible over the inherited value", () => {
|
||||
renderSection({
|
||||
claude_code_settings: {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: true,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
},
|
||||
});
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeSection — auth bridge toggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user