From 0a4332226776937a183b890ac3a9ccee78f514dc Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 06:47:20 -0800 Subject: [PATCH] feat(studio): opening a project whose folder is gone asks, in the list, whether to remove it from the list, instead of reporting the system's sentence --- desktop/frontend-next/src/i18n/msg/session.ts | 1 + desktop/frontend-next/src/ui/Workspaces.tsx | 16 ++++-- .../sidebar-folder-gone.interaction.test.tsx | 56 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 desktop/frontend-next/src/ui/sidebar-folder-gone.interaction.test.tsx diff --git a/desktop/frontend-next/src/i18n/msg/session.ts b/desktop/frontend-next/src/i18n/msg/session.ts index a48042a981..17b9a8f0a1 100644 --- a/desktop/frontend-next/src/i18n/msg/session.ts +++ b/desktop/frontend-next/src/i18n/msg/session.ts @@ -68,6 +68,7 @@ export const SESSION: Messages = { "session.expand_all_projects": { zh: "展开所有项目", en: "Expand all projects" }, "session.export_session": { zh: "导出会话", en: "Export session" }, "session.extra_folders_for_this_project": { zh: "此项目的额外文件夹", en: "Extra folders for this project" }, + "session.folder_gone_remove_from_list": { zh: "「{name}」的文件夹已经不在磁盘上了,要把它从列表里移除吗?", en: "The folder of “{name}” is no longer on disk. Remove it from the list?" }, "session.folder_is_gone": { zh: "文件夹已不在磁盘上", en: "Folder is gone" }, "session.folder_path_to_add": { zh: "要添加的文件夹路径", en: "Folder path to add" }, "session.folders_are_managed_in_the": { zh: "在左栏管理文件夹:底部添加,展开后可新建会话。会话归属于创建它的文件夹,不会移动到其他位置。", en: "Folders are managed in the left column: add at the bottom, expand to start a session. A session belongs to the folder it was created in and does not move." }, diff --git a/desktop/frontend-next/src/ui/Workspaces.tsx b/desktop/frontend-next/src/ui/Workspaces.tsx index d261bae25c..b34d573ff6 100644 --- a/desktop/frontend-next/src/ui/Workspaces.tsx +++ b/desktop/frontend-next/src/ui/Workspaces.tsx @@ -6,6 +6,7 @@ import { createPortal } from "react-dom"; import { t } from "../i18n"; import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub"; import { workspaceContains } from "../port/hub"; +import { HttpError } from "../port/http_error"; import type { Adder } from "./addws"; import { pinToViewport } from "./place"; import { useRailQuery } from "./railsearch"; @@ -94,6 +95,11 @@ const rowLabel = (session: TreeSession) => function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, onRecent, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) { const [busy, setBusy] = useState(""); const [confirm, setConfirm] = useState(""); + // The project whose folder an open just found gone: the question is asked about it, not about a removal chosen from the menu. + const [gone, setGone] = useState(""); + const failIn = (ws: TreeWorkspace, e: unknown) => { + if (e instanceof HttpError && e.reason?.code === "workspace.folder_missing") { setGone(ws.root); setConfirm(ws.root); } else onError(e); + }; const [selection, setSelection] = useState | null>(null); const [expanded, setExpanded] = useState>(new Set()); // The row a Delete key asked about. The question replaces that row, so focus @@ -190,7 +196,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active try { await onOpen({ root: session.workspaceRoot || ws.root, sessionPath: session.path }); } catch (e) { - onError(e); + failIn(ws, e); } }; @@ -199,7 +205,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active try { await onOpen({ root: ws.root }); } catch (e) { - onError(e); + failIn(ws, e); } finally { setBusy(""); } @@ -222,10 +228,12 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active const dropWorkspace = async (ws: TreeWorkspace) => { if (confirm !== ws.root) { + setGone(""); setConfirm(ws.root); return; } setConfirm(""); + setGone(""); try { // Closed here for the same reason dropSession does it: the kernel will not // pull a folder out from under a pane that is writing, and leaving that to @@ -381,12 +389,12 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
{confirm === ws.root ? ( 0} onGo={() => void dropWorkspace(ws)} - onCancel={() => setConfirm("")} + onCancel={() => { setConfirm(""); setGone(""); }} /> ) : (
localStorage.clear()); +afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); }); + +function draw(onOpen: (req: { root?: string; sessionPath?: string }) => Promise, hub = new MockHub(), onError = vi.fn()) { + const noop = vi.fn(); + render( []} + pinned={new Set()} onPin={noop} remotes={null} remoteTrees={{}} + reloadRemotes={async () => {}} reloadRemoteTrees={async () => {}} readRemoteTree={async () => {}} + reloadTree={async () => {}} paging={NO_PAGING} adder={{ busy: false, add: noop, addPath: noop } as never} + onOpen={onOpen as never} onOpenRemote={async () => {}} onFocusPane={noop} onClosePanes={async () => {}} + onPause={noop} onArchive={async () => {}} onRename={noop} onCollapse={noop} + wallet="" onSettings={noop} onFiles={noop} onView={noop} + onFeedback={noop} feedbackUnread={0} onFind={noop} onError={onError} />); + return { hub, onError }; +} + +const missing = () => new HttpError(400, "stat /gone/alpha: no such file or directory", { code: "workspace.folder_missing" }); + +it("asks, in Chinese, whether to remove a project whose folder is gone, instead of printing the system's sentence", async () => { + const { onError } = draw(async () => { throw missing(); }); + await userEvent.click(screen.getByText("first")); + expect(await screen.findByText("「alpha」的文件夹已经不在磁盘上了,要把它从列表里移除吗?")).toBeTruthy(); + expect(onError).not.toHaveBeenCalled(); +}); + +it("removes the project only when told to", async () => { + const hub = new MockHub(); + const remove = vi.spyOn(hub, "removeWorkspace"); + draw(async () => { throw missing(); }, hub); + await userEvent.click(screen.getByText("first")); + await userEvent.click(await screen.findByRole("button", { name: "取消" })); + expect(remove).not.toHaveBeenCalled(); + await userEvent.click(screen.getByText("first")); + await userEvent.click(await screen.findByRole("button", { name: "移除" })); + await waitFor(() => expect(remove).toHaveBeenCalledWith("/gone/alpha")); +}); + +it("keeps reporting every other failure as an error", async () => { + const { onError } = draw(async () => { throw new HttpError(500, "boom", { code: "session.open_failed" }); }); + await userEvent.click(screen.getByText("first")); + await waitFor(() => expect(onError).toHaveBeenCalled()); + expect(screen.queryByText(/文件夹已经不在磁盘上/)).toBeNull(); +});