mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12347' into takeover/12347
# Conflicts: # desktop/frontend-next/src/actions.ts # desktop/frontend-next/src/ui/Workspaces.tsx
This commit is contained in:
commit
6766cc0042
6 files changed
+411
-5
No files matched your search
@@ -63,6 +63,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
// en_settings and en_kernel were — one screen's worth of wording, read together.
|
||||
|
||||
export const EN_WINDOW: Record<string, string> = {
|
||||
"选择本机会话": "Select local sessions",
|
||||
"选择会话:{name}": "Select session: {name}",
|
||||
"已选 {n} 个会话": "{n} sessions selected",
|
||||
"完成选择": "Done selecting",
|
||||
"全选当前显示的会话": "Select all displayed sessions",
|
||||
"取消当前显示会话的选择": "Deselect displayed sessions",
|
||||
"取消全选": "Deselect all",
|
||||
"全选可见": "Select visible",
|
||||
"归档所选会话": "Archive selected sessions",
|
||||
"取消所选会话归档": "Unarchive selected sessions",
|
||||
"删除所选会话": "Delete selected sessions",
|
||||
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
|
||||
"正在处理所选会话…": "Processing selected sessions…",
|
||||
"删除 {n} 个会话?": "Delete {n} sessions?",
|
||||
"运行指标": "Run metrics",
|
||||
"时间线": "Timeline",
|
||||
"定位": "Locate",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.session-batch-entry { display: flex; justify-content: flex-end; padding: 3px 18px; }
|
||||
.session-batch-entry button, .session-batch button { font: var(--w-med) var(--fs-micro) var(--ui); }
|
||||
.session-batch-entry button { padding: 4px 6px; border: 0; border-radius: 4px; color: var(--muted); background: transparent; cursor: pointer; }
|
||||
.session-batch-entry button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
|
||||
.session-batch { margin: 4px 12px 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--page); }
|
||||
.session-batch-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-size: var(--fs-micro); }
|
||||
.session-batch-head button { flex: none; color: var(--muted); background: transparent; padding: 4px; border: 0; border-radius: 4px; cursor: pointer; }
|
||||
.session-batch-actions { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.session-batch-actions button { padding: 5px 7px; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
|
||||
.session-batch-actions button:hover:not(:disabled) { background: var(--overlay); }
|
||||
.session-batch [data-action="session.batch-delete"] { color: var(--err-ink); }
|
||||
.session-batch p { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-micro); }
|
||||
.session-batch button:disabled, .session-batch-entry button:disabled { opacity: .45; cursor: default; }
|
||||
.session-batch button:focus-visible, .session-batch-entry button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.session-batch .wsconfirm { margin: 8px 0 0; flex-wrap: wrap; gap: 8px; }
|
||||
.session-batch .wsconfirm-t { flex-basis: 100%; }
|
||||
.session-batch-names { font-size: var(--fs-micro); color: var(--text); overflow-wrap: anywhere; max-height: 100px; overflow-y: auto; }
|
||||
.rail .session-select { flex: none; width: 14px; height: 14px; margin: 0 6px 0 0; accent-color: var(--accent); }
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import type { HubPort, TreeSession } from "../port/hub";
|
||||
import { clearDraftForSession } from "./drafts";
|
||||
import "../styles/session-batch.css";
|
||||
|
||||
export function SessionBatch({ hub, selection, selected, displayed, onSelect, onExit, onBusy, onClose, onArchive, liveIds, reload, onError }: {
|
||||
hub: HubPort;
|
||||
selection: Set<string> | null;
|
||||
selected: TreeSession[];
|
||||
displayed: TreeSession[];
|
||||
onSelect: (next: Set<string> | null) => void;
|
||||
onExit: () => void;
|
||||
onBusy: (busy: boolean) => void;
|
||||
onClose: (ids: string[]) => Promise<void>;
|
||||
onArchive: (path: string, archived: boolean, runtimeId?: string) => Promise<void>;
|
||||
liveIds: (ids: string[]) => string[];
|
||||
reload: () => Promise<void>;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<"idle" | "confirm" | "archive" | "restore" | "delete">("idle");
|
||||
const entryButton = useRef<HTMLButtonElement>(null);
|
||||
const wasSelecting = useRef(selection !== null);
|
||||
const deleteButton = useRef<HTMLButtonElement>(null);
|
||||
const doneButton = useRef<HTMLButtonElement>(null);
|
||||
const pending = phase !== "idle" && phase !== "confirm";
|
||||
const running = liveIds(selected.flatMap((row) => row.runtimeId ? [row.runtimeId] : [])).length > 0;
|
||||
const blocked = pending || running || selected.length === 0;
|
||||
const all = displayed.length > 0 && displayed.every((row) => selection?.has(row.path));
|
||||
const restore = selected.length > 0 && selected.every((row) => row.archived);
|
||||
const cancel = () => { setPhase("idle"); onBusy(false); deleteButton.current?.focus(); };
|
||||
useEffect(() => {
|
||||
if (wasSelecting.current && selection === null) {
|
||||
if (entryButton.current?.disabled) onExit(); else entryButton.current?.focus();
|
||||
}
|
||||
wasSelecting.current = selection !== null;
|
||||
}, [selection !== null]);
|
||||
useEffect(() => {
|
||||
if (phase === "idle" && selection !== null) (deleteButton.current?.disabled ? doneButton.current : deleteButton.current)?.focus();
|
||||
}, [phase, selection !== null]);
|
||||
|
||||
const run = async (action: "archive" | "restore" | "delete") => {
|
||||
if (blocked) return;
|
||||
setPhase(action);
|
||||
onBusy(true);
|
||||
const failed = new Set<string>();
|
||||
let firstError: unknown;
|
||||
let removed = false;
|
||||
for (const row of selected) {
|
||||
try {
|
||||
if (row.runtimeId && liveIds([row.runtimeId]).length > 0) throw new Error(t("所选会话正在运行,停止后才能操作"));
|
||||
if (action === "delete") {
|
||||
if (row.runtimeId) await onClose([row.runtimeId]);
|
||||
await hub.removeSession(row.path);
|
||||
clearDraftForSession("", row.path);
|
||||
removed = true;
|
||||
} else {
|
||||
await onArchive(row.path, action === "archive", row.runtimeId);
|
||||
}
|
||||
} catch (e) {
|
||||
failed.add(row.path);
|
||||
firstError ??= e;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
try { await reload(); } catch (e) { firstError ??= e; }
|
||||
}
|
||||
onSelect(failed);
|
||||
setPhase("idle");
|
||||
onBusy(false);
|
||||
if (firstError) onError(firstError);
|
||||
deleteButton.current?.focus();
|
||||
};
|
||||
|
||||
if (selection === null) return (
|
||||
<div className="session-batch-entry">
|
||||
<button ref={entryButton} data-action="session.selection" data-value="start" disabled={displayed.length === 0} onClick={() => onSelect(new Set())}>{t("选择本机会话")}</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="session-batch" aria-busy={pending}>
|
||||
<div className="session-batch-head">
|
||||
<span role="status">{t("已选 {n} 个会话", { n: selected.length })}</span>
|
||||
<button ref={doneButton} data-action="session.selection" data-value="end" disabled={pending} onClick={() => { cancel(); onSelect(null); }}>{t("完成选择")}</button>
|
||||
</div>
|
||||
<div className="session-batch-actions">
|
||||
<button data-action="session.selection" data-value={all ? "clear" : "all"} disabled={phase !== "idle" || displayed.length === 0}
|
||||
aria-label={t(all ? "取消当前显示会话的选择" : "全选当前显示的会话")}
|
||||
onClick={() => {
|
||||
const next = new Set(selection);
|
||||
for (const row of displayed) { if (all) next.delete(row.path); else next.add(row.path); }
|
||||
onSelect(next);
|
||||
}}>{t(all ? "取消全选" : "全选可见")}</button>
|
||||
<button data-action="session.batch-archive" data-value={restore ? "restore" : "archive"} disabled={blocked || phase === "confirm"}
|
||||
aria-label={t(restore ? "取消所选会话归档" : "归档所选会话")} onClick={() => void run(restore ? "restore" : "archive")}>{t(restore ? "取消归档" : "归档")}</button>
|
||||
<button ref={deleteButton} data-action="session.batch-delete" disabled={blocked || phase === "confirm"} aria-label={t("删除所选会话")}
|
||||
onClick={() => { setPhase("confirm"); onBusy(true); }}>{t("删除")}</button>
|
||||
</div>
|
||||
{running && <p>{t("所选会话正在运行,停止后才能操作")}</p>}
|
||||
{pending && <p role="status">{t("正在处理所选会话…")}</p>}
|
||||
{phase === "confirm" && (
|
||||
<div className="wsconfirm" role="alertdialog" aria-label={t("删除 {n} 个会话?", { n: selected.length })}
|
||||
data-action-keydown="layer.dismiss" onKeyDown={(ev) => { if (ev.key === "Escape") { ev.stopPropagation(); cancel(); } }}>
|
||||
<div className="wsconfirm-t">
|
||||
<span className="q">{t("删除 {n} 个会话?", { n: selected.length })}</span>
|
||||
<span className="session-batch-names">{selected.map((row) => row.title || row.name).join(current() === "zh" ? "、" : ", ")}</span>
|
||||
<span className="h">{t("连同其记录一并删除")}</span>
|
||||
</div>
|
||||
<div className="wsconfirm-a">
|
||||
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("取消")}</button>
|
||||
<button data-action="session.batch-delete" data-danger="" disabled={blocked} onClick={() => void run("delete")}>{t("删除")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { SessionMenu } from "./SessionMenu";
|
||||
import { Confirm, removeHint } from "./WorkspaceConfirm";
|
||||
import { UnreadCount, UnreadDot } from "./UnreadMark";
|
||||
import { NO_PAGING, atCap, sessionCount, unloaded, type TreePaging } from "./treewindow";
|
||||
import { SessionBatch } from "./SessionBatch";
|
||||
|
||||
const parentOf = (root: string) => root.replace(/[/\\]+$/, "").split(/[/\\]/).slice(-2, -1)[0] ?? "";
|
||||
|
||||
@@ -78,10 +79,18 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
|
||||
// holds it.
|
||||
const [hereShut, setHereShut] = useState(false);
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [selection, setSelection] = useState<Set<string> | null>(null);
|
||||
// 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.
|
||||
const askedByKey = useRef("");
|
||||
const needle = useRailQuery();
|
||||
useEffect(() => { setSelection((current) => current === null ? null : new Set()); }, [needle, scope]);
|
||||
const toggleSelection = (path: string) => setSelection((current) => {
|
||||
if (!current) return current;
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path); else next.add(path);
|
||||
return next;
|
||||
});
|
||||
const treeKeys = useTreeKeys();
|
||||
// Renaming is a pencil, not a double-click: a single click already opens the
|
||||
// session, so a double one would open it twice on the way to the edit.
|
||||
@@ -317,11 +326,26 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
|
||||
paging.view({ scope: kernelScope, q: asked });
|
||||
}, [paging, kernelScope, asked]);
|
||||
const pagedScope = scope === "all" || scope === "archived";
|
||||
const displayed = shutHere ? [] : shownTree.filter((ws) => needle || !folded.has(ws.root))
|
||||
.flatMap((ws) => ws.sessions);
|
||||
const selected = [...new Map(displayed.filter((row) => selection?.has(row.path)).map((row) => [row.path, row])).values()];
|
||||
useEffect(() => {
|
||||
const paths = new Set(displayed.map((row) => row.path));
|
||||
setSelection((current) => {
|
||||
if (!current) return current;
|
||||
const next = new Set([...current].filter((path) => paths.has(path)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [tree, folded, hereShut, needle, scope, pinned, runs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
|
||||
onExit={() => treeKeys.ref.current?.querySelector<HTMLElement>('[role="treeitem"]')?.focus()}
|
||||
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
|
||||
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
|
||||
<div className="scroll">
|
||||
<div role="tree" aria-label={t("机器、工作区与会话")} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
|
||||
<div role="tree" aria-label={t("机器、工作区与会话")} aria-multiselectable={selection !== null} inert={busy === "batch"} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
|
||||
{/* This machine is the first row of the list rather than another kind of
|
||||
thing, and its add button sits where a host's does: open a folder
|
||||
on this machine. */}
|
||||
@@ -499,23 +523,24 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
|
||||
<Fragment key={session.path}>
|
||||
<div
|
||||
ref={sessionMenu === session.path ? sessionMenuBox : undefined}
|
||||
data-action-click="session.open"
|
||||
data-action-click={selection ? "session.selection" : "session.open"}
|
||||
data-action-contextmenu="session.menu"
|
||||
aria-haspopup="menu"
|
||||
data-action-keydown={["session.open", "session.delete"]}
|
||||
data-action-keydown={selection ? ["session.selection"] : ["session.open", "session.delete"]}
|
||||
data-target={session.path}
|
||||
className="sessrow sessrow-context"
|
||||
role="treeitem"
|
||||
aria-selected={on}
|
||||
aria-selected={selection ? selection.has(session.path) : on}
|
||||
data-on={on ? "" : undefined}
|
||||
data-live={session.runtimeId ? "" : undefined}
|
||||
data-run={run === "idle" ? undefined : run}
|
||||
data-unread={session.unread ? "" : undefined}
|
||||
data-just-done={session.runtimeId && justDone.has(session.runtimeId) ? "" : undefined}
|
||||
data-busy={opening === session.path ? "" : undefined}
|
||||
onClick={() => void pick(ws, session)}
|
||||
onClick={() => selection ? toggleSelection(session.path) : void pick(ws, session)}
|
||||
onContextMenu={(ev) => {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [role='menu']")) return;
|
||||
if (selection) { ev.preventDefault(); return; }
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
menuTrigger.current = ev.currentTarget;
|
||||
@@ -526,6 +551,10 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.target !== ev.currentTarget) return;
|
||||
if (editing === session.path) return;
|
||||
if (selection) {
|
||||
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); ev.stopPropagation(); toggleSelection(session.path); }
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
void pick(ws, session);
|
||||
@@ -535,6 +564,9 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selection && <input type="checkbox" className="session-select" tabIndex={-1} aria-label={t("选择会话:{name}", { name: rowLabel(session) })}
|
||||
data-action-click="session.selection" data-action-change="session.selection" data-target={session.path} checked={selection.has(session.path)}
|
||||
onClick={(ev) => ev.stopPropagation()} onChange={() => toggleSelection(session.path)} />}
|
||||
<i className="pip" />
|
||||
{editing === session.path ? (
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
import { RailSearch } from "./railsearch";
|
||||
import { draftKey, readDraft, writeDraft } from "./drafts";
|
||||
import type { TreeSession, TreeWorkspace } from "../port/hub";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
const sessions: TreeSession[] = [
|
||||
{ path: "/sessions/a.jsonl", name: "a", title: "Alpha", runtimeId: "r1" },
|
||||
{ path: "/sessions/b.jsonl", name: "b", title: "Beta" },
|
||||
{ path: "/sessions/c.jsonl", name: "c", title: "Gamma", archived: true },
|
||||
];
|
||||
|
||||
function draw(rows = sessions, scope: "all" | "archived" = "all", folded = new Set<string>()) {
|
||||
const hub = { removeSession: vi.fn().mockResolvedValue(undefined) };
|
||||
const onArchive = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn().mockResolvedValue(undefined);
|
||||
const onOpen = vi.fn().mockResolvedValue(undefined);
|
||||
const onFocus = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const reload = vi.fn().mockResolvedValue(undefined);
|
||||
let live: string[] = [];
|
||||
const props = { hub: hub as never, tree: [{ root: "/w", name: "Project", sessions: rows }] as TreeWorkspace[],
|
||||
treeRead: true, runtimes: [{ id: "r1" }] as never, active: "r1", folded, onFold: vi.fn(), reload,
|
||||
onOpen, onFocus, onClose, liveIds: (ids: string[]) => ids.filter((id) => live.includes(id)), runs: {},
|
||||
scope, onArchive, onRename: vi.fn(), onError, adder: { add: vi.fn(), close: vi.fn(), at: null } as never };
|
||||
const view = render(<RailSearch><Workspaces {...props} /></RailSearch>);
|
||||
return { hub, onArchive, onClose, onOpen, onFocus, onError, reload,
|
||||
live: (ids: string[]) => { live = ids; view.rerender(<RailSearch><Workspaces {...props}
|
||||
runs={Object.fromEntries(ids.map((id) => [id, { run: "running", live: true }]))} /></RailSearch>); },
|
||||
update: (tree: TreeWorkspace[]) => { props.tree = tree; view.rerender(<RailSearch><Workspaces {...props} /></RailSearch>); },
|
||||
fold: (roots: Set<string>) => { props.folded = roots; view.rerender(<RailSearch><Workspaces {...props} /></RailSearch>); } };
|
||||
}
|
||||
|
||||
async function selectAll() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择本机会话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
|
||||
}
|
||||
|
||||
it("selects rows with clicks and the keyboard without opening or focusing a pane", async () => {
|
||||
const pane = draw();
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择本机会话" }));
|
||||
const alpha = screen.getByRole("treeitem", { name: /Alpha/ });
|
||||
await userEvent.click(alpha);
|
||||
const beta = screen.getByRole("treeitem", { name: /Beta/ });
|
||||
beta.focus();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(alpha.getAttribute("aria-selected")).toBe("true");
|
||||
expect(beta.getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByText("已选 2 个会话")).toBeTruthy();
|
||||
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: "选择本机会话" }));
|
||||
await userEvent.click(beta);
|
||||
expect(pane.onOpen).toHaveBeenCalledWith({ root: "/w", sessionPath: "/sessions/b.jsonl" });
|
||||
});
|
||||
|
||||
it("archives selected sessions through the same owner as an individual row", async () => {
|
||||
const pane = draw();
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
|
||||
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledTimes(2));
|
||||
expect(pane.onArchive).toHaveBeenNthCalledWith(1, "/sessions/a.jsonl", true, "r1");
|
||||
expect(pane.onArchive).toHaveBeenNthCalledWith(2, "/sessions/b.jsonl", true, undefined);
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restores only the selected archived rows", async () => {
|
||||
const pane = draw(sessions, "archived");
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消所选会话归档" }));
|
||||
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledWith("/sessions/c.jsonl", false, undefined));
|
||||
expect(pane.onArchive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("confirms the selected names before deletion and lets Escape cancel without a mutation", async () => {
|
||||
const pane = draw();
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
|
||||
const question = screen.getByRole("alertdialog", { name: "删除 2 个会话?" });
|
||||
expect(within(question).getByText("Alpha、Beta")).toBeTruthy();
|
||||
expect(pane.hub.removeSession).not.toHaveBeenCalled();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "删除所选会话" }));
|
||||
expect(pane.onClose).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
|
||||
await userEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除" }));
|
||||
await waitFor(() => expect(pane.hub.removeSession).toHaveBeenCalledTimes(2));
|
||||
expect(pane.onClose).toHaveBeenCalledWith(["r1"]);
|
||||
expect(pane.onClose.mock.invocationCallOrder[0]).toBeLessThan(pane.hub.removeSession.mock.invocationCallOrder[0]!);
|
||||
expect(pane.hub.removeSession).toHaveBeenNthCalledWith(1, "/sessions/a.jsonl");
|
||||
expect(pane.hub.removeSession).toHaveBeenNthCalledWith(2, "/sessions/b.jsonl");
|
||||
expect(pane.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps failed rows selected while removing successful rows from the batch", async () => {
|
||||
const pane = draw();
|
||||
const failure = new Error("storage unavailable");
|
||||
pane.onArchive.mockRejectedValueOnce(failure);
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
|
||||
await waitFor(() => expect(pane.onError).toHaveBeenCalledWith(failure));
|
||||
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
|
||||
expect(screen.getByRole("treeitem", { name: /Alpha/ }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("treeitem", { name: /Beta/ }).getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not stop running sessions, including a turn that starts after selection", async () => {
|
||||
const pane = draw();
|
||||
await selectAll();
|
||||
pane.live(["r1"]);
|
||||
expect((screen.getByRole("button", { name: "归档所选会话" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("button", { name: "删除所选会话" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText("所选会话正在运行,停止后才能操作")).toBeTruthy();
|
||||
expect(pane.onClose).not.toHaveBeenCalled();
|
||||
expect(pane.hub.removeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks each running state during a batch instead of stopping a newly started turn", async () => {
|
||||
const pane = draw([sessions[0]!, { ...sessions[1]!, runtimeId: "r2" }]);
|
||||
pane.onArchive.mockImplementationOnce(async () => pane.live(["r2"]));
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
|
||||
await waitFor(() => expect(pane.onError).toHaveBeenCalled());
|
||||
expect(pane.onArchive).toHaveBeenCalledTimes(1);
|
||||
expect(pane.onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("treeitem", { name: /Beta/ }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps a session when closing its pane is refused and still deletes the other selected row", async () => {
|
||||
const pane = draw();
|
||||
const failure = new Error("pane cannot close");
|
||||
const kept = draftKey("", "/w", "/sessions/a.jsonl");
|
||||
const removed = draftKey("", "/w", "/sessions/b.jsonl");
|
||||
const remote = draftKey("other-host", "/w", "/sessions/b.jsonl");
|
||||
writeDraft(kept, "keep the refused session");
|
||||
writeDraft(removed, "delete with the removed session");
|
||||
writeDraft(remote, "keep the remote session");
|
||||
pane.onClose.mockRejectedValueOnce(failure);
|
||||
await selectAll();
|
||||
await userEvent.click(screen.getByRole("button", { name: "删除所选会话" }));
|
||||
await userEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "删除" }));
|
||||
await waitFor(() => expect(pane.onError).toHaveBeenCalledWith(failure));
|
||||
expect(pane.hub.removeSession).toHaveBeenCalledTimes(1);
|
||||
expect(pane.hub.removeSession).toHaveBeenCalledWith("/sessions/b.jsonl");
|
||||
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
|
||||
expect(readDraft(kept)).toBe("keep the refused session");
|
||||
expect(readDraft(removed)).toBe("");
|
||||
expect(readDraft(remote)).toBe("keep the remote session");
|
||||
});
|
||||
|
||||
it("keeps a pending batch from being submitted twice", async () => {
|
||||
const pane = draw();
|
||||
let release: () => void = () => {};
|
||||
pane.onArchive.mockImplementationOnce(() => new Promise<void>((resolve) => { release = resolve; }));
|
||||
await selectAll();
|
||||
await userEvent.dblClick(screen.getByRole("button", { name: "归档所选会话" }));
|
||||
expect(pane.onArchive).toHaveBeenCalledTimes(1);
|
||||
expect((screen.getByRole("button", { name: "完成选择" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await act(async () => release());
|
||||
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("selects only displayed rows and clears the selection when the query changes", async () => {
|
||||
const many = Array.from({ length: 35 }, (_, i) => ({ path: `/sessions/${i}.jsonl`, name: `${i}`, title: `Task ${i}` }));
|
||||
const pane = draw(many);
|
||||
await selectAll();
|
||||
expect(screen.getByText("已选 30 个会话")).toBeTruthy();
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "Task 34");
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
expect(pane.onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops deleted selections when a tree refresh removes their canonical rows", async () => {
|
||||
const pane = draw();
|
||||
await selectAll();
|
||||
pane.update([{ root: "/w", name: "Project", sessions: [sessions[1]!] }]);
|
||||
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "归档所选会话" }));
|
||||
await waitFor(() => expect(pane.onArchive).toHaveBeenCalledWith("/sessions/b.jsonl", true, undefined));
|
||||
expect(pane.onArchive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("removes hidden selections when a project or the local machine is folded", async () => {
|
||||
const pane = draw();
|
||||
await selectAll();
|
||||
pane.update([
|
||||
{ root: "/w", name: "Project", sessions },
|
||||
{ root: "/other", name: "Other", sessions: [{ path: "/sessions/d.jsonl", name: "Delta" }] },
|
||||
]);
|
||||
pane.fold(new Set(["/w"]));
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
pane.fold(new Set());
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
pane.fold(new Set(["/w"]));
|
||||
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
|
||||
expect(screen.getByText("已选 1 个会话")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消当前显示会话的选择" }));
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "全选当前显示的会话" }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /这台机器/ }));
|
||||
expect(screen.getByText("已选 0 个会话")).toBeTruthy();
|
||||
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("treeitem", { name: /这台机器/ }));
|
||||
});
|
||||
|
||||
it("limits a search matching the project name to the rows actually rendered", async () => {
|
||||
draw(Array.from({ length: 35 }, (_, i) => ({ path: `/sessions/${i}.jsonl`, name: `${i}`, title: `Task ${i}` })));
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "Project");
|
||||
await selectAll();
|
||||
expect(screen.getByText("已选 30 个会话")).toBeTruthy();
|
||||
expect(screen.queryByRole("treeitem", { name: /Task 34/ })).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user