fix(studio): after a batch selection focus returns to the project menu that started it, which no longer is a single list menu, and the session, workbench and rail tests follow the deliberate changes to the project menu, the scope dropdown, the palette ranking, the dirty-tab prompt and the line-numbered code blocks

This commit is contained in:
yhh committed 2026-11-07 00:53:06 -08:00
1 parent 6fd42831e2
commit 1b0fbd8bb1
15 files changed
+78 -48

No files matched your search

@@ -10,7 +10,7 @@ import { SsePort } from "../port/sse";
import { WorkbenchPanel } from "./WorkbenchPanel";
import { unseenPageOpenings } from "./browser_open";
afterEach(cleanup);
afterEach(() => { cleanup(); localStorage.clear(); });
describe("WorkbenchPanel", () => {
it("opens workspace files as tabs and saves what was typed in the editor", async () => {
@@ -614,7 +614,10 @@ describe("WorkbenchPanel", () => {
// From the keyboard: the menu key reports no pointer, and the item takes focus.
fireEvent.contextMenu(screen.getByRole("button", { name: "README.md" }), { clientX: 0, clientY: 0 });
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "在系统文件管理器中显示" }));
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "复制引用" }));
const target = screen.getByRole("menuitem", { name: "在系统文件管理器中显示" });
for (let i = 0; i < screen.getAllByRole("menuitem").length && document.activeElement !== target; i++) await user.keyboard("{ArrowDown}");
expect(document.activeElement).toBe(target);
await user.keyboard("{Enter}");
expect(reveal).toHaveBeenLastCalledWith("README.md");
});
+3 -2
View File
@@ -101,6 +101,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
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 selectFrom = useRef("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// The row a Delete key asked about. The question replaces that row, so focus
// goes back to it once the question is gone, or a keyboard is left nowhere.
@@ -369,7 +370,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
<>
{moveTo.dialog}
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
onExit={() => treeKeys.ref.current?.parentElement?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
onExit={() => document.querySelector<HTMLElement>(`[data-action="workspace.menu"][data-target="${CSS.escape(selectFrom.current)}"]`)?.focus()}
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
<ProjectsHeader adder={adder} onRecent={onRecent} onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle} />
@@ -491,7 +492,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
<WorkspaceReveal ws={ws} hub={hub} dismiss={dismissMenu} onError={onError} />
{ws.remembered && <WorkspaceOrder root={ws.root} position={tree.filter((w) => w.remembered).findIndex((w) => w.root === ws.root)} total={tree.filter((w) => w.remembered).length}
hub={hub} reload={reload} dismiss={dismissMenu} onError={onError} />}
<ProjectMenuExtras canSelect={displayed.length > 0} selecting={selection !== null} onSelect={() => setSelection(new Set())} dismiss={dismissMenu} />
<ProjectMenuExtras canSelect={displayed.length > 0} selecting={selection !== null} onSelect={() => { selectFrom.current = ws.root; setSelection(new Set()); }} dismiss={dismissMenu} />
<div className="session-pop-group">
<button className="danger" role="menuitem" data-action="workspace.remove" onClick={() => { setConfirm(ws.root); dismissMenu(); }}>
<StudioIcon name="close" /><span>{t("common.remove_from_the_list")}</span><small>{t("common.keeps_files")}</small>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, expect, it } from "vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { App } from "./App";
@@ -9,6 +9,11 @@ import type { TreeSession, TreeWindow, TreeWorkspace } from "../port/hub";
afterEach(cleanup);
async function chooseScope(name: RegExp) {
await userEvent.click(await screen.findByRole("button", { name: "会话范围" }));
await userEvent.click(within(document.querySelector<HTMLElement>(".session-filter-menu:not([hidden])")!).getByRole("menuitem", { name }));
}
const row = (n: number, archived: boolean): TreeSession => ({ path: `/s/${archived ? "old" : "new"}-${n}.jsonl`, name: `${archived ? "old" : "new"}-${n}`, title: `${archived ? "归档的" : "活跃的"}会话 ${n}`, turns: 2, archived });
const folder = (sessions: TreeSession[]): TreeWorkspace => ({
root: "/w", name: "w", open: false, remembered: true, sessions, total: 2127 + 27, archived: 2127,
@@ -40,9 +45,9 @@ function slowArchive() {
it("while the archived rows are being read the list says it is reading, not that nothing matches", async () => {
const { hub, release } = slowArchive();
render(<App hub={hub} />);
const group = await screen.findByRole("group", { name: "会话范围" });
await screen.findByRole("button", { name: "会话范围" });
await screen.findByText("活跃的会话 1");
await userEvent.click(within(group).getByRole("button", { name: /归档/ }));
await chooseScope(/归档/);
expect(await screen.findByText("正在读取文件夹…")).toBeTruthy();
expect(screen.queryByText("没有匹配的会话")).toBeNull();
await act(async () => release());
@@ -61,9 +66,9 @@ it("an archive that really holds nothing still says so once the kernel has answe
};
h.tree = async () => [{ ...folder([row(1, false)]), total: 1, archived: 0 }];
render(<App hub={h} />);
const group = await screen.findByRole("group", { name: "会话范围" });
await screen.findByRole("button", { name: "会话范围" });
await screen.findByText("活跃的会话 1");
await userEvent.click(within(group).getByRole("button", { name: /归档/ }));
await chooseScope(/归档/);
expect(await screen.findByText("没有匹配的会话")).toBeTruthy();
});
@@ -87,15 +92,16 @@ it("a long archive loads further pages in the archived view, and a search still
return [folder([row(1, false)])];
};
render(<App hub={h} />);
const group = await screen.findByRole("group", { name: "会话范围" });
await screen.findByRole("button", { name: "会话范围" });
await screen.findByText("活跃的会话 1");
await userEvent.click(within(group).getByRole("button", { name: /归档/ }));
await chooseScope(/归档/);
await screen.findByText("归档的会话 50");
await userEvent.click(await screen.findByRole("button", { name: /显示更多/ }));
await screen.findByText("归档的会话 100");
expect(asked.at(-1)).toMatchObject({ scope: "archived", limits: { "/w": 100 } });
await userEvent.click(within(group).getByRole("button", { name: /全部/ }));
await chooseScope(/全部/);
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "7");
expect(await screen.findByText("归档的会话 7")).toBeTruthy();
// The query is marked inside the title, so the title is no longer one text node.
await waitFor(() => expect(document.querySelector(".rail")!.textContent).toContain("归档的会话 7"));
expect(asked.at(-1)?.q).toBe("7");
});
@@ -48,13 +48,19 @@ describe("quoting a selection from an assistant reply", () => {
expect(screen.queryByRole("menu")).toBeNull();
});
it("quotes a multiline code selection without the surrounding answer", async () => {
it("quotes a code selection without the surrounding answer or the line numbers", async () => {
const { answer, onQuote } = await card({ text: "First paragraph.\n\n```text\nalpha\n beta\ngamma\n```" });
const code = await screen.findByText("alpha beta gamma", { selector: "code.hljs" });
select(code.firstChild!, 0, code.firstChild!, 12);
fireEvent.contextMenu(code);
const lines = await waitFor(() => {
const found = answer.querySelectorAll<HTMLElement>(".code-tx code.hljs");
expect(found).toHaveLength(3);
return found;
});
// Each source line is its own row; jsdom ignores the number's user-select: none, so a range
// across rows would carry the digits a browser leaves out, and the selection stays in one row.
select(lines[1]!.firstChild!, 0);
fireEvent.contextMenu(lines[1]!);
await userEvent.click(screen.getByRole("menuitem", { name: QUOTE }));
expect(onQuote).toHaveBeenCalledWith("alpha\n beta", item.id);
expect(onQuote).toHaveBeenCalledWith("beta", item.id);
expect(answer.textContent).toContain("First paragraph.");
});
@@ -39,10 +39,16 @@ async function mount(empty = false) {
}
const focused = () => document.querySelector<HTMLElement>(".pane[data-active]")!;
const filesShown = (pane = focused()) => pane.querySelector(".workbench-body")!.hasAttribute("data-files");
async function palette(query: string) {
// A typed query ranks a settings match above an action, so Enter would take
// whichever setting mentions the word; the action is picked by its identity.
async function palette(query: string, action = "files") {
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
await userEvent.type(await screen.findByRole("textbox", { name: "搜索会话或操作" }), query);
await userEvent.keyboard("{Enter}");
await userEvent.click(await waitFor(() => {
const hit = document.querySelector<HTMLElement>(`.palette-hit[data-target="c:${action}"]`);
expect(hit).toBeTruthy();
return hit!;
}));
}
it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files from %s and handles repeated navigation", async (entry) => {
@@ -71,7 +77,7 @@ it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files fro
it("leaves Storage in settings and dismisses settings when Files is chosen", async () => {
await mount();
await palette("storage");
await palette("storage", "storage");
await waitFor(() => expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(true));
await waitFor(() => expect(document.querySelector("#set-storage")).toBeTruthy());
await palette("文件");
@@ -91,14 +91,15 @@ it("draws the rail when it is chosen, with every entry in it", async () => {
});
it.each([
[null, "on"],
[null, "off"],
["on", "on"],
["off", "off"],
["collapsed", "collapsed"],
["true", "on"],
["", "on"],
["true", "off"],
["", "off"],
])("reads a stored %j as %s", async (stored, mode) => {
if (stored !== null) localStorage.setItem(KEY, stored);
if (stored === null) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, stored);
await open();
await openAppearance();
const label = { on: "始终显示", collapsed: "仅侧栏收起时显示", off: "不显示" }[mode as "on"];
@@ -171,7 +172,7 @@ it.each(["on", "off"])("reaches usage, account, and settings with the rail %s",
const entries = mode === "on" ? navEl()! : document.querySelector<HTMLElement>(".railfoot")!;
const account = mode === "on" ? [["账号", "account", "chrome.account"]] : [];
if (mode === "off") expect(entries.querySelector('[data-action="chrome.account"]')).toBeNull();
for (const [label, section, action] of [["用量", "usage"], ...account, ["设置", "session", "chrome.settings"]]) {
for (const [label, section, action] of [["用量", "storage"], ...account, ["设置", "session", "chrome.settings"]]) {
const button = within(entries).getByRole("button", { name: new RegExp(`^${label}`) });
if (action) expect(entries.querySelector(`[data-action="${action}"]`)).toBe(button);
await userEvent.click(button);
@@ -281,16 +282,16 @@ it("remembers divider width through collapse, reopen, and reload with the icon r
const divider = screen.getByRole("separator", { name: "调整工作区栏宽度" });
divider.focus();
await userEvent.keyboard("{ArrowRight}");
expect(divider.getAttribute("aria-valuenow")).toBe("328");
expect(localStorage.getItem("rx-rail-w")).toBe("328");
expect(divider.getAttribute("aria-valuenow")).toBe("276");
expect(localStorage.getItem("rx-rail-w")).toBe("276");
await userEvent.keyboard("{Enter}");
expect(app().dataset.rail).toBe("off");
await userEvent.keyboard(" ");
expect(app().dataset.rail).toBe("on");
expect(divider.getAttribute("aria-valuenow")).toBe("328");
expect(divider.getAttribute("aria-valuenow")).toBe("276");
first.view.unmount();
await open();
expect(screen.getByRole("separator", { name: "调整工作区栏宽度" }).getAttribute("aria-valuenow")).toBe("328");
expect(screen.getByRole("separator", { name: "调整工作区栏宽度" }).getAttribute("aria-valuenow")).toBe("276");
});
it.each(["on", "off"])("keeps one drawer entry at the scene boundary with the icon rail %s", async (mode) => {
@@ -307,7 +308,7 @@ it.each(["on", "off"])("keeps one drawer entry at the scene boundary with the ic
await userEvent.click(toggle);
expect(app().dataset.rail).toBe("off");
act(() => room(390));
await userEvent.click(screen.getByRole("button", { name: "展开工作区栏" }));
await userEvent.click(screen.getByRole("button", { name: "会话列表" }));
expect(app().dataset.rail).toBe("on");
act(() => room(1440));
expect(document.querySelector(".studio-menu") !== null).toBe(app().dataset.rail === "off");
@@ -108,7 +108,7 @@ describe("a write the kernel refuses", () => {
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}} weight="" onWeight={() => {}}
look={{} as never} onLook={() => {}} reloadThemes={() => {}}
onClose={() => {}} onChanged={() => {}} onError={() => {}}
account={null} accountUnread="" reloadAccount={() => {}}
at="storage" account={null} accountUnread="" reloadAccount={() => {}}
/>,
);
await userEvent.click(await screen.findByRole("button", { name: "创建" }));
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { beforeEach, expect, it } from "vitest";
import { readRecentFiles, rememberFile } from "./recentFiles";
@@ -39,7 +39,7 @@ function draw(rows = sessions, scope: "all" | "archived" = "all", folded = new S
}
async function startSelection() {
await userEvent.click(screen.getByRole("button", { name: "项目列表的更多操作" }));
await userEvent.click(screen.getByRole("button", { name: "项目操作:Project" }));
await userEvent.click(screen.getByRole("menuitem", { name: "选择本机会话" }));
}
@@ -52,7 +52,7 @@ it("keeps the batch entry in the project list menu without occupying an idle too
draw();
expect(document.querySelector(".session-batch-entry, .session-batch")).toBeNull();
expect(screen.queryByRole("menuitem", { name: "选择本机会话" })).toBeNull();
const more = screen.getByRole("button", { name: "项目列表的更多操作" });
const more = screen.getByRole("button", { name: "项目操作:Project" });
await userEvent.click(more);
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "选择本机会话" }));
await userEvent.keyboard("{Escape}");
@@ -66,7 +66,7 @@ it("keeps the batch entry in the project list menu without occupying an idle too
it.each([{ rows: [] }, { rows: sessions }])("disables selection when no local sessions are displayed: $rows", async ({ rows }) => {
draw(rows, "all", new Set(["/w"]));
await userEvent.click(screen.getByRole("button", { name: "项目列表的更多操作" }));
await userEvent.click(screen.getByRole("button", { name: "项目操作:Project" }));
expect((screen.getByRole("menuitem", { name: "选择本机会话" }) as HTMLButtonElement).disabled).toBe(true);
});
@@ -95,7 +95,7 @@ it("selects rows with clicks and the keyboard without opening or focusing a pane
expect(pane.onOpen).not.toHaveBeenCalled();
expect(pane.onFocus).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "完成选择" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "项目列表的更多操作" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "项目操作:Project" }));
await userEvent.click(beta);
expect(pane.onOpen).toHaveBeenCalledWith({ root: "/w", sessionPath: "/sessions/b.jsonl" });
});
@@ -249,7 +249,7 @@ it("removes hidden selections when projects are folded", async () => {
expect((screen.getByRole("button", { name: "删除所选会话" }) as HTMLButtonElement).disabled).toBe(true);
expect(pane.onArchive).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "完成选择" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "项目列表的更多操作" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "项目操作:Project" }));
});
it("selects every loaded row of a search matching the project name, and only those", async () => {
@@ -87,7 +87,8 @@ it("filters by the word on the kernel and never asks for every row", async () =>
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "release plan");
await waitFor(() => expect(asked.at(-1)).toMatchObject({ scope: "", q: "release plan" }));
expect(asked.every((window) => window?.limit !== 0)).toBe(true);
expect(await screen.findByText("release plan 109")).toBeTruthy();
// The query is marked inside the title, so the title is no longer one text node.
await waitFor(() => expect(document.querySelector(".rail")!.textContent).toContain("release plan 109"));
await userEvent.clear(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }));
await waitFor(() => expect(asked.at(-1)).toMatchObject({ scope: "active", q: "" }));
@@ -123,7 +123,7 @@ it("allows keyboard activation and keeps the version badge independently usable"
fireEvent.contextMenu(row());
await userEvent.click(await screen.findByRole("menuitem", { name: "早先版本(1)" }));
expect(screen.getByText("早先版本 · 2 轮")).toBeTruthy();
expect(row().querySelector(".unread-dot")).toBeTruthy();
expect(within(row()).getByRole("img", { name: "未读" })).toBeTruthy();
expect(onOpen).not.toHaveBeenCalled();
await userEvent.click(screen.getByText(TITLE));
expect(onOpen).toHaveBeenCalledWith({ root: "/w", sessionPath: PATH });
@@ -13,7 +13,7 @@ export function captureSessionFocus(row: HTMLElement | null): () => void {
if (document.activeElement !== from && document.activeElement !== document.body) return;
const replacement = target ? [...document.querySelectorAll<HTMLElement>('[role="treeitem"]')].find((node) => node.dataset.target === target) : null;
const candidates = [from, row, replacement, ...neighbors,
document.querySelector<HTMLElement>('[data-action="machine.menu"]')];
document.querySelector<HTMLElement>('[data-action="workspace.menu"]')];
candidates.find((node) => node?.isConnected && !node.matches(":disabled"))?.focus({ preventScroll: true });
};
}
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
@@ -10,7 +10,7 @@ import "./testkit";
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
afterEach(cleanup);
afterEach(() => { cleanup(); localStorage.clear(); });
function deferred<T>() {
let resolve!: (value: T) => void;
@@ -45,7 +45,11 @@ function fixture() {
const view = render(<WorkbenchPanel {...props} />);
const open = async (path: string) => {
await user.click(button("files"));
await user.click(await screen.findByRole("button", { name: path }));
await user.click(await waitFor(() => {
const row = document.querySelector<HTMLElement>(`[data-action-click="workbench.file"][data-target="${path}"]`);
if (!row) throw new Error("no explorer row");
return row;
}));
};
return { ...view, user, port, read, save, props, open };
}
@@ -77,6 +81,7 @@ it("keeps a draft through launcher cancellation, hiding and closing then reopeni
f.rerender(<WorkbenchPanel {...f.props} />);
await editor("saved a.txt draft");
await f.user.click(button("close", "file:a.txt"));
await f.user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "取消" }));
await f.open("b.txt");
await editor("saved b.txt");
await f.open("a.txt");
@@ -238,13 +243,13 @@ it("evicts clean closed files but retains a closed draft until its pending save
await append(" draft");
const pending = deferred<WorkspaceFile>();
f.save.mockReturnValueOnce(pending.promise);
await f.user.click(button("save"));
await f.user.click(button("close", "file:a.txt"));
await f.open("a.txt");
await f.user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "保存" }));
expect(button("tab", "file:a.txt")).toBeTruthy();
await editor("saved a.txt draft");
expect(button("save").disabled).toBe(true);
await f.user.click(button("close", "file:a.txt"));
await act(async () => pending.resolve(file("a.txt", "saved a.txt draft", "r2")));
await waitFor(() => expect(button("tab", "file:a.txt")).toBeNull());
const savedRead = deferred<WorkspaceFile>();
f.read.mockReturnValueOnce(savedRead.promise);
await f.open("a.txt");
@@ -11,7 +11,7 @@ import "./testkit";
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); });
const props = (port: MockPort) => ({
port, workspaceRoot: "/work/project", tabs: [], manual: false, shown: true, scheme: "light" as const,
@@ -81,6 +81,6 @@ it("offers only Remove for an unremembered project", async () => {
await userEvent.click(screen.getByRole("button", { name: "项目操作:b" }));
expect(screen.queryByRole("menuitem", { name: "上移" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "下移" })).toBeNull();
expect(screen.getAllByRole("menuitem")).toHaveLength(1);
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["选择本机会话", "从列表移除不删除文件"]);
expect(screen.getByRole("menuitem", { name: /从列表移除/ })).toBeTruthy();
});