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). */}
-