(null);
+ const entryActionPendingRef = useRef(false);
+
+ const getRoot = useCallback(() => navigator.storage.getDirectory(), []);
+ const handleRootError = useCallback((error: unknown) => {
+ setRoot(null);
+ setEntries([]);
+ setLoadError(error instanceof Error ? error.message : String(error));
+ setLoading(false);
+ }, []);
useEffect(() => {
- void navigator.storage.getDirectory().then(setRoot);
- }, []);
+ void getRoot().then(setRoot).catch(handleRootError);
+ }, [getRoot, handleRootError]);
const load = useCallback(async () => {
if (!root) return;
setLoading(true);
+ setLoadError(null);
try {
setEntries(await listDir(root, path));
- } catch {
+ } catch (error) {
setEntries([]);
+ setLoadError(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
@@ -178,7 +191,9 @@ export default function AgentOPFS() {
};
const handleEntryDialogSubmit = async () => {
- if (!root || !entryDialog) return;
+ if (!root || !entryDialog || entryActionPendingRef.current) return;
+ entryActionPendingRef.current = true;
+ setEntryActionPending(true);
try {
if (entryDialog.action === "rename") {
await renameEntry(root, path, entryDialog.entry.name, entryDialog.value.trim());
@@ -191,6 +206,9 @@ export default function AgentOPFS() {
await load();
} catch (error) {
notify.error(error instanceof Error ? error.message : String(error));
+ } finally {
+ entryActionPendingRef.current = false;
+ setEntryActionPending(false);
}
};
@@ -348,7 +366,33 @@ export default function AgentOPFS() {
)}
- {!loading && entries.length === 0 ? (
+ {loading ? (
+
+
+ {t("common:loading")}
+
+ ) : loadError ? (
+
+ {`${t("common:error")}: ${loadError}`}
+
+
+ ) : entries.length === 0 ? (
) : isMobile ? (
@@ -359,6 +403,15 @@ export default function AgentOPFS() {
...(entry.kind === "file" && entry.size != null ? [formatSize(entry.size)] : []),
...(entry.lastModified ? [dayFormat(new Date(entry.lastModified), "MM-DD HH:mm")] : []),
].join(" · ");
+ const items = menuItems(entry, {
+ openEntry,
+ handleDownload,
+ handleDelete,
+ openRenameDialog,
+ openMoveDialog,
+ t,
+ editable,
+ });
return (
{sub}
-
+ {items.length > 0 &&
}
);
})}
@@ -479,7 +522,7 @@ export default function AgentOPFS() {
/>
)}
-
- {loading ? (
+ {loading && entries.length === 0 ? (
{entryDialog?.entry.name}
-
- setEntryDialog((current) => (current ? { ...current, value: event.target.value } : current))
- }
- onKeyDown={(event) => {
- if (event.key === "Enter") void handleEntryDialogSubmit();
- }}
- />
+ {entryDialog?.action === "move" ? (
+
+ ) : (
+
+ setEntryDialog((current) => (current ? { ...current, value: event.target.value } : current))
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter") void handleEntryDialogSubmit();
+ }}
+ />
+ )}
setEntryDialog(null)}>
{t("common:cancel")}
@@ -610,7 +653,7 @@ function menuItems(
handleDownload: (e: FileEntry) => void | Promise;
handleDelete: (e: FileEntry) => void | Promise;
openRenameDialog: (e: FileEntry) => void;
- openMoveDialog: (e: FileEntry) => void;
+ openMoveDialog: (e: FileEntry) => void | Promise;
t: (k: string) => string;
editable: boolean;
}
@@ -639,7 +682,7 @@ function menuItems(
key: "move",
label: t("agent:opfs_move"),
icon: FolderInput,
- onSelect: () => openMoveDialog(entry),
+ onSelect: () => void openMoveDialog(entry),
},
{
key: "delete",
@@ -668,7 +711,7 @@ function RowActions({
onDownload: (e: FileEntry) => void;
onDelete: (e: FileEntry) => void;
onRename: (e: FileEntry) => void;
- onMove: (e: FileEntry) => void;
+ onMove: (e: FileEntry) => void | Promise;
editable: boolean;
t: (k: string) => string;
}) {
@@ -714,7 +757,7 @@ function RowActions({
data-testid={`move-${entry.name}`}
title={t("agent:opfs_move")}
aria-label={t("agent:opfs_move")}
- onClick={() => onMove(entry)}
+ onClick={() => void onMove(entry)}
className="flex size-[30px] items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
diff --git a/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts b/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts
index 00f4ad8b8..f11f00ee4 100644
--- a/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts
+++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts
@@ -9,6 +9,7 @@ import {
isEditablePath,
renameEntry,
moveEntry,
+ listMoveDestinations,
} from "./opfs_fs";
// ---- 内存版 FileSystemDirectoryHandle mock ----
@@ -228,6 +229,54 @@ describe("opfs_fs 文件系统封装", () => {
expect(workspace._children.moved).toBeUndefined();
});
+ it("移动在目标创建之前失败时保留原始错误", async () => {
+ const workspace = mutableDirectory("workspace", {
+ "broken.txt": {
+ kind: "file",
+ name: "broken.txt",
+ async getFile() {
+ throw new Error("read failure");
+ },
+ },
+ target: mutableDirectory("target"),
+ });
+ const root = mutableDirectory("root", {
+ agents: mutableDirectory("agents", { workspace }),
+ });
+
+ await expect(
+ moveEntry(root, ["agents", "workspace"], "broken.txt", ["agents", "workspace", "target"])
+ ).rejects.toThrow("read failure");
+ expect(workspace._children["broken.txt"]).toBeDefined();
+ expect(workspace._children.target._children["broken.txt"]).toBeUndefined();
+ });
+
+ it("列出移动目标时排除当前目录、条目自身及其子目录", async () => {
+ const workspace = mutableDirectory("workspace", {
+ src: mutableDirectory("src", { child: mutableDirectory("child"), "a.txt": mutableFile("a.txt", "") }),
+ target: mutableDirectory("target"),
+ });
+ const root = mutableDirectory("root", {
+ agents: mutableDirectory("agents", { workspace }),
+ });
+
+ const forDirectory = await listMoveDestinations(root, ["agents", "workspace"], {
+ name: "src",
+ kind: "directory",
+ });
+ expect(forDirectory.map((p) => p.join("/"))).toEqual(["agents/workspace/target"]);
+
+ const forFile = await listMoveDestinations(root, ["agents", "workspace", "src"], {
+ name: "a.txt",
+ kind: "file",
+ });
+ expect(forFile.map((p) => p.join("/"))).toEqual([
+ "agents/workspace",
+ "agents/workspace/src/child",
+ "agents/workspace/target",
+ ]);
+ });
+
it("formatSize 按量级格式化", () => {
expect(formatSize(500)).toBe("500 B");
expect(formatSize(2048)).toBe("2.0 KB");
diff --git a/src/pages/options/routes/Agent/OPFS/opfs_fs.ts b/src/pages/options/routes/Agent/OPFS/opfs_fs.ts
index 83ed9c601..1015a742c 100644
--- a/src/pages/options/routes/Agent/OPFS/opfs_fs.ts
+++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.ts
@@ -170,8 +170,8 @@ async function copyEntry(
const sourceDirectory = await sourceDir.getDirectoryHandle(sourceName);
const destinationDirectory = await destinationDir.getDirectoryHandle(destinationName, { create: true });
- for await (const [name, handle] of sourceDirectory as unknown as AsyncIterable<[string, FileSystemHandle]>) {
- await copyEntry(sourceDirectory, name, destinationDirectory, handle.name || name);
+ for await (const [name] of sourceDirectory as unknown as AsyncIterable<[string, FileSystemHandle]>) {
+ await copyEntry(sourceDirectory, name, destinationDirectory, name);
}
}
@@ -218,8 +218,38 @@ export async function moveEntry(
try {
await destinationDir.removeEntry(destinationName, { recursive: true });
} catch (cleanupError) {
- throw new AggregateError([error, cleanupError], "Failed to roll back a move");
+ // 复制可能在创建目标之前就失败(例如源文件不可读),此时目标不存在不算回滚失败,
+ // 不能用它掩盖真正的错误原因。
+ if ((cleanupError as { name?: string })?.name !== "NotFoundError") {
+ throw new AggregateError([error, cleanupError], "Failed to roll back a move");
+ }
}
throw error;
}
}
+
+/** 列出 workspace 下可作为移动目标的目录:排除条目所在目录、条目自身及其子目录 */
+export async function listMoveDestinations(
+ root: FileSystemDirectoryHandle,
+ sourcePath: string[],
+ entry: Pick
+): Promise {
+ const excludedSubtree = entry.kind === "directory" ? [...sourcePath, entry.name].join("/") : null;
+ const destinations: string[][] = [];
+
+ const walk = async (dir: FileSystemDirectoryHandle, path: string[]) => {
+ const joined = path.join("/");
+ if (excludedSubtree && (joined === excludedSubtree || joined.startsWith(`${excludedSubtree}/`))) return;
+ if (joined !== sourcePath.join("/")) destinations.push(path);
+ const children: [string, FileSystemDirectoryHandle][] = [];
+ for await (const [name, handle] of dir as unknown as AsyncIterable<[string, FileSystemHandle]>) {
+ if (handle.kind === "directory") children.push([name, handle as FileSystemDirectoryHandle]);
+ }
+ children.sort((a, b) => a[0].localeCompare(b[0]));
+ for (const [name, handle] of children) await walk(handle, [...path, name]);
+ };
+
+ const workspacePath = [...WORKSPACE_PATH];
+ await walk(await getDirHandle(root, workspacePath), workspacePath);
+ return destinations;
+}