diff --git a/e2e/branchesPopupExpansion.spec.ts b/e2e/branchesPopupExpansion.spec.ts new file mode 100644 index 000000000..be5db05fb --- /dev/null +++ b/e2e/branchesPopupExpansion.spec.ts @@ -0,0 +1,77 @@ +/** + * Git Branches keeps the groups the user expanded or collapsed after a restart, as IDEA does. + * Git is stubbed at the IPC layer; reloading the page stands in for restarting the IDE. + */ +import type { Page } from "@playwright/test"; +import { expect, stubDesktop, test } from "./fixtures"; + +const ROOT = "/dev/acme-api"; + +async function openBranches(page: Page) { + await stubDesktop(page); + await page.evaluate(async (root) => { + const h = window.__ipc.handlers; + const branch = (name: string, remote: boolean, current = false) => ({ + name, + current, + remote, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: null, + commit: "1111111", + }); + h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }]; + h.vcs_branch_info = () => ({ + head: "main", + headCommit: "1111111", + upstream: null, + ahead: 0, + behind: 0, + operation: null, + }); + h.vcs_status = () => []; + h.vcs_tags = () => []; + h.vcs_branches = () => [ + branch("main", false, true), + branch("develop", false), + branch("origin/main", true), + branch("origin/release", true), + ]; + const vcsModule = "/src/features/vcs/store.ts"; + const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule); + useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false }); + await refreshNow(); + await loadBranches(); + const actionsModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionsModule); + await executeAction("vcs.branches"); + }, ROOT); + const popup = page.getByRole("dialog", { name: "Git Branches" }); + await expect(popup.getByRole("listbox", { name: "Branches" })).toBeVisible(); + return popup; +} + +const group = (popup: ReturnType, label: string) => + popup.locator("[aria-expanded]").filter({ has: popup.page().getByText(label, { exact: true }) }); + +test("Git Branches reopens with the groups expanded as the user left them, also after a restart", async ({ + workbench: page, +}) => { + let popup = await openBranches(page); + await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "false"); + await group(popup, "Remote").click(); + await group(popup, "Local").click(); + await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "true"); + await expect(group(popup, "Local")).toHaveAttribute("aria-expanded", "false"); + await expect(popup.getByText("release", { exact: true })).toBeVisible(); + + await page.keyboard.press("Escape"); + await page.reload(); + await expect(page.getByRole("tree", { name: "Project" })).toBeVisible(); + popup = await openBranches(page); + await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "true"); + await expect(group(popup, "Local")).toHaveAttribute("aria-expanded", "false"); + await expect(popup.getByText("release", { exact: true })).toBeVisible(); + await expect(popup.getByText("develop", { exact: true })).toBeHidden(); +}); diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index 35d3cab6e..ffe09b9e3 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -1,11 +1,12 @@ import { branchesByRepo } from "./multiRoot"; /** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { create } from "zustand"; import type { Branch, BranchInfo } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { bindingsFor, normalizeBinding, registerKeyClaim, shortcutLabel, strokeOf } from "@/platform/keymap"; import { useEditorRegistry } from "@/editor/api"; import { @@ -201,16 +202,53 @@ function toggleOption(key: keyof PopupOptions) { } } -/** Groups and folders the user expanded or collapsed, remembered for the session (per popup kind). */ -const useExpansion = create<{ single: ReadonlyMap; multi: ReadonlyMap }>(() => ({ - single: new Map(), - multi: new Map(), -})); +/** + * Groups and folders the user expanded or collapsed (per popup kind), kept per project across + * sessions as IDEA's GitBranchesPopupTreeState does. + */ +const useExpansion = create<{ + root: string | null; + single: ReadonlyMap; + multi: ReadonlyMap; +}>(() => ({ root: null, single: new Map(), multi: new Map() })); + +const EXPANSION_KEY = "openidea.vcs.branchesPopup.expanded:"; + +/** Switches to the expansion stored for the project at `root`. */ +function loadExpansion(root: string | null) { + if (useExpansion.getState().root === root) return; + const next = { root, single: new Map(), multi: new Map() }; + try { + const v: unknown = root ? JSON.parse(localStorage.getItem(EXPANSION_KEY + root) ?? "null") : null; + for (const kind of ["single", "multi"] as const) { + const entries = (v as Record | null)?.[kind]; + if (Array.isArray(entries)) + for (const e of entries) + if (Array.isArray(e) && typeof e[0] === "string" && typeof e[1] === "boolean") next[kind].set(e[0], e[1]); + } + } catch { + // Storage unavailable: defaults. + } + useExpansion.setState(next); +} function useExpanded(kind: "single" | "multi") { const expanded = useExpansion((s) => s[kind]); - const setOpen = (key: string, open: boolean) => - useExpansion.setState((s) => (s[kind].get(key) === open ? s : { [kind]: withExpanded(s[kind], key, open) })); + const setOpen = (key: string, open: boolean) => { + const s = useExpansion.getState(); + if (s[kind].get(key) === open) return; + const next = { ...s, [kind]: withExpanded(s[kind], key, open) }; + useExpansion.setState(next); + if (!next.root) return; + try { + localStorage.setItem( + EXPANSION_KEY + next.root, + JSON.stringify({ single: [...next.single], multi: [...next.multi] }), + ); + } catch { + // Kept for this session only. + } + }; return [expanded, setOpen] as const; } @@ -317,6 +355,8 @@ function BranchesPopup({ anchor, onClose }: { anchor: PopupAnchor; onClose: () = // ⌥⌘N is also Inline (enabled while a popup is open over an editor): the search field handles it here. useEffect(() => registerKeyClaim((_e, stroke) => isNewBranchStroke(stroke)), []); const [initialSize] = useState(loadPopupSize); + const root = useProject((s) => s.project?.root ?? null); + useLayoutEffect(() => loadExpansion(root), [root]); return ( { fireEvent.pointerUp(window); expect(JSON.parse(localStorage.getItem("openidea.vcs.branchesPopup.size")!)).toEqual({ w: 560, h: 500 }); }); + +it("keeps the groups the user expanded per project across sessions", async () => { + const { useProject } = await import("@/platform/project"); + const { useVcs } = await import("../store"); + const branch = (name: string, remote: boolean, current = false) => ({ + name, + current, + remote, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: null, + commit: "1111111", + }); + useProject.setState({ project: { root: "/work/app" } as never }); + useVcs.setState({ + available: true, + root: "/work/app", + repo: "/work/app", + roots: ["/work/app"], + branchesLoaded: true, + branches: [branch("main", false, true), branch("origin/main", true), branch("origin/release", true)], + }); + // A previous session expanded Remote, which is collapsed by default. + localStorage.setItem( + "openidea.vcs.branchesPopup.expanded:/work/app", + JSON.stringify({ single: [["group:remote", true]], multi: [] }), + ); + render(); + act(showBranchesPopup); + const remote = screen.getByText("Remote").closest("[aria-expanded]")!; + expect(remote).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("release")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Local")); + expect(screen.getByText("Local").closest("[aria-expanded]")).toHaveAttribute("aria-expanded", "false"); + expect(JSON.parse(localStorage.getItem("openidea.vcs.branchesPopup.expanded:/work/app")!)).toEqual({ + single: [ + ["group:remote", true], + ["group:local", false], + ], + multi: [], + }); +});