Keep collapsed projects across sidebar refreshes
Sidebar project lists can be partial while hosts or workspace data are changing. Preserve collapsed preferences instead of treating a temporarily missing project as a request to expand it.
This commit is contained in:
@@ -213,7 +213,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
enabled: isCompactLayout || isOpen,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
useSidebarShortcutModel({ projects });
|
||||||
|
|
||||||
const groupMode = useSidebarViewStore((state) =>
|
const groupMode = useSidebarViewStore((state) =>
|
||||||
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
SidebarProjectEntry,
|
||||||
|
SidebarWorkspaceEntry,
|
||||||
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { useSidebarShortcutModel } from "./use-sidebar-shortcut-model";
|
||||||
|
|
||||||
|
function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEntry {
|
||||||
|
return {
|
||||||
|
workspaceKey: `srv:${workspaceId}`,
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId,
|
||||||
|
projectKey,
|
||||||
|
projectRootPath: `/repo/${projectKey}`,
|
||||||
|
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: workspaceId,
|
||||||
|
title: null,
|
||||||
|
currentBranch: null,
|
||||||
|
statusBucket: "done",
|
||||||
|
statusEnteredAt: null,
|
||||||
|
archivingAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
prHint: null,
|
||||||
|
archiveHasUncommittedChanges: null,
|
||||||
|
archiveUnpushedCommitCount: null,
|
||||||
|
scripts: [],
|
||||||
|
hasRunningScripts: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(projectKey: string): SidebarProjectEntry {
|
||||||
|
return {
|
||||||
|
projectKey,
|
||||||
|
projectName: projectKey,
|
||||||
|
projectKind: "git",
|
||||||
|
iconWorkingDir: `/repo/${projectKey}`,
|
||||||
|
canCreateWorktree: true,
|
||||||
|
workspaces: [workspace(projectKey, `${projectKey}-main`)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS_BOTH = [project("p1"), project("p2")];
|
||||||
|
const PROJECTS_ONLY_SECOND = [project("p2")];
|
||||||
|
|
||||||
|
function Probe({ projectSet }: { projectSet: "both" | "onlySecond" }) {
|
||||||
|
const projects = projectSet === "both" ? PROJECTS_BOTH : PROJECTS_ONLY_SECOND;
|
||||||
|
useSidebarShortcutModel({ projects });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSidebarShortcutModel", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
useSidebarCollapsedSectionsStore.setState({
|
||||||
|
collapsedProjectKeys: new Set(),
|
||||||
|
collapsedStatusGroupKeys: new Set(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a collapsed project collapsed when the project list temporarily omits it", async () => {
|
||||||
|
useSidebarCollapsedSectionsStore.setState({
|
||||||
|
collapsedProjectKeys: new Set(["p1"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe projectSet="both" />);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe projectSet="onlySecond" />);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe projectSet="both" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys.has("p1")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
|
||||||
export function useSidebarShortcutModel(input: {
|
export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[] }) {
|
||||||
projects: SidebarProjectEntry[];
|
const { projects } = input;
|
||||||
isInitialLoad: boolean;
|
|
||||||
}) {
|
|
||||||
const { projects, isInitialLoad } = input;
|
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedProjectKeys,
|
(state) => state.collapsedProjectKeys,
|
||||||
);
|
);
|
||||||
@@ -27,19 +24,6 @@ export function useSidebarShortcutModel(input: {
|
|||||||
[collapsedProjectKeys, projects],
|
[collapsedProjectKeys, projects],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialLoad || projects.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectKeys = new Set(projects.map((project) => project.projectKey));
|
|
||||||
for (const key of collapsedProjectKeys) {
|
|
||||||
if (!projectKeys.has(key)) {
|
|
||||||
setProjectCollapsed(key, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user