feat: persist sidebar list expansion

This commit is contained in:
Junyan Qin
2026-05-15 14:51:08 +08:00
parent ae11bce8b6
commit 98a106d3b5
2 changed files with 73 additions and 30 deletions

View File

@@ -186,6 +186,20 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
// localStorage key for collapsible section open/closed state
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion';
type SidebarNavSection = 'home' | 'extensions';
type SidebarListExpansionState = Record<
SidebarNavSection,
Partial<Record<EntityCategoryId, boolean>>
>;
function createEmptyListExpansionState(): SidebarListExpansionState {
return {
home: {},
extensions: {},
};
}
function loadSectionState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
@@ -205,6 +219,29 @@ function saveSectionState(state: Record<string, boolean>) {
}
}
function loadListExpansionState(): SidebarListExpansionState {
if (typeof window === 'undefined') return createEmptyListExpansionState();
try {
const stored = localStorage.getItem(SIDEBAR_LIST_EXPANSION_KEY);
if (!stored) return createEmptyListExpansionState();
const parsed = JSON.parse(stored) as Partial<SidebarListExpansionState>;
return {
home: parsed.home ?? {},
extensions: parsed.extensions ?? {},
};
} catch {
return createEmptyListExpansionState();
}
}
function saveListExpansionState(state: SidebarListExpansionState) {
try {
localStorage.setItem(SIDEBAR_LIST_EXPANSION_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
// Maximum number of entity sub-items visible before "More" toggle
const MAX_VISIBLE_ITEMS = 5;
@@ -270,7 +307,7 @@ function NavItems({
}: {
selectedChild: SidebarChildVO | undefined;
onChildClick: (child: SidebarChildVO) => void;
section: 'home' | 'extensions';
section: SidebarNavSection;
sectionOpenState: Record<string, boolean>;
onSectionToggle: (id: string, open: boolean) => void;
}) {
@@ -284,8 +321,8 @@ function NavItems({
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
// Track which entity categories have their full list expanded
const [expandedLists, setExpandedLists] = useState<Record<string, boolean>>(
{},
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
loadListExpansionState,
);
// Track popover open state for collapsed sidebar entity categories
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
@@ -356,6 +393,21 @@ function NavItems({
const sectionItems = sidebarConfigList.filter((c) => c.section === section);
function handleListExpansionToggle(id: EntityCategoryId, expanded: boolean) {
setExpandedLists(() => {
const latest = loadListExpansionState();
const next = {
...latest,
[section]: {
...latest[section],
[id]: expanded,
},
};
saveListExpansionState(next);
return next;
});
}
// Persist open state for sections that become active through navigation,
// so they remain expanded when the user switches to a different section.
const sectionOpenRef = useRef(sectionOpenState);
@@ -392,8 +444,9 @@ function NavItems({
}
// Entity categories: collapsible with sub-items
const entityKey = ENTITY_KEY_MAP[config.id];
const isExtensionsCategory = config.id === 'plugins';
const categoryId = config.id;
const entityKey = ENTITY_KEY_MAP[categoryId];
const isExtensionsCategory = categoryId === 'plugins';
const items: SidebarEntityItem[] = isExtensionsCategory
? [
...sidebarData.plugins.map((p) => ({
@@ -410,14 +463,14 @@ function NavItems({
})),
]
: sidebarData[entityKey];
const routePrefix = ENTITY_ROUTE_MAP[config.id];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id);
const canCreate = CREATABLE_CATEGORIES.includes(config.id);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
const isPlugin = config.id === 'plugins';
const isSkill = config.id === 'skills';
const isBot = config.id === 'bots';
const isMCP = config.id === 'mcp';
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
const isPlugin = categoryId === 'plugins';
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
@@ -431,12 +484,12 @@ function NavItems({
: routePrefix;
};
const isActive =
selectedChild?.id === config.id ||
selectedChild?.id === categoryId ||
pathname === routePrefix ||
pathname.startsWith(routePrefix + '/');
// Use stored open state if available, otherwise default to active state
const isOpen = sectionOpenState[config.id] ?? isActive;
const isOpen = sectionOpenState[categoryId] ?? isActive;
// When sidebar is collapsed on desktop and category is collapse-only,
// show a popover flyout instead of the hidden collapsible sub-items
@@ -452,7 +505,7 @@ function NavItems({
}),
)
: sortByRecent(items);
const isExpanded = expandedLists[config.id] ?? false;
const isExpanded = expandedLists[section]?.[categoryId] ?? false;
const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
const visibleItems =
sortedItems.length > maxItems && !isExpanded
@@ -530,7 +583,7 @@ function NavItems({
navigate(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
[categoryId]: false,
}));
}}
>
@@ -692,10 +745,7 @@ function NavItems({
<button
type="button"
onClick={() =>
setExpandedLists((prev) => ({
...prev,
[config.id]: !isExpanded,
}))
handleListExpansionToggle(categoryId, !isExpanded)
}
>
<span className="text-xs">
@@ -711,12 +761,7 @@ function NavItems({
<button
type="button"
className="flex w-full items-center justify-center rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() =>
setExpandedLists((prev) => ({
...prev,
[config.id]: true,
}))
}
onClick={() => handleListExpansionToggle(categoryId, true)}
>
{t('common.more', { count: hiddenCount })}
</button>

View File

@@ -136,9 +136,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div
className={`mx-auto flex w-full items-center gap-2 px-4 ${HOME_CONTENT_MAX_WIDTH}`}
>
<div className="flex w-full items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"