From 2f2f55faf5690afbb9dd6acce1500f9bf00338d3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:00:02 +0900 Subject: [PATCH 1/4] fix agent OPFS permissions --- src/app/service/agent/core/opfs_helpers.ts | 10 +- src/locales/de-DE/agent.json | 7 + src/locales/en-US/agent.json | 7 + src/locales/ja-JP/agent.json | 7 + src/locales/ru-RU/agent.json | 7 + src/locales/tr-TR/agent.json | 7 + src/locales/vi-VN/agent.json | 7 + src/locales/zh-CN/agent.json | 7 + src/locales/zh-TW/agent.json | 7 + .../options/routes/Agent/OPFS/index.test.tsx | 32 ++- src/pages/options/routes/Agent/OPFS/index.tsx | 238 +++++++++++++++--- .../options/routes/Agent/OPFS/opfs_fs.test.ts | 130 +++++++++- .../options/routes/Agent/OPFS/opfs_fs.ts | 122 ++++++++- 13 files changed, 531 insertions(+), 57 deletions(-) diff --git a/src/app/service/agent/core/opfs_helpers.ts b/src/app/service/agent/core/opfs_helpers.ts index 452110751..3d498ac59 100644 --- a/src/app/service/agent/core/opfs_helpers.ts +++ b/src/app/service/agent/core/opfs_helpers.ts @@ -1,7 +1,15 @@ // OPFS 工作区公共辅助函数 // 供 opfs_tools、agent_dom 等模块复用 -export const WORKSPACE_ROOT = "agents/workspace"; +export const WORKSPACE_PATH = ["agents", "workspace"] as const; +export const WORKSPACE_ROOT = WORKSPACE_PATH.join("/"); + +export function isWorkspacePath(path: readonly string[]): boolean { + return ( + path.every((part) => part.length > 0 && part !== "." && part !== "..") && + WORKSPACE_PATH.every((part, index) => path[index] === part) + ); +} /** Strip leading `/`, reject `..` segments */ export function sanitizePath(raw: string): string { diff --git a/src/locales/de-DE/agent.json b/src/locales/de-DE/agent.json index 40d0543ee..3297ea526 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Text", "opfs_type_binary": "Binär", "opfs_root": "Stammverzeichnis", + "opfs_read_only": "Dieses Verzeichnis ist schreibgeschützt. Änderungen sind nur in agents/workspace und darunter möglich.", + "opfs_rename": "Umbenennen", + "opfs_move": "Verschieben", + "opfs_rename_placeholder": "Neuer Datei- oder Verzeichnisname", + "opfs_move_destination_placeholder": "Zielverzeichnis (z. B. agents/workspace)", + "opfs_rename_success": "Erfolgreich umbenannt", + "opfs_move_success": "Erfolgreich verschoben", "dom_permission_title": "Das Skript fordert DOM-Zugriff an", "dom_permission_describe": "Dieses Skript fordert die Fähigkeit an, das DOM der Webseite zu lesen und zu manipulieren (Klicken, Formulare ausfüllen, Navigation, Screenshot usw.). Erlauben Sie nur vertrauenswürdigen Skripten.", "dom_permission_content": "Agent DOM-Operationen", diff --git a/src/locales/en-US/agent.json b/src/locales/en-US/agent.json index d4615aab2..addc53425 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Text", "opfs_type_binary": "Binary", "opfs_root": "Root", + "opfs_read_only": "This directory is read-only. Changes are allowed only in agents/workspace and its subdirectories.", + "opfs_rename": "Rename", + "opfs_move": "Move", + "opfs_rename_placeholder": "New file or folder name", + "opfs_move_destination_placeholder": "Destination directory (for example, agents/workspace)", + "opfs_rename_success": "Renamed successfully", + "opfs_move_success": "Moved successfully", "dom_permission_title": "The script is requesting DOM operation access", "dom_permission_describe": "This script requests the ability to read and manipulate web page DOM (click, fill forms, navigate, screenshot, etc.). Only grant access to trusted scripts.", "dom_permission_content": "Agent DOM Operations", diff --git a/src/locales/ja-JP/agent.json b/src/locales/ja-JP/agent.json index c8b5c0ba8..8e6ebd9af 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "テキスト", "opfs_type_binary": "バイナリ", "opfs_root": "ルート", + "opfs_read_only": "このディレクトリは読み取り専用です。変更できるのは agents/workspace とその配下だけです。", + "opfs_rename": "名前を変更", + "opfs_move": "移動", + "opfs_rename_placeholder": "新しいファイルまたはディレクトリ名", + "opfs_move_destination_placeholder": "移動先ディレクトリ(例: agents/workspace)", + "opfs_rename_success": "名前を変更しました", + "opfs_move_success": "移動しました", "dom_permission_title": "スクリプトが DOM 操作アクセスをリクエストしています", "dom_permission_describe": "このスクリプトはウェブページの DOM を読み取り操作する能力(クリック、フォーム入力、ナビゲーション、スクリーンショットなど)をリクエストしています。信頼できるスクリプトにのみ許可してください。", "dom_permission_content": "Agent DOM 操作", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index c2b7c7fa2..4d736968f 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Текст", "opfs_type_binary": "Двоичный", "opfs_root": "Корень", + "opfs_read_only": "Этот каталог доступен только для чтения. Изменения разрешены только в agents/workspace и его подкаталогах.", + "opfs_rename": "Переименовать", + "opfs_move": "Переместить", + "opfs_rename_placeholder": "Новое имя файла или папки", + "opfs_move_destination_placeholder": "Каталог назначения (например, agents/workspace)", + "opfs_rename_success": "Успешно переименовано", + "opfs_move_success": "Успешно перемещено", "dom_permission_title": "Скрипт запрашивает доступ к операциям DOM", "dom_permission_describe": "Этот скрипт запрашивает возможность читать и управлять DOM веб-страницы (клик, заполнение форм, навигация, скриншот и т.д.). Разрешайте только доверенным скриптам.", "dom_permission_content": "Agent DOM операции", diff --git a/src/locales/tr-TR/agent.json b/src/locales/tr-TR/agent.json index 8215c5e9d..0b4ac4728 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Metin", "opfs_type_binary": "İkili", "opfs_root": "Kök", + "opfs_read_only": "Bu dizin salt okunurdur. Değişikliklere yalnızca agents/workspace ve alt dizinlerinde izin verilir.", + "opfs_rename": "Yeniden adlandır", + "opfs_move": "Taşı", + "opfs_rename_placeholder": "Yeni dosya veya klasör adı", + "opfs_move_destination_placeholder": "Hedef dizin (örneğin agents/workspace)", + "opfs_rename_success": "Yeniden adlandırıldı", + "opfs_move_success": "Taşındı", "dom_permission_title": "Betik DOM işlem erişimi istiyor", "dom_permission_describe": "Bu betik web sayfası DOM'unu okuma ve değiştirme yetkisi istiyor (tıklama, form doldurma, gezinme, ekran görüntüsü vb.). Yalnızca güvendiğiniz betiklere erişim verin.", "dom_permission_content": "Agent DOM İşlemleri", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index 74115f117..4cb4b9536 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Văn bản", "opfs_type_binary": "Nhị phân", "opfs_root": "Gốc", + "opfs_read_only": "Thư mục này chỉ đọc. Chỉ agents/workspace và các thư mục con cho phép thay đổi.", + "opfs_rename": "Đổi tên", + "opfs_move": "Di chuyển", + "opfs_rename_placeholder": "Tên tệp hoặc thư mục mới", + "opfs_move_destination_placeholder": "Thư mục đích (ví dụ: agents/workspace)", + "opfs_rename_success": "Đã đổi tên", + "opfs_move_success": "Đã di chuyển", "dom_permission_title": "Script yêu cầu quyền thao tác DOM", "dom_permission_describe": "Script này yêu cầu khả năng đọc và thao tác DOM trang web (nhấp chuột, điền biểu mẫu, điều hướng, chụp ảnh màn hình, v.v.). Chỉ cấp quyền cho các script đáng tin cậy.", "dom_permission_content": "Agent DOM thao tác", diff --git a/src/locales/zh-CN/agent.json b/src/locales/zh-CN/agent.json index 8743c1612..2cabb5879 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -134,6 +134,13 @@ "opfs_type_image": "图片", "opfs_type_text": "文本", "opfs_type_binary": "二进制", + "opfs_read_only": "此目录为只读目录。仅 agents/workspace 及其子目录允许修改。", + "opfs_rename": "重命名", + "opfs_move": "移动", + "opfs_rename_placeholder": "新的文件或目录名称", + "opfs_move_destination_placeholder": "目标目录(例如 agents/workspace)", + "opfs_rename_success": "重命名成功", + "opfs_move_success": "移动成功", "dom_permission_title": "脚本请求 DOM 操作权限", "dom_permission_describe": "此脚本请求读取和操作网页 DOM 的能力(点击、填写表单、导航、截图等)。请仅对可信脚本授权。", "dom_permission_content": "Agent DOM 操作", diff --git a/src/locales/zh-TW/agent.json b/src/locales/zh-TW/agent.json index 678c7d573..32ac654db 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "文字", "opfs_type_binary": "二進位", "opfs_root": "根目錄", + "opfs_read_only": "此資料夾為唯讀。只有 agents/workspace 及其子資料夾允許修改。", + "opfs_rename": "重新命名", + "opfs_move": "移動", + "opfs_rename_placeholder": "新的檔案或資料夾名稱", + "opfs_move_destination_placeholder": "目的資料夾(例如 agents/workspace)", + "opfs_rename_success": "重新命名成功", + "opfs_move_success": "移動成功", "dom_permission_title": "腳本請求 DOM 操作權限", "dom_permission_describe": "此腳本請求讀取和操作網頁 DOM 的能力(點擊、填寫表單、導航、截圖等)。請僅對可信腳本授權。", "dom_permission_content": "Agent DOM 操作", diff --git a/src/pages/options/routes/Agent/OPFS/index.test.tsx b/src/pages/options/routes/Agent/OPFS/index.test.tsx index 31e78b8b5..a24b47a7e 100644 --- a/src/pages/options/routes/Agent/OPFS/index.test.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.test.tsx @@ -83,6 +83,14 @@ describe("AgentOPFS 页面", () => { expect(screen.getByText("subdir")).toBeInTheDocument(); }); + it("系统目录浏览时只提供读取和下载,不提供修改操作", async () => { + render(); + expect(await screen.findByText("file1.txt")).toBeInTheDocument(); + expect(screen.queryByTestId("opfs-upload")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-file1.txt")).not.toBeInTheDocument(); + expect(screen.getByTestId("opfs-read-only-notice")).toBeInTheDocument(); + }); + it("点击目录进入并更新面包屑", async () => { render(); expect(await screen.findByTestId("entry-subdir")).toBeInTheDocument(); @@ -109,8 +117,12 @@ describe("AgentOPFS 页面", () => { }); it("选择文件后写入当前目录并刷新展示", async () => { + const workspace = dirHandle("workspace"); + root = dirHandle("root", { agents: dirHandle("agents", { workspace }) }); + (navigator.storage.getDirectory as any).mockResolvedValue(root); render(); - expect(await screen.findByText("file1.txt")).toBeInTheDocument(); + fireEvent.click(await screen.findByTestId("entry-agents")); + fireEvent.click(await screen.findByTestId("entry-workspace")); const input = screen.getByTestId("opfs-upload-input") as HTMLInputElement; const file = new File(["uploaded-content"], "report.json", { type: "application/json" }); fireEvent.change(input, { target: { files: [file] } }); @@ -123,8 +135,8 @@ describe("AgentOPFS 页面", () => { const closeGate = new Promise((resolve) => { releaseClose = resolve; }); - root = dirHandle("root", { "file1.txt": fileHandle("file1.txt", "hi") }); - root.getFileHandle = async (n: string) => ({ + const workspace = dirHandle("workspace", { "file1.txt": fileHandle("file1.txt", "hi") }); + workspace.getFileHandle = async (n: string) => ({ kind: "file", name: n, async createWritable() { @@ -136,9 +148,12 @@ describe("AgentOPFS 页面", () => { }; }, }); + root = dirHandle("root", { agents: dirHandle("agents", { workspace }) }); (navigator.storage.getDirectory as any).mockResolvedValue(root); render(); + fireEvent.click(await screen.findByTestId("entry-agents")); + fireEvent.click(await screen.findByTestId("entry-workspace")); expect(await screen.findByText("file1.txt")).toBeInTheDocument(); const upload = screen.getByTestId("opfs-upload"); @@ -174,12 +189,9 @@ describe("AgentOPFS 页面", () => { mockedUseIsMobile.mockReturnValue(true); render(); expect(await screen.findByText("file1.txt")).toBeInTheDocument(); - const upload = screen.getByTestId("opfs-upload"); const refresh = screen.getByTestId("opfs-refresh"); - // 图标按钮:有可访问名,但没有可见文本节点 - expect(upload).toHaveAccessibleName(); - expect(upload.textContent).toBe(""); expect(refresh.textContent).toBe(""); + expect(screen.queryByTestId("opfs-upload")).not.toBeInTheDocument(); // 页内标题存在(以 test-id 断言,不耦合译文) expect(screen.getByTestId("opfs-mobile-title")).toBeInTheDocument(); }); @@ -191,15 +203,15 @@ describe("AgentOPFS 页面", () => { // AgentPageHeader 的副标题与带文案的桌面按钮均不应出现:说明 64px 页头未渲染 expect(screen.queryByText("Origin Private File System · Agent 私有存储")).not.toBeInTheDocument(); expect(screen.queryByTestId("opfs-refresh")?.textContent).not.toContain("刷新"); - expect(screen.queryByTestId("opfs-upload")?.textContent).not.toContain("上传"); + expect(screen.queryByTestId("opfs-upload")).not.toBeInTheDocument(); }); - it("桌面端渲染 64px 页头(含副标题与带文案的刷新/上传按钮)", async () => { + it("桌面端渲染 64px 页头并在系统目录隐藏上传按钮", async () => { mockedUseIsMobile.mockReturnValue(false); render(); expect(await screen.findByText("file1.txt")).toBeInTheDocument(); expect(screen.getByText("Origin Private File System · Agent 私有存储")).toBeInTheDocument(); expect(screen.getByTestId("opfs-refresh")).toHaveTextContent("刷新"); - expect(screen.getByTestId("opfs-upload")).toHaveTextContent("上传"); + expect(screen.queryByTestId("opfs-upload")).not.toBeInTheDocument(); }); }); diff --git a/src/pages/options/routes/Agent/OPFS/index.tsx b/src/pages/options/routes/Agent/OPFS/index.tsx index 96e0a52d7..32657bb7f 100644 --- a/src/pages/options/routes/Agent/OPFS/index.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.tsx @@ -19,11 +19,23 @@ import { Eye, Download, Trash2, + Pencil, + FolderInput, + LockKeyhole, type LucideIcon, } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; import { Progress } from "@App/pages/components/ui/progress"; +import { Input } from "@App/pages/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@App/pages/components/ui/dialog"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { dayFormat } from "@App/pkg/utils/day_format"; import { cn } from "@App/pkg/utils/cn"; @@ -37,6 +49,10 @@ import { readFileText, getFileBlob, writeFile, + renameEntry, + moveEntry, + isEditablePath, + parsePath, formatSize, fileKind, type FileEntry, @@ -45,6 +61,7 @@ import { type PreviewState = { open: boolean; name: string; kind: FileKind; text?: string; imageUrl?: string }; type SortKey = "name" | "size" | "time"; +type EntryDialogState = { action: "rename" | "move"; entry: FileEntry; value: string }; const PREVIEWABLE: FileKind[] = ["json", "md", "text", "img"]; @@ -71,6 +88,7 @@ export default function AgentOPFS() { const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [preview, setPreview] = useState(null); + const [entryDialog, setEntryDialog] = useState(null); const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "name", dir: "asc" }); const fileInputRef = useRef(null); @@ -111,6 +129,7 @@ export default function AgentOPFS() { }, [entries, sort]); const totalSize = useMemo(() => entries.reduce((sum, e) => sum + (e.size ?? 0), 0), [entries]); + const editable = isEditablePath(path); const toggleSort = (key: SortKey) => setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" })); @@ -150,6 +169,31 @@ export default function AgentOPFS() { await load(); }; + const openRenameDialog = (entry: FileEntry) => { + setEntryDialog({ action: "rename", entry, value: entry.name }); + }; + + const openMoveDialog = (entry: FileEntry) => { + setEntryDialog({ action: "move", entry, value: path.join("/") }); + }; + + const handleEntryDialogSubmit = async () => { + if (!root || !entryDialog) return; + try { + if (entryDialog.action === "rename") { + await renameEntry(root, path, entryDialog.entry.name, entryDialog.value.trim()); + notify.success(t("agent:opfs_rename_success")); + } else { + await moveEntry(root, path, entryDialog.entry.name, parsePath(entryDialog.value)); + notify.success(t("agent:opfs_move_success")); + } + setEntryDialog(null); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : String(error)); + } + }; + const handleUpload = async (e: ChangeEvent) => { const files = Array.from(e.target.files ?? []); e.target.value = ""; // 允许重复选择同名文件 @@ -226,10 +270,12 @@ export default function AgentOPFS() { {t("agent:opfs_refresh")} - + {editable && ( + + )} } /> @@ -251,15 +297,27 @@ export default function AgentOPFS() { > - + {editable && ( + + )} + + )} + + {!editable && ( +
+ + {t("agent:opfs_read_only")}
)} @@ -325,7 +383,17 @@ export default function AgentOPFS() { {sub} - + ); })} @@ -349,7 +417,7 @@ export default function AgentOPFS() { dir={sort.dir} onClick={() => toggleSort("time")} /> - {t("agent:opfs_actions")} + {t("agent:opfs_actions")} {sorted.map((entry) => { const meta = entryMeta(entry); @@ -381,12 +449,15 @@ export default function AgentOPFS() { {entry.lastModified ? dayFormat(new Date(entry.lastModified), "YYYY-MM-DD HH:mm") : "—"} -
+
@@ -407,6 +478,41 @@ export default function AgentOPFS() { onOpenChange={(v) => (v ? undefined : closePreview())} /> )} + + !open && setEntryDialog(null)}> + + + + {entryDialog?.action === "rename" ? t("agent:opfs_rename") : t("agent:opfs_move")} + + {entryDialog?.entry.name} + + + setEntryDialog((current) => (current ? { ...current, value: event.target.value } : current)) + } + onKeyDown={(event) => { + if (event.key === "Enter") void handleEntryDialogSubmit(); + }} + /> + + + + + +
); } @@ -439,19 +545,25 @@ function SortHeader({ ); } -// 移动端 kebab 菜单项:按类型给出 预览 / 下载 / 删除 +// 移动端 kebab 菜单项:按权限给出 预览 / 下载 / 重命名 / 移动 / 删除 function menuItems( entry: FileEntry, { openEntry, handleDownload, handleDelete, + openRenameDialog, + openMoveDialog, t, + editable, }: { openEntry: (e: FileEntry) => void | Promise; handleDownload: (e: FileEntry) => void | Promise; handleDelete: (e: FileEntry) => void | Promise; + openRenameDialog: (e: FileEntry) => void; + openMoveDialog: (e: FileEntry) => void; t: (k: string) => string; + editable: boolean; } ): AgentCardMenuItem[] { const items: AgentCardMenuItem[] = []; @@ -466,13 +578,29 @@ function menuItems( onSelect: () => void handleDownload(entry), }); } - items.push({ - key: "delete", - label: t("common:delete"), - icon: Trash2, - danger: true, - onSelect: () => void handleDelete(entry), - }); + if (editable) { + items.push( + { + key: "rename", + label: t("agent:opfs_rename"), + icon: Pencil, + onSelect: () => openRenameDialog(entry), + }, + { + key: "move", + label: t("agent:opfs_move"), + icon: FolderInput, + onSelect: () => openMoveDialog(entry), + }, + { + key: "delete", + label: t("common:delete"), + icon: Trash2, + danger: true, + onSelect: () => void handleDelete(entry), + } + ); + } return items; } @@ -481,12 +609,18 @@ function RowActions({ onPreview, onDownload, onDelete, + onRename, + onMove, + editable, t, }: { entry: FileEntry; onPreview: (e: FileEntry) => void; onDownload: (e: FileEntry) => void; onDelete: (e: FileEntry) => void; + onRename: (e: FileEntry) => void; + onMove: (e: FileEntry) => void; + editable: boolean; t: (k: string) => string; }) { const canPreview = entry.kind === "file" && PREVIEWABLE.includes(fileKind(entry.name)); @@ -514,22 +648,46 @@ function RowActions({ )} - onDelete(entry)} - destructive - align="end" - > - - + {editable && ( + <> + + + onDelete(entry)} + destructive + align="end" + > + + + + )} ); } 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 e59cde008..fd8731938 100644 --- a/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts +++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from "vitest"; -import { listDir, removeEntry, readFileText, writeFile, formatSize, fileKind } from "./opfs_fs"; +import { + listDir, + removeEntry, + readFileText, + writeFile, + formatSize, + fileKind, + isEditablePath, + renameEntry, + moveEntry, +} from "./opfs_fs"; // ---- 内存版 FileSystemDirectoryHandle mock ---- function fileHandle(name: string, content = "x", lastModified = 0): any { @@ -31,7 +41,86 @@ function dirHandle(name: string, children: Record = {}): any { }; } +function mutableFile(name: string, initialContent: string): any { + let content = initialContent; + return { + kind: "file", + name, + async getFile() { + return new Blob([content]); + }, + async createWritable() { + let nextContent = ""; + return { + async write(data: Blob | string) { + nextContent = typeof data === "string" ? data : await data.text(); + }, + async close() { + content = nextContent; + }, + }; + }, + }; +} + +function mutableDirectory(name: string, children: Record = {}): any { + return { + kind: "directory", + name, + async getDirectoryHandle(childName: string, opts?: { create?: boolean }) { + if (!children[childName]) { + if (!opts?.create) throw new DOMException("Not found", "NotFoundError"); + children[childName] = mutableDirectory(childName); + } + if (children[childName].kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError"); + return children[childName]; + }, + async getFileHandle(childName: string, opts?: { create?: boolean }) { + if (!children[childName]) { + if (!opts?.create) throw new DOMException("Not found", "NotFoundError"); + children[childName] = mutableFile(childName, ""); + } + if (children[childName].kind !== "file") throw new DOMException("Not a file", "TypeMismatchError"); + return children[childName]; + }, + async removeEntry(childName: string) { + if (!children[childName]) throw new DOMException("Not found", "NotFoundError"); + delete children[childName]; + }, + async *[Symbol.asyncIterator]() { + for (const [childName, child] of Object.entries(children)) yield [childName, child]; + }, + _children: children, + }; +} + describe("opfs_fs 文件系统封装", () => { + it("只允许 agents/workspace 及其子目录修改", () => { + expect(isEditablePath(["agents", "workspace"])).toBe(true); + expect(isEditablePath(["agents", "workspace", "uploads"])).toBe(true); + expect(isEditablePath([])).toBe(false); + expect(isEditablePath(["agents"])).toBe(false); + expect(isEditablePath(["agents", "workspaces"])).toBe(false); + expect(isEditablePath(["agents", "workspace", ""])).toBe(false); + expect(isEditablePath(["agents", "workspace", "."])).toBe(false); + expect(isEditablePath(["agents", "workspace", ".."])).toBe(false); + }); + + it("写入和删除系统目录中的条目应被拒绝", async () => { + const root = {} as FileSystemDirectoryHandle; + + await expect(writeFile(root, [], "system.txt", new Blob(["blocked"]))).rejects.toThrow("read-only"); + await expect(removeEntry(root, [], "system.txt", "file")).rejects.toThrow("read-only"); + await expect(renameEntry(root, ["agents"], "system.txt", "renamed.txt")).rejects.toThrow("read-only"); + }); + + it("移动条目必须同时拥有源目录和目标目录的修改权限", async () => { + const root = {} as FileSystemDirectoryHandle; + + await expect(moveEntry(root, ["agents", "workspace"], "file.txt", ["agents"])).rejects.toThrow("read-only"); + await expect(moveEntry(root, ["agents"], "file.txt", ["agents", "workspace"])).rejects.toThrow("read-only"); + }); + it("listDir 目录置顶并读取文件大小", async () => { const root = dirHandle("root", { "z.txt": fileHandle("z.txt", "hello"), alpha: dirHandle("alpha") }); const entries = await listDir(root, []); @@ -48,9 +137,10 @@ describe("opfs_fs 文件系统封装", () => { }); it("removeEntry 删除条目", async () => { - const root = dirHandle("root", { "a.txt": fileHandle("a.txt"), "b.txt": fileHandle("b.txt") }); - await removeEntry(root, [], "a.txt", "file"); - const entries = await listDir(root, []); + const workspace = dirHandle("workspace", { "a.txt": fileHandle("a.txt"), "b.txt": fileHandle("b.txt") }); + const root = dirHandle("root", { agents: dirHandle("agents", { workspace }) }); + await removeEntry(root, ["agents", "workspace"], "a.txt", "file"); + const entries = await listDir(root, ["agents", "workspace"]); expect(entries.map((e) => e.name)).toEqual(["b.txt"]); }); @@ -78,11 +168,41 @@ describe("opfs_fs 文件系统封装", () => { return created[n]; }, }; - await writeFile(root, [], "new.txt", new Blob(["uploaded"])); + const workspace = { + kind: "directory", + async getFileHandle(n: string, opts?: { create?: boolean }) { + if (!created[n]) { + if (!opts?.create) throw new Error("not found"); + created[n] = { kind: "file", name: n, createWritable: async () => writable }; + } + return created[n]; + }, + }; + const agents = { getDirectoryHandle: async () => workspace }; + root.getDirectoryHandle = async () => agents; + await writeFile(root, ["agents", "workspace"], "new.txt", new Blob(["uploaded"])); expect(created["new.txt"]).toBeDefined(); expect(written).toBe("uploaded"); }); + it("在可编辑工作区内支持重命名和移动", async () => { + const workspace = mutableDirectory("workspace", { + "old.txt": mutableFile("old.txt", "data"), + target: mutableDirectory("target"), + }); + const root = mutableDirectory("root", { + agents: mutableDirectory("agents", { workspace }), + }); + + await renameEntry(root, ["agents", "workspace"], "old.txt", "new.txt"); + expect(workspace._children["old.txt"]).toBeUndefined(); + expect(workspace._children["new.txt"]).toBeDefined(); + + await moveEntry(root, ["agents", "workspace"], "new.txt", ["agents", "workspace", "target"]); + expect(workspace._children["new.txt"]).toBeUndefined(); + expect(workspace._children.target._children["new.txt"]).toBeDefined(); + }); + 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 29fa882c1..a5e802594 100644 --- a/src/pages/options/routes/Agent/OPFS/opfs_fs.ts +++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.ts @@ -1,4 +1,7 @@ import { isImageFileName } from "@App/app/service/agent/core/content_utils"; +import { isWorkspacePath, sanitizePath, WORKSPACE_PATH } from "@App/app/service/agent/core/opfs_helpers"; + +export const EDITABLE_PATH = WORKSPACE_PATH; export interface FileEntry { name: string; @@ -26,7 +29,30 @@ export function fileKind(name: string): FileKind { return "bin"; } -async function getDirHandle(root: FileSystemDirectoryHandle, path: string[]): Promise { +export function isEditablePath(path: readonly string[]): boolean { + return isWorkspacePath(path); +} + +function assertEditablePath(path: readonly string[]): void { + if (!isEditablePath(path)) { + throw new Error("This directory is read-only"); + } +} + +function assertEntryName(name: string): void { + if (!name.trim() || name === "." || name === ".." || /[\\/]/.test(name)) { + throw new Error("Invalid file name"); + } +} + +export function parsePath(rawPath: string): string[] { + return sanitizePath(rawPath).split("/").filter(Boolean); +} + +export async function getDirHandle( + root: FileSystemDirectoryHandle, + path: string[] +): Promise { let dir = root; for (const part of path) { dir = await dir.getDirectoryHandle(part); @@ -64,6 +90,8 @@ export async function removeEntry( name: string, kind: "file" | "directory" ): Promise { + assertEditablePath(path); + assertEntryName(name); const dir = await getDirHandle(root, path); await dir.removeEntry(name, { recursive: kind === "directory" }); } @@ -88,9 +116,101 @@ export async function writeFile( name: string, data: Blob ): Promise { + assertEditablePath(path); + assertEntryName(name); const dir = await getDirHandle(root, path); const handle = await dir.getFileHandle(name, { create: true }); const writable = await handle.createWritable(); await writable.write(data); await writable.close(); } + +async function ensureDestinationAvailable(dir: FileSystemDirectoryHandle, name: string): Promise { + let exists = false; + try { + await dir.getFileHandle(name); + exists = true; + } catch { + // The entry may be a directory. + } + if (!exists) { + try { + await dir.getDirectoryHandle(name); + exists = true; + } catch { + // The destination is available. + } + } + if (exists) { + throw new Error(`An entry named "${name}" already exists`); + } +} + +async function copyEntry( + sourceDir: FileSystemDirectoryHandle, + sourceName: string, + destinationDir: FileSystemDirectoryHandle, + destinationName: string +): Promise { + let sourceFile: FileSystemFileHandle | undefined; + try { + sourceFile = await sourceDir.getFileHandle(sourceName); + } catch { + // The source may be a directory. + } + + if (sourceFile) { + const source = await sourceFile.getFile(); + const target = await destinationDir.getFileHandle(destinationName, { create: true }); + const writable = await target.createWritable(); + await writable.write(source); + await writable.close(); + return; + } + + 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); + } +} + +export async function renameEntry( + root: FileSystemDirectoryHandle, + path: string[], + name: string, + newName: string +): Promise { + assertEditablePath(path); + assertEntryName(name); + assertEntryName(newName); + if (name === newName) return; + await moveEntry(root, path, name, path, newName); +} + +export async function moveEntry( + root: FileSystemDirectoryHandle, + sourcePath: string[], + name: string, + destinationPath: string[], + destinationName = name +): Promise { + assertEditablePath(sourcePath); + assertEditablePath(destinationPath); + assertEntryName(name); + assertEntryName(destinationName); + + const sourceEntryPath = [...sourcePath, name]; + const destinationEntryPath = [...destinationPath, destinationName]; + if (destinationEntryPath.join("/") === sourceEntryPath.join("/")) return; + const destinationIsInsideSource = sourceEntryPath.every((part, index) => destinationPath[index] === part); + if (destinationIsInsideSource) { + throw new Error("Cannot move an entry into itself"); + } + + const sourceDir = await getDirHandle(root, sourcePath); + const destinationDir = await getDirHandle(root, destinationPath); + await ensureDestinationAvailable(destinationDir, destinationName); + await copyEntry(sourceDir, name, destinationDir, destinationName); + await sourceDir.removeEntry(name, { recursive: true }); +} From 5f541ea6d1c02298db59ab983732e04ff6109775 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:28:30 +0900 Subject: [PATCH 2/4] i18n --- src/locales/ko-KR/agent.json | 7 +++++++ src/locales/pt-BR/agent.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/locales/ko-KR/agent.json b/src/locales/ko-KR/agent.json index bd896e633..530d60410 100644 --- a/src/locales/ko-KR/agent.json +++ b/src/locales/ko-KR/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "텍스트", "opfs_type_binary": "바이너리", "opfs_root": "루트", + "opfs_read_only": "이 디렉터리는 읽기 전용입니다. 변경은 agents/workspace 및 그 하위 디렉터리에서만 가능합니다.", + "opfs_rename": "이름 바꾸기", + "opfs_move": "이동", + "opfs_rename_placeholder": "새 파일 또는 폴더 이름", + "opfs_move_destination_placeholder": "대상 디렉터리 (예: agents/workspace)", + "opfs_rename_success": "이름이 변경되었습니다", + "opfs_move_success": "이동되었습니다", "dom_permission_title": "스크립트가 DOM 조작 권한을 요청합니다", "dom_permission_describe": "이 스크립트는 웹페이지의 DOM을 읽고 조작하는 기능(클릭, 양식 입력, 페이지 이동, 스크린샷 등)을 요청합니다. 신뢰할 수 있는 스크립트에만 허용하세요.", "dom_permission_content": "에이전트 DOM 조작", diff --git a/src/locales/pt-BR/agent.json b/src/locales/pt-BR/agent.json index d778352c6..3961dd1de 100644 --- a/src/locales/pt-BR/agent.json +++ b/src/locales/pt-BR/agent.json @@ -134,6 +134,13 @@ "opfs_type_text": "Texto", "opfs_type_binary": "Binário", "opfs_root": "Raiz", + "opfs_read_only": "Este diretório é somente leitura. As alterações são permitidas apenas em agents/workspace e suas subpastas.", + "opfs_rename": "Renomear", + "opfs_move": "Mover", + "opfs_rename_placeholder": "Novo nome do arquivo ou pasta", + "opfs_move_destination_placeholder": "Diretório de destino (por exemplo, agents/workspace)", + "opfs_rename_success": "Renomeado com sucesso", + "opfs_move_success": "Movido com sucesso", "dom_permission_title": "O script solicita permissão para manipular o DOM", "dom_permission_describe": "Este script solicita a capacidade de ler e manipular o DOM da página da web (clicar, preencher formulários, navegar, capturar tela, etc.). Conceda acesso apenas a scripts confiáveis.", "dom_permission_content": "Operações do Agente no DOM", From 7b44c12a556e396c5397d1f26ad6c432c0d50c76 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:04:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20OPFS=20=E7=A7=BB=E5=8A=A8=E5=9B=9E=E6=BB=9A=E4=B8=8E?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../options/routes/Agent/OPFS/index.test.tsx | 61 +++++++++++++ src/pages/options/routes/Agent/OPFS/index.tsx | 89 ++++++++++++++----- .../options/routes/Agent/OPFS/opfs_fs.test.ts | 25 ++++++ .../options/routes/Agent/OPFS/opfs_fs.ts | 13 ++- 4 files changed, 166 insertions(+), 22 deletions(-) diff --git a/src/pages/options/routes/Agent/OPFS/index.test.tsx b/src/pages/options/routes/Agent/OPFS/index.test.tsx index a24b47a7e..0ca0d374e 100644 --- a/src/pages/options/routes/Agent/OPFS/index.test.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.test.tsx @@ -185,6 +185,58 @@ describe("AgentOPFS 页面", () => { expect(desc).not.toBe(title); }); + it("加载失败显示错误并可重试,不应伪装成空目录", async () => { + const getDirectory = vi.fn().mockRejectedValueOnce(new Error("permission denied")).mockResolvedValueOnce(root); + Object.defineProperty(navigator, "storage", { + configurable: true, + value: { getDirectory }, + }); + render(); + expect(await screen.findByTestId("opfs-load-error")).toHaveTextContent("permission denied"); + expect(screen.queryByTestId("empty-state")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("opfs-load-retry")); + expect(await screen.findByText("file1.txt")).toBeInTheDocument(); + }); + + it("重命名进行中禁用重复提交并显示忙碌状态", async () => { + let releaseLookup!: () => void; + const lookupGate = new Promise((resolve) => { + releaseLookup = resolve; + }); + const oldFile = fileHandle("old.txt", "data"); + oldFile.createWritable = async () => writableFor("new.txt", {}); + const workspace = dirHandle("workspace", { "old.txt": oldFile }); + const originalGetFileHandle = workspace.getFileHandle; + const originalGetDirectoryHandle = workspace.getDirectoryHandle; + workspace.getFileHandle = async (name: string, opts?: { create?: boolean }) => { + if (name === "new.txt" && !opts?.create) { + await lookupGate; + throw new DOMException("Not found", "NotFoundError"); + } + return originalGetFileHandle(name, opts); + }; + workspace.getDirectoryHandle = async (name: string) => { + if (name === "new.txt") throw new DOMException("Not found", "NotFoundError"); + return originalGetDirectoryHandle(name); + }; + root = dirHandle("root", { agents: dirHandle("agents", { workspace }) }); + (navigator.storage.getDirectory as any).mockResolvedValue(root); + + render(); + fireEvent.click(await screen.findByTestId("entry-agents")); + fireEvent.click(await screen.findByTestId("entry-workspace")); + fireEvent.click(await screen.findByTestId("rename-old.txt")); + fireEvent.change(await screen.findByTestId("opfs-entry-edit-input"), { target: { value: "new.txt" } }); + const submit = screen.getByTestId("opfs-entry-edit-submit"); + fireEvent.click(submit); + expect(submit).toBeDisabled(); + expect(submit).toHaveTextContent("加载中..."); + + releaseLookup(); + await waitFor(() => expect(screen.queryByTestId("opfs-entry-edit-input")).not.toBeInTheDocument()); + }); + it("移动端:页内工具行为图标按钮(无可见文案标签)+ 标题作为页内标题", async () => { mockedUseIsMobile.mockReturnValue(true); render(); @@ -196,6 +248,15 @@ describe("AgentOPFS 页面", () => { expect(screen.getByTestId("opfs-mobile-title")).toBeInTheDocument(); }); + it("移动端只读目录没有可用操作时不显示空菜单", async () => { + mockedUseIsMobile.mockReturnValue(true); + root = dirHandle("root", { subdir: dirHandle("subdir") }); + (navigator.storage.getDirectory as any).mockResolvedValue(root); + render(); + expect(await screen.findByTestId("entry-subdir")).toBeInTheDocument(); + expect(screen.queryByTestId("card-menu")).not.toBeInTheDocument(); + }); + it("移动端抑制 64px 桌面页头(避免与全局 MobileHeader 双层堆叠)", async () => { mockedUseIsMobile.mockReturnValue(true); render(); diff --git a/src/pages/options/routes/Agent/OPFS/index.tsx b/src/pages/options/routes/Agent/OPFS/index.tsx index 32657bb7f..c1e1db796 100644 --- a/src/pages/options/routes/Agent/OPFS/index.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.tsx @@ -86,23 +86,36 @@ export default function AgentOPFS() { const [path, setPath] = useState([]); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [uploading, setUploading] = useState(false); const [preview, setPreview] = useState(null); const [entryDialog, setEntryDialog] = useState(null); + const [entryActionPending, setEntryActionPending] = useState(false); const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "name", dir: "asc" }); const fileInputRef = useRef(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() { /> )} - !open && setEntryDialog(null)}> + !open && !entryActionPending && setEntryDialog(null)}> @@ -490,6 +533,7 @@ export default function AgentOPFS() { - - 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 fd8731938..00f4ad8b8 100644 --- a/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts +++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.test.ts @@ -203,6 +203,31 @@ describe("opfs_fs 文件系统封装", () => { expect(workspace._children.target._children["new.txt"]).toBeDefined(); }); + it("移动复制失败时不应留下部分目标条目", async () => { + const brokenFile = { + kind: "file", + name: "broken.txt", + async getFile() { + throw new Error("read failure"); + }, + }; + const workspace = mutableDirectory("workspace", { + source: mutableDirectory("source", { + "ok.txt": mutableFile("ok.txt", "data"), + "broken.txt": brokenFile, + }), + }); + const root = mutableDirectory("root", { + agents: mutableDirectory("agents", { workspace }), + }); + + await expect(moveEntry(root, ["agents", "workspace"], "source", ["agents", "workspace"], "moved")).rejects.toThrow( + "read failure" + ); + expect(workspace._children.source).toBeDefined(); + expect(workspace._children.moved).toBeUndefined(); + }); + 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 a5e802594..83ed9c601 100644 --- a/src/pages/options/routes/Agent/OPFS/opfs_fs.ts +++ b/src/pages/options/routes/Agent/OPFS/opfs_fs.ts @@ -211,6 +211,15 @@ export async function moveEntry( const sourceDir = await getDirHandle(root, sourcePath); const destinationDir = await getDirHandle(root, destinationPath); await ensureDestinationAvailable(destinationDir, destinationName); - await copyEntry(sourceDir, name, destinationDir, destinationName); - await sourceDir.removeEntry(name, { recursive: true }); + try { + await copyEntry(sourceDir, name, destinationDir, destinationName); + await sourceDir.removeEntry(name, { recursive: true }); + } catch (error) { + try { + await destinationDir.removeEntry(destinationName, { recursive: true }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Failed to roll back a move"); + } + throw error; + } } From 9b7fcbd53f63a8d508b83a906f824c6d5fcdb0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 27 Aug 2026 16:19:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Agent?= =?UTF-8?q?=20OPFS=20=E7=A7=BB=E5=8A=A8=E5=9B=9E=E6=BB=9A=E6=8E=A9?= =?UTF-8?q?=E7=9B=96=E7=9C=9F=E5=AE=9E=E9=94=99=E8=AF=AF=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=9B=AE=E6=A0=87=E6=94=B9=E7=94=A8=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 PR #1578 时发现的四个问题,均在本 PR 范围内修掉: 1. moveEntry 的回滚会掩盖真正的失败原因。复制可能在创建目标之前就失败 (例如源文件 getFile() 抛错),此时目标条目根本不存在,cleanup 的 removeEntry 抛 NotFoundError,被包成 AggregateError("Failed to roll back a move") 抛出,UI 只取 error.message,用户看到的是"回滚失败"而不是 "read failure"。现在把 NotFoundError 视为"无需回滚",原始错误照常抛出。 2. 移动目标从自由文本框改为目录选择器。原实现要求用户手打完整路径,打 "workspace" 而不是 "agents/workspace" 就会撞上 read-only 报错。新增 listMoveDestinations() 递归列出 workspace 下的目录,并排除条目所在目录、 条目自身及其子目录,选项恒为合法目标;workspace 下无其他目录时直接提示, 不再打开一个没有可选项的对话框。 3. 重命名未修改名称时不再提示"重命名成功"。原实现走到 renameEntry 的早返回 分支后仍然弹成功提示。 4. 刷新时保留已加载列表,spinner 只在没有内容可显示时出现,避免每次刷新/每次 操作后列表整块闪烁。加载失败与真实空目录的区分保持不变。 另:copyEntry 递归时去掉 `handle.name || name` 的无谓兜底。 i18n:opfs_move_destination_placeholder(文本框占位符)已无对应控件,替换为 opfs_move_destination,并新增 opfs_move_no_destination,10 个语言包同步。 测试:补 opfs_fs 的"目标创建前失败保留原始错误"与 listMoveDestinations 排除 规则用例;补回 workspace 内上传按钮的桌面/移动端覆盖(此前被改成断言按钮不存在, 只剩系统目录一侧);新增重命名无改动、移动选择器、无可选目标、刷新保留列表四条 页面用例。 --- src/locales/de-DE/agent.json | 3 +- src/locales/en-US/agent.json | 3 +- src/locales/ja-JP/agent.json | 3 +- src/locales/ko-KR/agent.json | 3 +- src/locales/pt-BR/agent.json | 3 +- src/locales/ru-RU/agent.json | 3 +- src/locales/tr-TR/agent.json | 3 +- src/locales/vi-VN/agent.json | 3 +- src/locales/zh-CN/agent.json | 3 +- src/locales/zh-TW/agent.json | 3 +- .../options/routes/Agent/OPFS/index.test.tsx | 101 +++++++++++++++++- src/pages/options/routes/Agent/OPFS/index.tsx | 95 +++++++++++----- .../options/routes/Agent/OPFS/opfs_fs.test.ts | 49 +++++++++ .../options/routes/Agent/OPFS/opfs_fs.ts | 36 ++++++- 14 files changed, 270 insertions(+), 41 deletions(-) diff --git a/src/locales/de-DE/agent.json b/src/locales/de-DE/agent.json index c7509ae18..96182d196 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Umbenennen", "opfs_move": "Verschieben", "opfs_rename_placeholder": "Neuer Datei- oder Verzeichnisname", - "opfs_move_destination_placeholder": "Zielverzeichnis (z. B. agents/workspace)", + "opfs_move_destination": "Zielverzeichnis", + "opfs_move_no_destination": "In agents/workspace gibt es kein weiteres Verzeichnis als Ziel.", "opfs_rename_success": "Erfolgreich umbenannt", "opfs_move_success": "Erfolgreich verschoben", "dom_permission_title": "Das Skript fordert DOM-Zugriff an", diff --git a/src/locales/en-US/agent.json b/src/locales/en-US/agent.json index 3b850ae7e..845474438 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Rename", "opfs_move": "Move", "opfs_rename_placeholder": "New file or folder name", - "opfs_move_destination_placeholder": "Destination directory (for example, agents/workspace)", + "opfs_move_destination": "Destination directory", + "opfs_move_no_destination": "There is no other directory under agents/workspace to move into.", "opfs_rename_success": "Renamed successfully", "opfs_move_success": "Moved successfully", "dom_permission_title": "The script is requesting DOM operation access", diff --git a/src/locales/ja-JP/agent.json b/src/locales/ja-JP/agent.json index c556ff166..03c5e7d58 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "名前を変更", "opfs_move": "移動", "opfs_rename_placeholder": "新しいファイルまたはディレクトリ名", - "opfs_move_destination_placeholder": "移動先ディレクトリ(例: agents/workspace)", + "opfs_move_destination": "移動先ディレクトリ", + "opfs_move_no_destination": "agents/workspace の下に移動先となる他のディレクトリがありません。", "opfs_rename_success": "名前を変更しました", "opfs_move_success": "移動しました", "dom_permission_title": "スクリプトが DOM 操作アクセスをリクエストしています", diff --git a/src/locales/ko-KR/agent.json b/src/locales/ko-KR/agent.json index 54ec4f239..69bc3b4b3 100644 --- a/src/locales/ko-KR/agent.json +++ b/src/locales/ko-KR/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "이름 바꾸기", "opfs_move": "이동", "opfs_rename_placeholder": "새 파일 또는 폴더 이름", - "opfs_move_destination_placeholder": "대상 디렉터리 (예: agents/workspace)", + "opfs_move_destination": "대상 디렉터리", + "opfs_move_no_destination": "agents/workspace 아래에 이동할 다른 디렉터리가 없습니다.", "opfs_rename_success": "이름이 변경되었습니다", "opfs_move_success": "이동되었습니다", "dom_permission_title": "스크립트가 DOM 조작 권한을 요청합니다", diff --git a/src/locales/pt-BR/agent.json b/src/locales/pt-BR/agent.json index 0e268864a..9e9353847 100644 --- a/src/locales/pt-BR/agent.json +++ b/src/locales/pt-BR/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Renomear", "opfs_move": "Mover", "opfs_rename_placeholder": "Novo nome do arquivo ou pasta", - "opfs_move_destination_placeholder": "Diretório de destino (por exemplo, agents/workspace)", + "opfs_move_destination": "Diretório de destino", + "opfs_move_no_destination": "Não há outro diretório em agents/workspace para onde mover.", "opfs_rename_success": "Renomeado com sucesso", "opfs_move_success": "Movido com sucesso", "dom_permission_title": "O script solicita permissão para manipular o DOM", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index 4ea86d41f..5943773cd 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Переименовать", "opfs_move": "Переместить", "opfs_rename_placeholder": "Новое имя файла или папки", - "opfs_move_destination_placeholder": "Каталог назначения (например, agents/workspace)", + "opfs_move_destination": "Каталог назначения", + "opfs_move_no_destination": "В agents/workspace нет другого каталога для перемещения.", "opfs_rename_success": "Успешно переименовано", "opfs_move_success": "Успешно перемещено", "dom_permission_title": "Скрипт запрашивает доступ к операциям DOM", diff --git a/src/locales/tr-TR/agent.json b/src/locales/tr-TR/agent.json index 1e5f567e0..67b0e27ca 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Yeniden adlandır", "opfs_move": "Taşı", "opfs_rename_placeholder": "Yeni dosya veya klasör adı", - "opfs_move_destination_placeholder": "Hedef dizin (örneğin agents/workspace)", + "opfs_move_destination": "Hedef dizin", + "opfs_move_no_destination": "agents/workspace altında taşınabilecek başka bir dizin yok.", "opfs_rename_success": "Yeniden adlandırıldı", "opfs_move_success": "Taşındı", "dom_permission_title": "Betik DOM işlem erişimi istiyor", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index fb6f45494..e834c35c3 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "Đổi tên", "opfs_move": "Di chuyển", "opfs_rename_placeholder": "Tên tệp hoặc thư mục mới", - "opfs_move_destination_placeholder": "Thư mục đích (ví dụ: agents/workspace)", + "opfs_move_destination": "Thư mục đích", + "opfs_move_no_destination": "Không có thư mục nào khác trong agents/workspace để di chuyển đến.", "opfs_rename_success": "Đã đổi tên", "opfs_move_success": "Đã di chuyển", "dom_permission_title": "Script yêu cầu quyền thao tác DOM", diff --git a/src/locales/zh-CN/agent.json b/src/locales/zh-CN/agent.json index 627062cf2..750aabd22 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "重命名", "opfs_move": "移动", "opfs_rename_placeholder": "新的文件或目录名称", - "opfs_move_destination_placeholder": "目标目录(例如 agents/workspace)", + "opfs_move_destination": "目标目录", + "opfs_move_no_destination": "agents/workspace 下没有其他目录可作为移动目标", "opfs_rename_success": "重命名成功", "opfs_move_success": "移动成功", "dom_permission_title": "脚本请求 DOM 操作权限", diff --git a/src/locales/zh-TW/agent.json b/src/locales/zh-TW/agent.json index c84b74672..3a836274e 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -147,7 +147,8 @@ "opfs_rename": "重新命名", "opfs_move": "移動", "opfs_rename_placeholder": "新的檔案或資料夾名稱", - "opfs_move_destination_placeholder": "目的資料夾(例如 agents/workspace)", + "opfs_move_destination": "目的資料夾", + "opfs_move_no_destination": "agents/workspace 下沒有其他資料夾可作為移動目標", "opfs_rename_success": "重新命名成功", "opfs_move_success": "移動成功", "dom_permission_title": "腳本請求 DOM 操作權限", diff --git a/src/pages/options/routes/Agent/OPFS/index.test.tsx b/src/pages/options/routes/Agent/OPFS/index.test.tsx index 0ca0d374e..63dfe1b63 100644 --- a/src/pages/options/routes/Agent/OPFS/index.test.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.test.tsx @@ -4,6 +4,8 @@ import { initTestLanguage } from "@Tests/initTestLanguage"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: vi.fn(() => false) })); +const notify = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock("@App/pages/components/ui/toast", () => ({ notify })); import AgentOPFS from "./index"; @@ -26,10 +28,12 @@ function dirHandle(name: string, children: Record = {}): any { for (const [n, h] of Object.entries(children)) yield [n, h]; }, async getDirectoryHandle(n: string) { + if (!children[n]) throw new DOMException("Not found", "NotFoundError"); return children[n]; }, async getFileHandle(n: string, opts?: { create?: boolean }) { - if (!children[n] && opts?.create) { + if (!children[n]) { + if (!opts?.create) throw new DOMException("Not found", "NotFoundError"); children[n] = { ...fileHandle(n, ""), createWritable: async () => writableFor(n, children) }; } return children[n]; @@ -61,6 +65,19 @@ function writableFor(name: string, children: Record): any { let root: any; +// agents/workspace 是唯一可修改目录,涉及写操作的用例都要先进到这里 +function useWorkspace(children: Record = {}) { + const workspace = dirHandle("workspace", children); + root = dirHandle("root", { agents: dirHandle("agents", { workspace }) }); + (navigator.storage.getDirectory as any).mockResolvedValue(root); + return workspace; +} + +async function enterWorkspace() { + fireEvent.click(await screen.findByTestId("entry-agents")); + fireEvent.click(await screen.findByTestId("entry-workspace")); +} + beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { @@ -74,7 +91,11 @@ beforeEach(() => { value: { getDirectory: vi.fn(async () => root) }, }); }); -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + notify.success.mockClear(); + notify.error.mockClear(); +}); describe("AgentOPFS 页面", () => { it("挂载后展示文件与目录", async () => { @@ -237,6 +258,82 @@ describe("AgentOPFS 页面", () => { await waitFor(() => expect(screen.queryByTestId("opfs-entry-edit-input")).not.toBeInTheDocument()); }); + it("workspace 内桌面端上传按钮带文案", async () => { + useWorkspace({ "file1.txt": fileHandle("file1.txt", "hi") }); + render(); + await enterWorkspace(); + expect(await screen.findByText("file1.txt")).toBeInTheDocument(); + expect(screen.getByTestId("opfs-upload")).toHaveTextContent("上传"); + }); + + it("workspace 内移动端上传按钮为图标按钮(有可访问名,无可见文案)", async () => { + mockedUseIsMobile.mockReturnValue(true); + useWorkspace({ "file1.txt": fileHandle("file1.txt", "hi") }); + render(); + await enterWorkspace(); + const upload = await screen.findByTestId("opfs-upload"); + expect(upload).toHaveAccessibleName(); + expect(upload.textContent).toBe(""); + }); + + it("重命名未修改名称时直接关闭且不提示成功", async () => { + useWorkspace({ "old.txt": fileHandle("old.txt", "data") }); + render(); + await enterWorkspace(); + fireEvent.click(await screen.findByTestId("rename-old.txt")); + fireEvent.click(screen.getByTestId("opfs-entry-edit-submit")); + await waitFor(() => expect(screen.queryByTestId("opfs-entry-edit-input")).not.toBeInTheDocument()); + expect(notify.success).not.toHaveBeenCalled(); + }); + + it("移动对话框用目录选择器给出 workspace 下的可选目标", async () => { + useWorkspace({ "old.txt": fileHandle("old.txt", "data"), target: dirHandle("target") }); + render(); + await enterWorkspace(); + fireEvent.click(await screen.findByTestId("move-old.txt")); + const destination = await screen.findByTestId("opfs-move-destination"); + expect(destination).toHaveTextContent("agents/workspace/target"); + fireEvent.click(screen.getByTestId("opfs-entry-edit-submit")); + await waitFor(() => expect(screen.queryByText("old.txt")).not.toBeInTheDocument()); + expect(notify.success).toHaveBeenCalled(); + }); + + it("workspace 下没有其他目录时移动给出提示而不是打开空选择器", async () => { + useWorkspace({ "old.txt": fileHandle("old.txt", "data") }); + render(); + await enterWorkspace(); + fireEvent.click(await screen.findByTestId("move-old.txt")); + await waitFor(() => expect(notify.error).toHaveBeenCalled()); + expect(screen.queryByTestId("opfs-move-destination")).not.toBeInTheDocument(); + }); + + it("刷新时保留已加载列表,不整块换成加载态", async () => { + let releaseSecondList!: () => void; + const gate = new Promise((resolve) => { + releaseSecondList = resolve; + }); + let listCount = 0; + const children = { "file1.txt": fileHandle("file1.txt", "hi") }; + root = { + kind: "directory", + name: "root", + async *[Symbol.asyncIterator]() { + listCount += 1; + if (listCount === 2) await gate; + for (const [n, h] of Object.entries(children)) yield [n, h]; + }, + }; + (navigator.storage.getDirectory as any).mockResolvedValue(root); + + render(); + expect(await screen.findByText("file1.txt")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("opfs-refresh")); + expect(screen.queryByTestId("opfs-loading")).not.toBeInTheDocument(); + expect(screen.getByText("file1.txt")).toBeInTheDocument(); + releaseSecondList(); + await waitFor(() => expect(screen.getByText("file1.txt")).toBeInTheDocument()); + }); + it("移动端:页内工具行为图标按钮(无可见文案标签)+ 标题作为页内标题", async () => { mockedUseIsMobile.mockReturnValue(true); render(); diff --git a/src/pages/options/routes/Agent/OPFS/index.tsx b/src/pages/options/routes/Agent/OPFS/index.tsx index c1e1db796..b971a40c0 100644 --- a/src/pages/options/routes/Agent/OPFS/index.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.tsx @@ -28,6 +28,7 @@ import { Button } from "@App/pages/components/ui/button"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; import { Progress } from "@App/pages/components/ui/progress"; import { Input } from "@App/pages/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select"; import { Dialog, DialogContent, @@ -51,6 +52,7 @@ import { writeFile, renameEntry, moveEntry, + listMoveDestinations, isEditablePath, parsePath, formatSize, @@ -61,7 +63,9 @@ import { type PreviewState = { open: boolean; name: string; kind: FileKind; text?: string; imageUrl?: string }; type SortKey = "name" | "size" | "time"; -type EntryDialogState = { action: "rename" | "move"; entry: FileEntry; value: string }; +type EntryDialogState = + | { action: "rename"; entry: FileEntry; value: string } + | { action: "move"; entry: FileEntry; value: string; destinations: string[][] }; const PREVIEWABLE: FileKind[] = ["json", "md", "text", "img"]; @@ -186,8 +190,18 @@ export default function AgentOPFS() { setEntryDialog({ action: "rename", entry, value: entry.name }); }; - const openMoveDialog = (entry: FileEntry) => { - setEntryDialog({ action: "move", entry, value: path.join("/") }); + const openMoveDialog = async (entry: FileEntry) => { + if (!root) return; + try { + const destinations = await listMoveDestinations(root, path, entry); + if (destinations.length === 0) { + notify.error(t("agent:opfs_move_no_destination")); + return; + } + setEntryDialog({ action: "move", entry, value: destinations[0].join("/"), destinations }); + } catch (error) { + notify.error(error instanceof Error ? error.message : String(error)); + } }; const handleEntryDialogSubmit = async () => { @@ -196,7 +210,12 @@ export default function AgentOPFS() { setEntryActionPending(true); try { if (entryDialog.action === "rename") { - await renameEntry(root, path, entryDialog.entry.name, entryDialog.value.trim()); + const newName = entryDialog.value.trim(); + if (newName === entryDialog.entry.name) { + setEntryDialog(null); + return; + } + await renameEntry(root, path, entryDialog.entry.name, newName); notify.success(t("agent:opfs_rename_success")); } else { await moveEntry(root, path, entryDialog.entry.name, parsePath(entryDialog.value)); @@ -366,7 +385,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(); + }} + /> + )}