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>
This commit is contained in:
Alexander
2026-11-13 23:33:16 +08:00
committed by GitHub
co-authored by formiat
parent 52b8cf5e70
commit 1ae3edd5c8
3 changed files with 169 additions and 8 deletions
+77
View File
@@ -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<Page["getByRole"]>, 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();
});
+48 -8
View File
@@ -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<string, boolean>; multi: ReadonlyMap<string, boolean> }>(() => ({
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<string, boolean>;
multi: ReadonlyMap<string, boolean>;
}>(() => ({ 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<string, boolean>(), multi: new Map<string, boolean>() };
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<string, unknown> | 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 (
<Popup
open
@@ -32,3 +32,47 @@ it("opens Git Branches at the size it was last resized to", () => {
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(<BranchesPopupHost />);
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: [],
});
});