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 }); +}