feat(web): group plugin pages by plugin in sidebar with collapsible sections

- Group pages by plugin when a plugin has multiple pages, collapse under
  the plugin label; single-page plugins render directly without nesting
- Rename "Extension Pages" to "Plugin Pages" with tooltip explaining
  these are visual pages provided by installed plugins
- Add pluginLabel to PluginPageItem for display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-04-25 20:06:03 +08:00
parent 12df9d6ee9
commit 5f3cecfbe2
4 changed files with 89 additions and 24 deletions
@@ -92,7 +92,7 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { LayoutDashboard } from 'lucide-react'; import { LayoutDashboard, Puzzle } from 'lucide-react';
// Compare two version strings, returns true if v1 > v2 // Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean { function compareVersions(v1: string, v2: string): boolean {
@@ -1040,7 +1040,7 @@ function PluginItemMenu({
); );
} }
// Plugin pages navigation section // Plugin pages navigation section — grouped by plugin
function PluginPagesNav() { function PluginPagesNav() {
const { pluginPages } = useSidebarData(); const { pluginPages } = useSidebarData();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1054,27 +1054,87 @@ function PluginPagesNav() {
const currentId = const currentId =
pathname === '/home/plugin-pages' ? searchParams.get('id') : null; pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
// Group pages by plugin (author/name)
const grouped = new Map<
string,
{ label: string; pages: typeof pluginPages }
>();
for (const page of pluginPages) {
const key = `${page.pluginAuthor}/${page.pluginName}`;
if (!grouped.has(key)) {
grouped.set(key, { label: page.pluginLabel, pages: [] });
}
grouped.get(key)!.pages.push(page);
}
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{t('sidebar.pluginPages')}</SidebarGroupLabel> <SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
{t('sidebar.pluginPages')}
</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{pluginPages.map((page) => { {Array.from(grouped.entries()).map(
const isActive = currentId === page.id; ([pluginKey, { label, pages }]) => {
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`; const hasActivePage = pages.some((p) => p.id === currentId);
return (
<SidebarMenuItem key={page.id}> // Single page — render directly without nesting
<SidebarMenuButton if (pages.length === 1) {
isActive={isActive} const page = pages[0];
tooltip={page.name} const isActive = currentId === page.id;
onClick={() => navigate(route)} const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuItem key={page.id}>
<SidebarMenuButton
isActive={isActive}
tooltip={page.name}
onClick={() => navigate(route)}
>
<LayoutDashboard className="size-4 text-blue-500" />
<span>{page.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// Multiple pages — collapsible group
return (
<Collapsible
key={pluginKey}
defaultOpen={hasActivePage}
className="group/collapsible"
> >
<LayoutDashboard className="size-4 text-blue-500" /> <SidebarMenuItem>
<span>{page.name}</span> <CollapsibleTrigger asChild>
</SidebarMenuButton> <SidebarMenuButton tooltip={label}>
</SidebarMenuItem> <Puzzle className="size-4 text-blue-500" />
); <span>{label}</span>
})} <ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{pages.map((page) => {
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
isActive={isActive}
onClick={() => navigate(route)}
>
<span>{page.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
},
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -37,6 +37,7 @@ export interface PluginPageItem {
name: string; // display label name: string; // display label
pluginAuthor: string; pluginAuthor: string;
pluginName: string; pluginName: string;
pluginLabel: string; // human-readable plugin display name
pageId: string; pageId: string;
path: string; // asset path (HTML file) path: string; // asset path (HTML file)
icon?: string; // optional icon name icon?: string; // optional icon name
@@ -191,6 +192,7 @@ export function SidebarDataProvider({
const meta = plugin.manifest.manifest.metadata; const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? ''; const author = meta.author ?? '';
const name = meta.name; const name = meta.name;
const label = meta.label ? extractI18nObject(meta.label) : name;
const spec = plugin.manifest.manifest.spec; const spec = plugin.manifest.manifest.spec;
if (spec?.pages && Array.isArray(spec.pages)) { if (spec?.pages && Array.isArray(spec.pages)) {
for (const page of spec.pages) { for (const page of spec.pages) {
@@ -202,6 +204,7 @@ export function SidebarDataProvider({
name: page.label ? extractI18nObject(page.label) : page.id, name: page.label ? extractI18nObject(page.label) : page.id,
pluginAuthor: author, pluginAuthor: author,
pluginName: name, pluginName: name,
pluginLabel: label,
pageId: page.id, pageId: page.id,
path: page.path, path: page.path,
icon: page.icon, icon: page.icon,
+4 -3
View File
@@ -5,7 +5,8 @@ const enUS = {
installedPlugins: 'Installed Plugins', installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace', pluginMarket: 'Marketplace',
mcpServers: 'MCP Servers', mcpServers: 'MCP Servers',
pluginPages: 'Extension Pages', pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
quickStart: 'Quick Start', quickStart: 'Quick Start',
}, },
common: { common: {
@@ -1294,8 +1295,8 @@ const enUS = {
}, },
}, },
pluginPages: { pluginPages: {
selectFromSidebar: 'Select an extension page from the sidebar', selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid extension page', invalidPage: 'Invalid plugin page',
}, },
}; };
+4 -3
View File
@@ -5,7 +5,8 @@ const zhHans = {
installedPlugins: '已安装插件', installedPlugins: '已安装插件',
pluginMarket: '插件市场', pluginMarket: '插件市场',
mcpServers: 'MCP 服务器', mcpServers: 'MCP 服务器',
pluginPages: '扩展页', pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
quickStart: '快速开始向导', quickStart: '快速开始向导',
}, },
common: { common: {
@@ -1236,8 +1237,8 @@ const zhHans = {
}, },
}, },
pluginPages: { pluginPages: {
selectFromSidebar: '从侧边栏选择一个扩展页', selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的扩展页', invalidPage: '无效的插件页面',
}, },
}; };