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

This commit is contained in:
yhh committed 2026-11-06 06:47:20 -08:00
1 parent 1ff0525a34
commit 0a43322267
3 files changed
+69 -4

No files matched your search

@@ -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." },
+12 -4
View File
@@ -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<Set<string> | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(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
<div className="wsnode" key={ws.root} data-current={panesOf(ws.root).includes(active) ? "" : undefined} data-missing={ws.missing ? "" : undefined}>
{confirm === ws.root ? (
<Confirm
what={t("session.remove_name_from_the_list", { name: ws.name })}
what={gone === ws.root ? t("session.folder_gone_remove_from_list", { name: ws.name }) : t("session.remove_name_from_the_list", { name: ws.name })}
hint={removeHint(doomed.length, busyPanes)}
go={t("common.remove")}
danger={busyPanes > 0}
onGo={() => void dropWorkspace(ws)}
onCancel={() => setConfirm("")}
onCancel={() => { setConfirm(""); setGone(""); }}
/>
) : (
<div
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Sidebar } from "./Sidebar";
import { MockHub } from "../port/mock_hub";
import { HttpError } from "../port/http_error";
import { NO_PAGING } from "./treewindow";
beforeEach(() => localStorage.clear());
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); });
function draw(onOpen: (req: { root?: string; sessionPath?: string }) => Promise<unknown>, hub = new MockHub(), onError = vi.fn()) {
const noop = vi.fn();
render(<Sidebar hub={hub} collapsed={false} navShown treeRead
tree={[{ root: "/gone/alpha", name: "alpha", remembered: true,
sessions: [{ path: "/gone/alpha/a.jsonl", name: "a", title: "first" }] }]}
runtimes={[]} runs={{}} active="" activeWorkspace={undefined} liveIds={() => []}
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();
});