diff --git a/src/app/service/agent/core/opfs_helpers.ts b/src/app/service/agent/core/opfs_helpers.ts index 88ac18f77..e6b30d374 100644 --- a/src/app/service/agent/core/opfs_helpers.ts +++ b/src/app/service/agent/core/opfs_helpers.ts @@ -3,7 +3,15 @@ import { throwIfAborted } from "./abort_utils"; -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 2741e9bc4..96182d196 100644 --- a/src/locales/de-DE/agent.json +++ b/src/locales/de-DE/agent.json @@ -143,6 +143,14 @@ "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": "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", "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 0b0278633..845474438 100644 --- a/src/locales/en-US/agent.json +++ b/src/locales/en-US/agent.json @@ -143,6 +143,14 @@ "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": "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", "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 165d6b0c8..03c5e7d58 100644 --- a/src/locales/ja-JP/agent.json +++ b/src/locales/ja-JP/agent.json @@ -143,6 +143,14 @@ "opfs_type_text": "テキスト", "opfs_type_binary": "バイナリ", "opfs_root": "ルート", + "opfs_read_only": "このディレクトリは読み取り専用です。変更できるのは agents/workspace とその配下だけです。", + "opfs_rename": "名前を変更", + "opfs_move": "移動", + "opfs_rename_placeholder": "新しいファイルまたはディレクトリ名", + "opfs_move_destination": "移動先ディレクトリ", + "opfs_move_no_destination": "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/ko-KR/agent.json b/src/locales/ko-KR/agent.json index de21efed8..69bc3b4b3 100644 --- a/src/locales/ko-KR/agent.json +++ b/src/locales/ko-KR/agent.json @@ -143,6 +143,14 @@ "opfs_type_text": "텍스트", "opfs_type_binary": "바이너리", "opfs_root": "루트", + "opfs_read_only": "이 디렉터리는 읽기 전용입니다. 변경은 agents/workspace 및 그 하위 디렉터리에서만 가능합니다.", + "opfs_rename": "이름 바꾸기", + "opfs_move": "이동", + "opfs_rename_placeholder": "새 파일 또는 폴더 이름", + "opfs_move_destination": "대상 디렉터리", + "opfs_move_no_destination": "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 4c106138b..9e9353847 100644 --- a/src/locales/pt-BR/agent.json +++ b/src/locales/pt-BR/agent.json @@ -143,6 +143,14 @@ "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": "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", "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", diff --git a/src/locales/ru-RU/agent.json b/src/locales/ru-RU/agent.json index 1f7d61047..5943773cd 100644 --- a/src/locales/ru-RU/agent.json +++ b/src/locales/ru-RU/agent.json @@ -143,6 +143,14 @@ "opfs_type_text": "Текст", "opfs_type_binary": "Двоичный", "opfs_root": "Корень", + "opfs_read_only": "Этот каталог доступен только для чтения. Изменения разрешены только в agents/workspace и его подкаталогах.", + "opfs_rename": "Переименовать", + "opfs_move": "Переместить", + "opfs_rename_placeholder": "Новое имя файла или папки", + "opfs_move_destination": "Каталог назначения", + "opfs_move_no_destination": "В 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 bfb18ad36..67b0e27ca 100644 --- a/src/locales/tr-TR/agent.json +++ b/src/locales/tr-TR/agent.json @@ -142,10 +142,18 @@ "opfs_type_image": "Görsel", "opfs_type_text": "Metin", "opfs_type_binary": "İkili", - "opfs_root": "Kök dizin", - "dom_permission_title": "Betik, DOM işlemleri için erişim istiyor", - "dom_permission_describe": "Bu betik, web sayfası DOM'unu okuma ve değiştirme (tıklama, form doldurma, gezinme, ekran görüntüsü alma vb.) yeteneği istiyor. Yalnızca güvenilir betiklere erişim izni verin.", - "dom_permission_content": "Ajan DOM İşlemleri", + "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": "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", + "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", "mcp_title": "MCP Sunucuları", "mcp_subtitle": "Ajan araçlarını, kaynaklarını ve istemlerini genişletmek için MCP sunucularını bağlayın", "mcp_add_server": "Sunucu Ekle", diff --git a/src/locales/vi-VN/agent.json b/src/locales/vi-VN/agent.json index 66f68e1f4..e834c35c3 100644 --- a/src/locales/vi-VN/agent.json +++ b/src/locales/vi-VN/agent.json @@ -143,6 +143,14 @@ "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": "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", "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 95abba3e0..750aabd22 100644 --- a/src/locales/zh-CN/agent.json +++ b/src/locales/zh-CN/agent.json @@ -143,6 +143,14 @@ "opfs_type_image": "图片", "opfs_type_text": "文本", "opfs_type_binary": "二进制", + "opfs_read_only": "此目录为只读目录。仅 agents/workspace 及其子目录允许修改。", + "opfs_rename": "重命名", + "opfs_move": "移动", + "opfs_rename_placeholder": "新的文件或目录名称", + "opfs_move_destination": "目标目录", + "opfs_move_no_destination": "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 62a562ab4..3a836274e 100644 --- a/src/locales/zh-TW/agent.json +++ b/src/locales/zh-TW/agent.json @@ -143,6 +143,14 @@ "opfs_type_text": "文字", "opfs_type_binary": "二進位", "opfs_root": "根目錄", + "opfs_read_only": "此資料夾為唯讀。只有 agents/workspace 及其子資料夾允許修改。", + "opfs_rename": "重新命名", + "opfs_move": "移動", + "opfs_rename_placeholder": "新的檔案或資料夾名稱", + "opfs_move_destination": "目的資料夾", + "opfs_move_no_destination": "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..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 () => { @@ -83,6 +104,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 +138,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 +156,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 +169,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"); @@ -170,20 +206,154 @@ 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("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(); 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(); }); + 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(); @@ -191,15 +361,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..b971a40c0 100644 --- a/src/pages/options/routes/Agent/OPFS/index.tsx +++ b/src/pages/options/routes/Agent/OPFS/index.tsx @@ -19,11 +19,24 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select"; +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 +50,11 @@ import { readFileText, getFileBlob, writeFile, + renameEntry, + moveEntry, + listMoveDestinations, + isEditablePath, + parsePath, formatSize, fileKind, type FileEntry, @@ -45,6 +63,9 @@ import { type PreviewState = { open: boolean; name: string; kind: FileKind; text?: string; imageUrl?: string }; type SortKey = "name" | "size" | "time"; +type EntryDialogState = + | { action: "rename"; entry: FileEntry; value: string } + | { action: "move"; entry: FileEntry; value: string; destinations: string[][] }; const PREVIEWABLE: FileKind[] = ["json", "md", "text", "img"]; @@ -69,22 +90,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); - useEffect(() => { - void navigator.storage.getDirectory().then(setRoot); + 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 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); } @@ -111,6 +146,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 +186,51 @@ export default function AgentOPFS() { await load(); }; + const openRenameDialog = (entry: FileEntry) => { + setEntryDialog({ action: "rename", entry, value: entry.name }); + }; + + 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 () => { + if (!root || !entryDialog || entryActionPendingRef.current) return; + entryActionPendingRef.current = true; + setEntryActionPending(true); + try { + if (entryDialog.action === "rename") { + 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)); + notify.success(t("agent:opfs_move_success")); + } + setEntryDialog(null); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : String(error)); + } finally { + entryActionPendingRef.current = false; + setEntryActionPending(false); + } + }; + const handleUpload = async (e: ChangeEvent) => { const files = Array.from(e.target.files ?? []); e.target.value = ""; // 允许重复选择同名文件 @@ -226,10 +307,12 @@ export default function AgentOPFS() { {t("agent:opfs_refresh")} - + {editable && ( + + )} } /> @@ -251,15 +334,27 @@ export default function AgentOPFS() { > - + {editable && ( + + )} + + )} + + {!editable && ( +
+ + {t("agent:opfs_read_only")}
)} @@ -290,7 +385,33 @@ export default function AgentOPFS() { )} - {!loading && entries.length === 0 ? ( + {loading && entries.length === 0 ? ( +
+ + {t("common:loading")} +
+ ) : loadError ? ( +
+ {`${t("common:error")}: ${loadError}`} + +
+ ) : entries.length === 0 ? ( ) : isMobile ? (
@@ -301,6 +422,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 && }
); })} @@ -349,7 +479,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 +511,15 @@ export default function AgentOPFS() { {entry.lastModified ? dayFormat(new Date(entry.lastModified), "YYYY-MM-DD HH:mm") : "—"} -
+
@@ -407,6 +540,71 @@ export default function AgentOPFS() { onOpenChange={(v) => (v ? undefined : closePreview())} /> )} + + !open && !entryActionPending && setEntryDialog(null)}> + + + + {entryDialog?.action === "rename" ? t("agent:opfs_rename") : t("agent:opfs_move")} + + {entryDialog?.entry.name} + + {entryDialog?.action === "move" ? ( + + ) : ( + + setEntryDialog((current) => (current ? { ...current, value: event.target.value } : current)) + } + onKeyDown={(event) => { + if (event.key === "Enter") void handleEntryDialogSubmit(); + }} + /> + )} + + + + + +
); } @@ -439,19 +637,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 | Promise; t: (k: string) => string; + editable: boolean; } ): AgentCardMenuItem[] { const items: AgentCardMenuItem[] = []; @@ -466,13 +670,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: () => void openMoveDialog(entry), + }, + { + key: "delete", + label: t("common:delete"), + icon: Trash2, + danger: true, + onSelect: () => void handleDelete(entry), + } + ); + } return items; } @@ -481,12 +701,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 | Promise; + editable: boolean; t: (k: string) => string; }) { const canPreview = entry.kind === "file" && PREVIEWABLE.includes(fileKind(entry.name)); @@ -514,22 +740,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..f11f00ee4 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,16 @@ 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, + listMoveDestinations, +} from "./opfs_fs"; // ---- 内存版 FileSystemDirectoryHandle mock ---- function fileHandle(name: string, content = "x", lastModified = 0): any { @@ -31,7 +42,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 +138,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 +169,114 @@ 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("移动复制失败时不应留下部分目标条目", 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("移动在目标创建之前失败时保留原始错误", 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 29fa882c1..1015a742c 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,140 @@ 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] of sourceDirectory as unknown as AsyncIterable<[string, FileSystemHandle]>) { + await copyEntry(sourceDirectory, name, destinationDirectory, 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); + try { + await copyEntry(sourceDir, name, destinationDir, destinationName); + await sourceDir.removeEntry(name, { recursive: true }); + } catch (error) { + try { + await destinationDir.removeEntry(destinationName, { recursive: true }); + } catch (cleanupError) { + // 复制可能在创建目标之前就失败(例如源文件不可读),此时目标不存在不算回滚失败, + // 不能用它掩盖真正的错误原因。 + 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; +}