From 51404ed015546b7e025ba859b655aefd56881f89 Mon Sep 17 00:00:00 2001 From: lsamc <2322176165@qq.com> Date: Wed, 21 Oct 2026 21:20:29 -0400 Subject: [PATCH] feat(plugins): allow inline actions beside sidebar section headings --- .../__tests__/sidebar-plugin-section.test.tsx | 22 ++++++++++- .../plugins/plugin-sidebar-sections.tsx | 37 ++++++++++++------- lib/plugin-slot-catalog.ts | 1 + 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/components/layout/__tests__/sidebar-plugin-section.test.tsx b/components/layout/__tests__/sidebar-plugin-section.test.tsx index d17904faf..0ca14bed3 100644 --- a/components/layout/__tests__/sidebar-plugin-section.test.tsx +++ b/components/layout/__tests__/sidebar-plugin-section.test.tsx @@ -31,7 +31,7 @@ const mailboxes: Mailbox[] = [ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } }, ] as unknown as Mailbox[]; -function offer(id: string, name: string, slot: string) { +function offer(id: string, name: string, slot: string, extraSlots: string[] = []) { const plugin: InstalledPlugin = { id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension', permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js', @@ -42,7 +42,7 @@ function offer(id: string, name: string, slot: string) { code: '', tier: 'untrusted', background: { evaluateShouldShow: vi.fn(async () => true) } as never, - slotOffers: [{ name: slot as never, order: 100, hasShouldShow: false }], + slotOffers: [{ name: slot as never, order: 100, hasShouldShow: false }, ...extraSlots.map((name) => ({ name: name as never, order: 100, hasShouldShow: false }))], hookDisposables: [], }); } @@ -84,6 +84,24 @@ describe('Sidebar plugin sections', () => { expect(opts.extraProps).toEqual({ mailboxId: 'inbox' }); }); + it('places inline plugin actions next to the collapsible heading, never inside its button', async () => { + offer('cal', 'Agenda', 'sidebar-section', ['sidebar-section-actions']); + const { container } = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2)); + + const section = container.querySelector('[data-plugin-sidebar-section="cal"]')!; + const header = screen.getByRole('button', { name: 'Agenda' }); + const actions = section.querySelector('[data-plugin-sidebar-actions="cal"]')!; + expect(actions).toBeInTheDocument(); + expect(header.contains(actions)).toBe(false); + expect(header.parentElement).toBe(actions.parentElement); + expect(section.querySelector('[data-plugin-iframe-slot="cal:sidebar-section-actions"]')).toBeInTheDocument(); + + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(section.querySelector('[data-plugin-sidebar-actions="cal"]')).toBeNull(); + }); + it('folds a section and remembers it per plugin', async () => { offer('cal', 'Agenda', 'sidebar-section'); const first = render(); diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx index f52f3bff9..bb77c2287 100644 --- a/components/plugins/plugin-sidebar-sections.tsx +++ b/components/plugins/plugin-sidebar-sections.tsx @@ -30,6 +30,7 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr const bodyId = useId(); const title = useSyncExternalStore(subscribeSectionTitles, () => sectionTitle(pluginId), () => undefined); const name = title || getActivePlugin(pluginId)?.plugin.name || pluginId; + const hasActions = !!getActivePlugin(pluginId)?.slotOffers.some((o) => o.name === 'sidebar-section-actions'); const toggle = () => { setCollapsed((prev) => { @@ -40,21 +41,29 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr return (
- {/* Same look as the sidebar's own section headers (Folders, Tags). */} - + {hasActions && !collapsed && ( +
+ +
)} - {name} - +
{/* A collapsed section has no iframe at all, so it costs nothing. */}