From 1ae3edd5c8808d6ca84dc3937fbedae169d72fe4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 13 Nov 2026 12:33:16 -0300 Subject: [PATCH] fix(vcs): keep Git Branches' expanded groups and folders across restarts (#3182) Git Branches kept the groups and folders the user expanded or collapsed only for the session. The toggled keys, per popup kind, are now stored per project root in localStorage and loaded when the popup opens; untouched keys keep their defaults. Co-authored-by: formiat <29867335+formiat@users.noreply.github.com> --- e2e/branchesPopupExpansion.spec.ts | 77 +++++++++++++++++++ src/features/vcs/BranchWidget.tsx | 56 ++++++++++++-- .../vcs/__tests__/branchesPopupHost.test.tsx | 44 +++++++++++ 3 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 e2e/branchesPopupExpansion.spec.ts 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: [], + }); +});