From 37846bec44d956d7209329cc21f8f8f964f105c9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:45:24 +0200 Subject: [PATCH 1/7] feat: let plugins add collapsible sections to the mail sidebar --- .../__tests__/sidebar-plugin-section.test.tsx | 99 +++++++++++++++++++ components/layout/sidebar.tsx | 2 + .../plugins/plugin-sidebar-sections.tsx | 83 ++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 components/layout/__tests__/sidebar-plugin-section.test.tsx create mode 100644 components/plugins/plugin-sidebar-sections.tsx diff --git a/components/layout/__tests__/sidebar-plugin-section.test.tsx b/components/layout/__tests__/sidebar-plugin-section.test.tsx new file mode 100644 index 000000000..412f2f42e --- /dev/null +++ b/components/layout/__tests__/sidebar-plugin-section.test.tsx @@ -0,0 +1,99 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const registry = await import('@/lib/plugin-sandbox/registry'); + +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) { + 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', + enabled: true, status: 'running', settings: {}, + }; + registry.register({ + plugin, + code: '', + tier: 'untrusted', + background: { evaluateShouldShow: vi.fn(async () => true) } as never, + slotOffers: [{ name: slot as never, order: 100, hasShouldShow: false }], + hookDisposables: [], + }); +} + +beforeEach(() => { + createSlotInstance.mockClear(); + localStorage.clear(); +}); + +afterEach(() => { + for (const p of registry.all()) registry.deregister(p.plugin.id); +}); + +describe('Sidebar plugin sections', () => { + it('draws nothing when no plugin offers a section', () => { + offer('w', 'Widget only', 'sidebar-widget'); + const { container } = render(); + + expect(container.querySelector('[data-plugin-slot="sidebar-section"]')).toBeNull(); + expect(screen.queryByText('Widget only')).toBeNull(); + }); + + it('gives each plugin a section headed by its name, below the folders, with the open folder', async () => { + offer('cal', 'Agenda', 'sidebar-section'); + const { container } = render(); + + const header = screen.getByRole('button', { name: 'Agenda' }); + expect(header).toHaveAttribute('aria-expanded', 'true'); + const section = container.querySelector('[data-plugin-slot="sidebar-section"]')!; + // Below the folder list. + const inbox = container.querySelector('[data-mailbox-id="inbox"]')!; + expect(inbox.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record; plugin: { id: string } }; + expect(opts.slot).toBe('sidebar-section'); + expect(opts.plugin.id).toBe('cal'); + expect(opts.extraProps).toEqual({ mailboxId: 'inbox' }); + }); + + it('folds a section and remembers it per plugin', async () => { + offer('cal', 'Agenda', 'sidebar-section'); + const first = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1)); + + act(() => { fireEvent.click(screen.getByRole('button', { name: 'Agenda' })); }); + expect(screen.getByRole('button', { name: 'Agenda' })).toHaveAttribute('aria-expanded', 'false'); + first.unmount(); + + createSlotInstance.mockClear(); + render(); + expect(screen.getByRole('button', { name: 'Agenda' })).toHaveAttribute('aria-expanded', 'false'); + await new Promise((r) => setTimeout(r, 20)); + expect(createSlotInstance).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a5cfd249c..e5969acfb 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, Fragment, ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginSidebarSections } from "@/components/plugins/plugin-sidebar-sections"; import { Button } from "@/components/ui/button"; import { Inbox, @@ -1709,6 +1710,7 @@ export function Sidebar({ )} + {!isCollapsed && } {!isCollapsed && } diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx new file mode 100644 index 000000000..7476af003 --- /dev/null +++ b/components/plugins/plugin-sidebar-sections.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useId, useMemo, useState, useSyncExternalStore } from 'react'; +import { ChevronDown, ChevronRight } from '@/components/icons'; +import { get as getActivePlugin, offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; +import { PluginIframeSlot } from './plugin-iframe-slot'; + +const STORAGE_PREFIX = 'plugin-sidebar-section-collapsed:'; + +function readCollapsed(pluginId: string): boolean { + try { + return localStorage.getItem(STORAGE_PREFIX + pluginId) === '1'; + } catch { + return false; + } +} + +function writeCollapsed(pluginId: string, collapsed: boolean): void { + try { + if (collapsed) localStorage.setItem(STORAGE_PREFIX + pluginId, '1'); + else localStorage.removeItem(STORAGE_PREFIX + pluginId); + } catch { + // Storage may be blocked; the section just opens expanded next time. + } +} + +function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extraProps: Record }) { + const [collapsed, setCollapsed] = useState(() => readCollapsed(pluginId)); + const bodyId = useId(); + const name = getActivePlugin(pluginId)?.plugin.name || pluginId; + + const toggle = () => { + setCollapsed((prev) => { + writeCollapsed(pluginId, !prev); + return !prev; + }); + }; + + return ( +
+ {/* Same look as the sidebar's own section headers (Folders, Tags). */} + + {/* A collapsed section has no iframe at all, so it costs nothing. */} + +
+ ); +} + +/** + * The `sidebar-section` slot: one collapsible section per plugin in the mail + * sidebar, below the folders and tags. The header carries the plugin's name; + * whether it is folded is remembered per plugin in this browser. + */ +export function PluginSidebarSections({ mailboxId }: { mailboxId: string | null }) { + const getSnapshot = () => offersForSlot('sidebar-section'); + const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const extraProps = useMemo(() => ({ mailboxId }), [mailboxId]); + + if (offers.length === 0) return null; + + return ( +
+ {offers.map((offer) => ( + + ))} +
+ ); +} From b57fa6455791a524093c87f90de662d83693a376 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:50:21 +0200 Subject: [PATCH 2/7] feat: let plugins add buttons to the top bar, and to the header menu on phones --- .../__tests__/top-bar-plugin-actions.test.tsx | 72 ++++++++++++ components/layout/mobile-header.tsx | 64 ++++++++++- components/layout/mv-top-bar.tsx | 7 ++ .../mail-app-top-bar-actions.test.tsx | 105 ++++++++++++++++++ components/mail/mail-app.tsx | 9 ++ locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + 32 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 components/layout/__tests__/top-bar-plugin-actions.test.tsx create mode 100644 components/mail/__tests__/mail-app-top-bar-actions.test.tsx diff --git a/components/layout/__tests__/top-bar-plugin-actions.test.tsx b/components/layout/__tests__/top-bar-plugin-actions.test.tsx new file mode 100644 index 000000000..3835b22cc --- /dev/null +++ b/components/layout/__tests__/top-bar-plugin-actions.test.tsx @@ -0,0 +1,72 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; + +// The top bar's plugin buttons (top-bar-actions): next to the search field in +// Mountain View's bar, and in the phone header's overflow menu. + +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false })); + +const { MvTopBar } = await import('../mv-top-bar'); +const { MobileHeader } = await import('../mobile-header'); + +const dispatch = vi.fn().mockResolvedValue(undefined); + +const search = { + query: '', onQueryChange: vi.fn(), onSubmit: vi.fn(), onClear: vi.fn(), onSelectContact: vi.fn(), + onToggleFilters: vi.fn(), filtersOpen: false, activeFilterCount: 0, +}; + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); +}); + +describe('Mountain View top bar', () => { + it('has no plugin buttons when no plugin declared any', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull(); + }); + + it('puts plugin buttons right after the search field and sends the click', async () => { + act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate', icon: 'Languages' }])); + const { container } = render(); + + const group = container.querySelector('[data-plugin-actions="top-bar-actions"]')!; + expect(group).not.toBeNull(); + const searchField = screen.getByTestId('search'); + expect(searchField.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + const button = screen.getByRole('button', { name: 'Translate' }); + // Compact: icon only, the label as tooltip. + expect(button).toHaveAttribute('title', 'Translate (Translator)'); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('tr', 'top-bar-actions', 'go', {}); + }); +}); + +describe('phone header', () => { + it('has no overflow menu when no plugin declared buttons', () => { + render(); + expect(screen.queryByRole('button', { name: 'more' })).toBeNull(); + }); + + it('lists plugin buttons in its overflow menu', async () => { + act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }])); + render(); + + const more = screen.getByRole('button', { name: 'more' }); + expect(screen.queryByRole('menuitem', { name: 'Translate' })).toBeNull(); + fireEvent.click(more); + expect(more).toHaveAttribute('aria-expanded', 'true'); + + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Translate' })); }); + expect(dispatch).toHaveBeenCalledWith('tr', 'top-bar-actions', 'go', {}); + // The menu closes on the click. + expect(screen.queryByRole('menu')).toBeNull(); + }); +}); diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 7e929ddae..288e6b78d 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -1,6 +1,7 @@ "use client"; -import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Menu, ArrowLeft, Plus, Search, X, MoreVertical } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useUIStore } from "@/stores/ui-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -8,6 +9,65 @@ import { useIsDesktop } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { AccountSwitcher } from "@/components/layout/account-switcher"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; + +/** Plugin buttons in the top bar are not about any one item. */ +const TOP_BAR_CONTEXT: Record = {}; + +/** + * The phone header's overflow menu: what the desktop top bar shows as + * buttons (plugin top-bar-actions) and has no room for here. Renders nothing + * while it would be empty, so the header keeps its layout without plugins. + */ +function MobileHeaderMenu() { + const t = useTranslations('top_bar'); + const pluginActions = usePluginActions('top-bar-actions'); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const close = useCallback(() => setOpen(false), []); + const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef }); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return; + setOpen(false); + }; + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [open, menuRef]); + + if (pluginActions.length === 0) return null; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} interface MobileHeaderProps { title: string; @@ -166,6 +226,8 @@ export function MobileHeader({ )} + + {/* Which account you are reading is otherwise only visible after opening the drawer. Last in this group so it sits on the far edge — opposite the menu button, and mirrored under RTL because diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index d1a0b484d..f5d15678a 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -31,8 +31,12 @@ import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { cn } from "@/lib/utils"; import { AccountSwitcher } from "@/components/layout/account-switcher"; import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; +import { PluginActions } from "@/components/plugins/plugin-actions"; import type { ContactSuggestion } from "@/lib/search-suggestions"; +/** The top bar is not about any one item: plugin clicks carry no context. */ +const TOP_BAR_CONTEXT: Record = {}; + /** Mail search, which only the mail surface puts into the bar. */ interface MvTopBarSearch { query: string; @@ -229,6 +233,9 @@ export function MvTopBar({ )} + {/* Plugin buttons (top-bar-actions), next to the search field */} + + {/* Spacer keeps the right cluster pinned while search stays left */}
diff --git a/components/mail/__tests__/mail-app-top-bar-actions.test.tsx b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx new file mode 100644 index 000000000..c131de9dd --- /dev/null +++ b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { resetActions, setPluginActions } from '@/lib/plugin-sandbox/actions'; + +// The standard layout has no global top bar: its search row sits on top of +// the message list. Plugin top-bar buttons go there, after refresh. + +const device = vi.hoisted(() => ({ isMobile: false })); + +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), + useMediaQuery: () => false, + useIsDesktop: () => !device.isMobile, + useIsMobile: () => device.isMobile, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; + +beforeEach(() => { + resetActions(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', interfaceLayout: 'default' } as never); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + device.isMobile = false; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('top-bar-actions in the standard layout', () => { + it('draws nothing in the search row without plugins', () => { + const { container } = render(); + expect(screen.getByTestId('search')).toBeInTheDocument(); + expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull(); + }); + + it('puts plugin buttons in the search row after refresh', () => { + act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }])); + const { container } = render(); + + const group = container.querySelector('[data-plugin-actions="top-bar-actions"]')!; + expect(group).not.toBeNull(); + const refresh = screen.getByRole('button', { name: 'refresh' }); + expect(group.parentElement).toBe(refresh.parentElement); + expect(refresh.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Translate' })).toBeInTheDocument(); + }); + + it('leaves them to the header menu on phones', () => { + device.isMobile = true; + act(() => setPluginActions('tr', 'Translator', 'top-bar-actions', [{ id: 'go', label: 'Translate' }])); + const { container } = render(); + expect(container.querySelector('[data-plugin-actions="top-bar-actions"]')).toBeNull(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index a4a7799cf..6ea61f7b8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -82,6 +82,7 @@ import { useSearchHistoryStore } from "@/stores/search-history-store"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { MvTopBar } from "@/components/layout/mv-top-bar"; +import { PluginActions } from "@/components/plugins/plugin-actions"; import { MvSearchForm } from "@/components/search/mv-search-form"; import { MvSidePanel } from "@/components/layout/mv-side-panel"; import { MvListToolbar } from "@/components/email/mv-list-toolbar"; @@ -179,6 +180,9 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {}; + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -4409,6 +4413,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { > + {/* Plugin buttons (top-bar-actions). On phones they sit in + the header's overflow menu instead (MobileHeader). */} + {!isMobile && ( + + )}
)} diff --git a/locales/ar/common.json b/locales/ar/common.json index e1e898aa5..583ab9ffc 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "اختر نتيجة لمعاينتها", "open_full": "فتح", "view_label": "العرض" + }, + "top_bar": { + "more": "خيارات أخرى" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 04062c552..2bfbc2ce4 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Seleccioneu un resultat per previsualitzar-lo", "open_full": "Obre", "view_label": "Vista" + }, + "top_bar": { + "more": "Més opcions" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 2c70e6e2c..654081fe4 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Vyberte výsledek pro náhled", "open_full": "Otevřít", "view_label": "Zobrazení" + }, + "top_bar": { + "more": "Další možnosti" } } diff --git a/locales/da/common.json b/locales/da/common.json index 84ffb09d1..cbd60c123 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Vælg et resultat for at se det", "open_full": "Åbn", "view_label": "Visning" + }, + "top_bar": { + "more": "Flere muligheder" } } diff --git a/locales/de/common.json b/locales/de/common.json index d89bc781e..fa2a61c8d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Ergebnis auswählen, um es anzuzeigen", "open_full": "Öffnen", "view_label": "Ansicht" + }, + "top_bar": { + "more": "Weitere Optionen" } } diff --git a/locales/en/common.json b/locales/en/common.json index c907ec0e0..af46b4fe9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Select a result to preview it", "open_full": "Open", "view_label": "View" + }, + "top_bar": { + "more": "More options" } } diff --git a/locales/es/common.json b/locales/es/common.json index 381b16765..0f7c452ec 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Selecciona un resultado para previsualizarlo", "open_full": "Abrir", "view_label": "Vista" + }, + "top_bar": { + "more": "Más opciones" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index abe439825..ad9e14027 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "برای پیش‌نمایش یک نتیجه انتخاب کنید", "open_full": "باز کردن", "view_label": "نما" + }, + "top_bar": { + "more": "گزینه‌های بیشتر" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 5212cee79..bb22839fa 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Sélectionnez un résultat pour l'aperçu", "open_full": "Ouvrir", "view_label": "Affichage" + }, + "top_bar": { + "more": "Plus d'options" } } diff --git a/locales/he/common.json b/locales/he/common.json index 20b4537eb..ce9002d42 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4341,5 +4341,8 @@ "preview_empty": "בחרו תוצאה לתצוגה מקדימה", "open_full": "פתיחה", "view_label": "תצוגה" + }, + "top_bar": { + "more": "אפשרויות נוספות" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index a32c5cd47..788139e7a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Válasszon egy találatot az előnézethez", "open_full": "Megnyitás", "view_label": "Nézet" + }, + "top_bar": { + "more": "További lehetőségek" } } diff --git a/locales/it/common.json b/locales/it/common.json index 38fb72610..604eefe86 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Seleziona un risultato per l'anteprima", "open_full": "Apri", "view_label": "Vista" + }, + "top_bar": { + "more": "Altre opzioni" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 518dc7fff..bea68df1d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "プレビューする結果を選択してください", "open_full": "開く", "view_label": "表示" + }, + "top_bar": { + "more": "その他のオプション" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 9490975e9..ec9834048 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "미리 볼 결과를 선택하세요", "open_full": "열기", "view_label": "보기" + }, + "top_bar": { + "more": "추가 옵션" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index ab8308a8b..5ed7b5529 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Izvēlieties rezultātu, lai to priekšskatītu", "open_full": "Atvērt", "view_label": "Skats" + }, + "top_bar": { + "more": "Citas iespējas" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index e4a56f2dd..616d18243 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Урьдчилан харах үр дүнгээ сонгоно уу", "open_full": "Нээх", "view_label": "Харагдац" + }, + "top_bar": { + "more": "Бусад сонголт" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 9d210b7e5..66ce2e760 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Velg et resultat for å forhåndsvise det", "open_full": "Åpne", "view_label": "Visning" + }, + "top_bar": { + "more": "Flere alternativer" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index d48e2535b..cd97ea378 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Selecteer een resultaat voor een voorbeeld", "open_full": "Openen", "view_label": "Weergave" + }, + "top_bar": { + "more": "Meer opties" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index bb04b7de6..9fe2b1232 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Wybierz wynik, aby zobaczyć podgląd", "open_full": "Otwórz", "view_label": "Widok" + }, + "top_bar": { + "more": "Więcej opcji" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 3b168875e..ca3e0c17e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Selecione um resultado para pré-visualizar", "open_full": "Abrir", "view_label": "Vista" + }, + "top_bar": { + "more": "Mais opções" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 3fef212eb..7ba263006 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Selectează un rezultat pentru previzualizare", "open_full": "Deschide", "view_label": "Vizualizare" + }, + "top_bar": { + "more": "Mai multe opțiuni" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 9e611a41b..634e4b8b6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Выберите результат для предпросмотра", "open_full": "Открыть", "view_label": "Вид" + }, + "top_bar": { + "more": "Другие действия" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index d9ad29d8e..cb8d4a361 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Vyberte výsledok pre náhľad", "open_full": "Otvoriť", "view_label": "Zobrazenie" + }, + "top_bar": { + "more": "Ďalšie možnosti" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index ad0c5dbf6..38f314055 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Önizlemek için bir sonuç seçin", "open_full": "Aç", "view_label": "Görünüm" + }, + "top_bar": { + "more": "Diğer seçenekler" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index c9d02417d..e9859b6c2 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "Виберіть результат для перегляду", "open_full": "Відкрити", "view_label": "Вигляд" + }, + "top_bar": { + "more": "Інші дії" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 03ce06f43..78d083a6b 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "選擇結果以預覽", "open_full": "開啟", "view_label": "檢視" + }, + "top_bar": { + "more": "更多選項" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 8e42b7e40..6b07004aa 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4340,5 +4340,8 @@ "preview_empty": "选择一个结果以预览", "open_full": "打开", "view_label": "视图" + }, + "top_bar": { + "more": "更多选项" } } From b8b7a4ed1569f5e17d0efbea798069607736c8ec Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:57:00 +0200 Subject: [PATCH 3/7] feat: let plugins add pages of their own with an entry in the navigation --- .../[locale]/apps/[[...segments]]/page.tsx | 26 +++ .../navigation-plugin-pages.test.tsx | 67 +++++++ components/layout/mv-top-bar.tsx | 26 +++ components/layout/navigation-rail.tsx | 70 +++++++- .../__tests__/plugin-page-app.test.tsx | 104 +++++++++++ components/plugins/plugin-page-app.tsx | 165 ++++++++++++++++++ hooks/use-plugin-pages.ts | 20 +++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + 34 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 app/(main)/[locale]/apps/[[...segments]]/page.tsx create mode 100644 components/layout/__tests__/navigation-plugin-pages.test.tsx create mode 100644 components/plugins/__tests__/plugin-page-app.test.tsx create mode 100644 components/plugins/plugin-page-app.tsx create mode 100644 hooks/use-plugin-pages.ts diff --git a/app/(main)/[locale]/apps/[[...segments]]/page.tsx b/app/(main)/[locale]/apps/[[...segments]]/page.tsx new file mode 100644 index 000000000..bd91cb2f0 --- /dev/null +++ b/app/(main)/[locale]/apps/[[...segments]]/page.tsx @@ -0,0 +1,26 @@ +import { PluginPageApp } from "@/components/plugins/plugin-page-app"; +import { generateLiteSegmentParams } from "@/lib/lite-static-params"; + +// Pages plugins add with api.ui.setPages: /apps//[/]. +// Anything after the page id reaches the plugin's `app-page` slot as `path`. +// A catch-all rather than [pluginId]/[pageId] so the static Lite export can +// prerender the bare route; plugins never run in Lite, so it only ever shows +// the "not available" note there (lib/lite-static-params.ts). +export const generateStaticParams = generateLiteSegmentParams; + +function decodeOnce(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export default async function PluginPageRoute({ + params, +}: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments } = await params; + return ; +} diff --git a/components/layout/__tests__/navigation-plugin-pages.test.tsx b/components/layout/__tests__/navigation-plugin-pages.test.tsx new file mode 100644 index 000000000..e0e84e679 --- /dev/null +++ b/components/layout/__tests__/navigation-plugin-pages.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Every plugin page gets an entry in the navigation, after the built-in apps: +// the rail (desktop), the bottom bar (phones) and Mountain View's app grid. + +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false })); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }), + usePathname: () => '/apps/kanban/board', + Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => {children}, +})); + +const { NavigationRail } = await import('../navigation-rail'); +const { MvTopBar } = await import('../mv-top-bar'); +const pages = await import('@/lib/plugin-sandbox/pages'); +const { useUpdateStore } = await import('@/stores/update-store'); + +beforeEach(() => { + useUpdateStore.setState({ startPolling: vi.fn() } as never); +}); +afterEach(() => pages.resetPages()); + +const register = () => act(() => pages.setPluginPages('kanban', 'Kanban', [ + { id: 'board', title: 'Board', icon: 'LayoutGrid' }, + { id: 'stats', title: 'Stats' }, +])); + +describe.each(['vertical', 'horizontal'] as const)('navigation rail (%s)', (orientation) => { + it('lists no plugin pages when none are registered', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-page-link]')).toBeNull(); + }); + + it('links each page after the built-in apps and marks the open one', () => { + register(); + const { container } = render(); + + const links = [...container.querySelectorAll('[data-plugin-page-link]')]; + expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']); + const mail = container.querySelector('a[href="/"]')!; + expect(mail.compareDocumentPosition(links[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(links[0]).toHaveAttribute('aria-current', 'page'); + expect(links[1]).not.toHaveAttribute('aria-current'); + // Title in the entry (as label on the collapsed rail). + expect(screen.getAllByText('Board').length + screen.queryAllByLabelText('Board').length).toBeGreaterThan(0); + }); +}); + +describe('Mountain View app grid', () => { + it('offers plugin pages next to the built-in apps', () => { + register(); + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'add_app' })); + + const links = [...container.querySelectorAll('[data-plugin-page-link]')]; + expect(links.map((a) => a.getAttribute('href'))).toEqual(['/apps/kanban/board', '/apps/kanban/stats']); + expect(links[0]).toHaveAttribute('role', 'menuitem'); + expect(screen.getByText('Stats')).toBeInTheDocument(); + }); +}); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index f5d15678a..d97aefa67 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -9,6 +9,7 @@ import { HelpCircle, Mail, Menu, + Puzzle, Settings, Shield, SlidersHorizontal, @@ -32,6 +33,7 @@ import { cn } from "@/lib/utils"; import { AccountSwitcher } from "@/components/layout/account-switcher"; import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; import { PluginActions } from "@/components/plugins/plugin-actions"; +import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; import type { ContactSuggestion } from "@/lib/search-suggestions"; /** The top bar is not about any one item: plugin clicks carry no context. */ @@ -111,6 +113,7 @@ export function MvTopBar({ // The same gate the navigation rail puts on its "add app" button. const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled")); const sidebarApps = useResolvedSidebarApps(); + const pluginPages = usePluginPages(); const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); // The navigation rail starts the update check, and this bar replaces it on @@ -330,6 +333,29 @@ export function MvTopBar({ ); })} + {pluginPages.map((page) => { + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { + if (activeAppId) onCloseInlineApp?.(); + setAppsOpen(false); + }} + className={cn( + "flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted", + !activeAppId && isPluginPageActive(pathname, page) && "bg-muted" + )} + > + + {page.title} + + ); + })} {sidebarApps.map((app) => { const AppIcon = iconForName(app.icon); return ( diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 2021e30ed..765f6f2a6 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; import { createPortal } from "react-dom"; -import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search } from "@/components/icons"; +import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check, Search, Puzzle } from "@/components/icons"; import { AccountSwitcher } from "./account-switcher"; import { iconForName } from "@/components/icons"; import { useConfig } from "@/hooks/use-config"; @@ -27,6 +27,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { Avatar } from "@/components/ui/avatar"; import { toUnicodeEmail } from "@/lib/idn"; +import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; interface NavItem { id: string; @@ -220,6 +221,8 @@ export function NavigationRail({ // Operator-provided apps (#931) plus the user's own; the gate above only // removes the user's, so a pinned set still shows when custom apps are off. const visibleSidebarApps = useResolvedSidebarApps(); + // Pages plugins added (api.ui.setPages); listed after the built-in apps. + const pluginPages = usePluginPages(); const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); @@ -382,6 +385,40 @@ export function NavigationRail({ ); })} + {/* Plugin pages, after the built-in apps */} + {pluginPages.map((page) => { + const isActive = !activeAppId && isPluginPageActive(pathname, page); + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { if (activeAppId) onCloseInlineApp?.(); }} + data-plugin-page-link={`${page.pluginId}/${page.id}`} + title={page.pluginName} + className={cn( + "flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]", + "transition-colors duration-150", + isActive + ? "text-primary" + : "text-muted-foreground hover:text-foreground" + )} + aria-current={isActive ? "page" : undefined} + > +
+ + {isActive && !mountainView && ( + + )} +
+ {page.title} + + ); + })} + {/* Custom sidebar apps (per-app mobile visibility) */} {visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => { const AppIcon = iconForName(app.icon); @@ -554,6 +591,37 @@ export function NavigationRail({ ); })} + {/* Plugin pages, after the built-in apps */} + {pluginPages.map((page) => { + const isActive = !activeAppId && isPluginPageActive(pathname, page); + const PageIcon = iconForName(page.icon) ?? Puzzle; + return ( + { if (activeAppId) onCloseInlineApp?.(); }} + data-plugin-page-link={`${page.pluginId}/${page.id}`} + className={cn( + "relative flex items-center gap-2.5 rounded-md transition-colors duration-150", + collapsed + ? "justify-center w-10 h-10" + : "px-2.5 text-sm", + "max-lg:min-h-[44px]", + isActive + ? "bg-primary/10 text-primary font-medium" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + )} + aria-current={isActive ? "page" : undefined} + aria-label={collapsed ? page.title : undefined} + title={collapsed ? `${page.title} (${page.pluginName})` : page.pluginName} + style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }} + > + + {!collapsed && {page.title}} + + ); + })} + {/* Custom sidebar apps */} {visibleSidebarApps.length > 0 && (
/, inside the +// app's own frame. + +const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); +vi.mock('@/components/layout/navigation-rail', () => ({ + NavigationRail: ({ orientation }: { orientation?: string }) =>