diff --git a/desktop/frontend-next/src/ui/WorktreeChip.mount.test.tsx b/desktop/frontend-next/src/ui/WorktreeChip.mount.test.tsx new file mode 100644 index 0000000000..08859010a6 --- /dev/null +++ b/desktop/frontend-next/src/ui/WorktreeChip.mount.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { MockPort } from "../port/mock"; +import { WorktreeChip } from "./WorktreeChip"; +import type { AgentPort } from "../port/port"; + +const root = document.createElement("div"); +let mounted: ReturnType | null = null; +afterEach(() => { mounted?.unmount(); mounted = null; root.remove(); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; }); + +it("keeps open a menu opened between the chip's commit and its mount effects", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; + document.body.append(root); + const port = new MockPort() as AgentPort; + const sources = { available: false, sourceRoot: "/w", sources: [] }; + mounted = createRoot(root); + const clicked = new Promise((resolve) => { + const watch = new MutationObserver(() => { + const button = root.querySelector("button.worktree-trigger"); + if (!button) return; + watch.disconnect(); + button.click(); + resolve(); + }); + watch.observe(root, { childList: true, subtree: true }); + }); + mounted.render( sources} onChanged={vi.fn()} onError={vi.fn()} />); + await clicked; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(root.querySelector("button.worktree-trigger")?.getAttribute("aria-expanded")).toBe("true"); +}); diff --git a/desktop/frontend-next/src/ui/WorktreeChip.tsx b/desktop/frontend-next/src/ui/WorktreeChip.tsx index ced5ef3b46..aa816d0dfe 100644 --- a/desktop/frontend-next/src/ui/WorktreeChip.tsx +++ b/desktop/frontend-next/src/ui/WorktreeChip.tsx @@ -43,9 +43,14 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated; const chosen = sources.sources.find((source) => source.ref === selection?.ref); const label = chosen?.name ?? selection?.ref ?? branch; + const scope = useRef({ port, root: sources.sourceRoot }); useEffect(() => { let live = true; - returnFocus.current = false; setWorkspace(null); setOpen(false); setQuery(""); + if (scope.current.port !== port || scope.current.root !== sources.sourceRoot) { + scope.current = { port, root: sources.sourceRoot }; + returnFocus.current = false; setOpen(false); setQuery(""); + } + setWorkspace(null); void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {}); return () => { live = false; }; }, [port, sources.sourceRoot]); diff --git a/desktop/frontend-next/src/ui/rewind-undo.interaction.test.tsx b/desktop/frontend-next/src/ui/rewind-undo.interaction.test.tsx index 6d3afd88b4..f83c4ec67b 100644 --- a/desktop/frontend-next/src/ui/rewind-undo.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/rewind-undo.interaction.test.tsx @@ -134,7 +134,9 @@ it("keeps undo after unrelated rerender, supports a refused undo retry and repea it("allows explicit dismissal without invoking undo", async () => { const h = await open(); await restore(); await screen.findByRole("button", { name: "撤销这次回退" }); - fireEvent.click(screen.getByRole("button", { name: "关闭回退提示" })); + const close = screen.getByRole("button", { name: "关闭回退提示" }); + await waitFor(() => expect(close.disabled).toBe(false)); + fireEvent.click(close); expect(screen.queryByText("已还原 1 个文件")).toBeNull(); expect(h.undo).not.toHaveBeenCalled(); }); diff --git a/desktop/frontend-next/src/ui/useWorkspaceBranches.test.tsx b/desktop/frontend-next/src/ui/useWorkspaceBranches.test.tsx index cc219e2ac4..036fac8f79 100644 --- a/desktop/frontend-next/src/ui/useWorkspaceBranches.test.tsx +++ b/desktop/frontend-next/src/ui/useWorkspaceBranches.test.tsx @@ -22,3 +22,16 @@ it("releases the switch state on port change and ignores the old completion", as expect(switched).not.toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); }); + +it("keeps the list a reader asked for when the root settles while it is being read", async () => { + const port = new MockPort(); + let land!: () => void; + const branches = port.branches.bind(port); + vi.spyOn(port, "branches").mockImplementation(() => new Promise((done) => { land = () => done(branches()); }) as ReturnType); + const { result, rerender } = renderHook(({ root }) => useWorkspaceBranches(port, vi.fn(), undefined, vi.fn(), root), { initialProps: { root: "/a" } }); + act(() => result.current.loadBranches()); + rerender({ root: "/b" }); + await act(async () => { land(); await Promise.resolve(); }); + expect(result.current.reading).toBe("ready"); + expect(result.current.locals.map((b) => b.name)).toContain("studio"); +}); diff --git a/desktop/frontend-next/src/ui/useWorkspaceBranches.ts b/desktop/frontend-next/src/ui/useWorkspaceBranches.ts index a20c329b83..87b13869cd 100644 --- a/desktop/frontend-next/src/ui/useWorkspaceBranches.ts +++ b/desktop/frontend-next/src/ui/useWorkspaceBranches.ts @@ -6,12 +6,16 @@ export function useWorkspaceBranches(port: AgentPort, onChanged: () => void, onS const [moving, setMoving] = useState(null); const [reading, setReading] = useState<"idle" | "loading" | "ready" | "failed">("idle"); const generation = useRef(0); - useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); return () => { generation.current++; }; }, [port, root]); + const asked = useRef(false); + const load = useRef<() => void>(() => {}); + useEffect(() => { generation.current++; setLocals([]); setMoving(null); setReading("idle"); if (asked.current) load.current(); return () => { generation.current++; }; }, [port, root]); const loadBranches = useCallback(() => { const current = generation.current; + asked.current = true; setReading("loading"); - void port.branches().then((result) => { if (current === generation.current) { setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { setReading("failed"); onError(error); } }); + void port.branches().then((result) => { if (current === generation.current) { asked.current = false; setLocals(result.branches); setReading("ready"); } }).catch((error) => { if (current === generation.current) { asked.current = false; setReading("failed"); onError(error); } }); }, [port, onError, root]); + load.current = loadBranches; const switchTo = (name: string) => { if (moving !== null) return; const current = generation.current; diff --git a/release-notes/studio/unreleased/13674-branch-menu-stays-open.md b/release-notes/studio/unreleased/13674-branch-menu-stays-open.md new file mode 100644 index 0000000000..d9e0753e00 --- /dev/null +++ b/release-notes/studio/unreleased/13674-branch-menu-stays-open.md @@ -0,0 +1,2 @@ +- 修复:刚进入窗格就打开分支菜单时,菜单会被自己的挂载收尾关掉,或一直停在加载中。关联 #13674。 +- Fixed: Opening the branch menu just as a pane settles no longer shuts it or leaves it loading forever. Refs #13674.