From eb34caa3817c8b501803fb0efc1e695acb45948b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:58:21 +0200 Subject: [PATCH] feat: let plugins add entries to file menus and the selection toolbar, and a section to file previews --- .../__tests__/files-plugin-surfaces.test.tsx | 247 ++++++++++++++++++ components/files/file-browser.tsx | 27 +- components/files/file-details-panel.tsx | 9 +- components/files/file-preview-modal.tsx | 7 +- .../files/file-preview-plugin-section.tsx | 25 ++ components/files/files-app.tsx | 13 +- components/files/files-header.tsx | 15 +- components/files/image-preview-modal.tsx | 7 +- lib/__tests__/plugin-slot-catalog.test.ts | 2 +- stores/file-store.ts | 2 +- 10 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 components/files/__tests__/files-plugin-surfaces.test.tsx create mode 100644 components/files/file-preview-plugin-section.tsx diff --git a/components/files/__tests__/files-plugin-surfaces.test.tsx b/components/files/__tests__/files-plugin-surfaces.test.tsx new file mode 100644 index 000000000..c2bcee520 --- /dev/null +++ b/components/files/__tests__/files-plugin-surfaces.test.tsx @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { FileBrowser } from '../file-browser'; +import { FileDetailsPanel } from '../file-details-panel'; +import { FilePreviewModal } from '../file-preview-modal'; +import { ImagePreviewModal } from '../image-preview-modal'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileResourceView } from '@/lib/plugin-types'; + +// Plugin surfaces in Files: entries in an item's menu and buttons in the +// selection toolbar (file-actions), and a section in the previews and the +// details panel (file-preview-section). + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const folder = { + id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0, + lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null, +} as FileResource; +const notes = { + id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null, +} as FileResource; +const photo = { + id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99, + lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null, +} as FileResource; + +const notesView: FileResourceView = { + id: 'f-notes', name: 'notes.txt', type: 'file', size: 10, mimeType: 'text/plain', path: '/', modified: '2026-10-01T00:00:00Z', +}; + +function renderBrowser(selected: string[] = []) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ + client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true, + }); + }); + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + />, + ); +} + +function rowOf(name: string): HTMLElement { + return within(screen.getByRole('grid', { name: 'file_list' })).getByText(name).closest('[role=row]') as HTMLElement; +} + +const dispatch = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + localStorage.clear(); + dispatch.mockClear(); + setActionDispatcher(dispatch); +}); + +afterEach(() => { + cleanup(); + clearSlotOffers(); + resetActions(); + setActionDispatcher(null); + act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); }); +}); + +describe('file-actions', () => { + it('adds entries to an item menu, filtered by its type, and sends the item', async () => { + act(() => setPluginActions('ocr', 'OCR', 'file-actions', [ + { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }, + { id: 'edit-image', label: 'Edit image', mimeTypes: ['image/'] }, + { id: 'archive', label: 'Archive' }, + ])); + renderBrowser(); + + fireEvent.contextMenu(rowOf('notes.txt')); + expect(await screen.findByRole('menuitem', { name: 'Read text' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Edit image' })).toBeNull(); + // After the app's own entries. + const items = screen.getAllByRole('menuitem').map((el) => el.textContent); + expect(items.indexOf('Archive')).toBeGreaterThan(items.findIndex((text) => text?.includes('delete'))); + + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Read text' })); }); + expect(dispatch).toHaveBeenCalledWith('ocr', 'file-actions', 'read-text', { files: [notesView] }); + }); + + it('keeps type-specific entries off folders', async () => { + act(() => setPluginActions('ocr', 'OCR', 'file-actions', [ + { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }, + { id: 'archive', label: 'Archive' }, + ])); + renderBrowser(); + + fireEvent.contextMenu(rowOf('Documents')); + expect(await screen.findByRole('menuitem', { name: 'Archive' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Read text' })).toBeNull(); + }); + + it('adds buttons to the selection toolbar with the selected items', async () => { + act(() => setPluginActions('ocr', 'OCR', 'file-actions', [ + { id: 'archive', label: 'Archive' }, + { id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }, + ])); + renderBrowser(['notes.txt', 'photo.png']); + + const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' }); + const button = within(toolbar).getByRole('button', { name: 'Archive' }); + // Several items: no type filter applies, so every entry shows. + expect(within(toolbar).getByRole('button', { name: 'Read text' })).toBeInTheDocument(); + + await act(async () => { fireEvent.click(button); }); + const context = dispatch.mock.calls[0][3] as { files: FileResourceView[] }; + expect(context.files.map((f) => f.id)).toEqual(['f-notes', 'f-photo']); + }); + + it('filters the toolbar by the type of a single selected file', () => { + act(() => setPluginActions('ocr', 'OCR', 'file-actions', [{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }])); + renderBrowser(['photo.png']); + + const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' }); + expect(within(toolbar).queryByRole('button', { name: 'Read text' })).toBeNull(); + }); + + it('adds nothing without a plugin', async () => { + renderBrowser(['notes.txt']); + expect(document.querySelector('[data-plugin-actions]')).toBeNull(); + fireEvent.contextMenu(rowOf('notes.txt')); + await screen.findAllByRole('menuitem'); + expect(screen.queryByRole('menuitem', { name: 'Archive' })).toBeNull(); + }); +}); + +describe('file-preview-section', () => { + const panelProps = { + resource: notes, + selection: [notes], + folderName: 'Files', + folderItems: [notes], + path: '/notes.txt', + isStarred: false, + canShare: false, + canPreview: true, + canEdit: false, + iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false }, + getImageUrl: async () => '', + onClose: vi.fn(), + onOpen: vi.fn(), + onEdit: vi.fn(), + onDownload: vi.fn(), + onShare: vi.fn(), + onToggleStar: vi.fn(), + } as unknown as React.ComponentProps; + + it('shows the selected file in the details panel', () => { + offerSlots('p1', ['file-preview-section']); + render(); + expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView); + }); + + it('passes the selection from the browser to the details panel', () => { + offerSlots('p1', ['file-preview-section']); + localStorage.setItem('files-show-details', '1'); + renderBrowser(['notes.txt']); + expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView); + }); + + it('shows the previewed file under the text preview', async () => { + offerSlots('p1', ['file-preview-section']); + render( + ({ blob: new Blob(['hi'], { type: 'text/plain' }), contentType: 'text/plain' })} + />, + ); + expect(framePropsOf(await screen.findByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView); + }); + + it('keeps clicks on the section from closing the image preview', () => { + offerSlots('p1', ['file-preview-section']); + const onClose = vi.fn(); + render( {}} getImageUrl={async () => ''} />); + + const frame = screen.getByTestId('plugin-frame-file-preview-section'); + expect(framePropsOf(frame).file).toMatchObject({ id: 'f-photo' }); + fireEvent.click(frame); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders nothing without a plugin or a file', () => { + render(); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + cleanup(); + + offerSlots('p1', ['file-preview-section']); + render( {}} getImageUrl={async () => ''} />); + expect(document.querySelector('[data-plugin-slot]')).toBeNull(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index f245ed5e2..c616e391e 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -26,11 +26,13 @@ import { FileDetailsPanel } from "@/components/files/file-details-panel"; import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; -import { useFileStore, type FileResource } from "@/stores/file-store"; +import { resourceToView, useFileStore, type FileResource } from "@/stores/file-store"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; import { toast } from "@/stores/toast-store"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNodeRights } from "@/lib/jmap/types"; +import type { FileResourceView } from "@/lib/plugin-types"; interface ClipboardState { mode: "cut" | "copy"; @@ -371,6 +373,8 @@ export function FileBrowser({ const cutNames = useMemo(() => new Set(clipboard?.mode === "cut" ? clipboard.names : []), [clipboard]); const selectedList = useMemo(() => displayResources.filter((r) => selectedResources.has(r.name)), [displayResources, selectedResources]); const singleSelected = selectedList.length === 1 ? selectedList[0] : null; + // What plugins see of the selection (file-actions, file-preview-section). + const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]); // ---- Breadcrumbs ----------------------------------------------------------- const crumbs: Crumb[] = useMemo(() => { @@ -759,6 +763,7 @@ export function FileBrowser({ { closeMenu(); onCopy(names); }} /> { closeMenu(); deleteNames(names); }} /> + resourceToView(r, currentPath))} onRan={closeMenu} /> ); } @@ -800,6 +805,11 @@ export function FileBrowser({ { closeMenu(); toggleDetails(true); }} /> { closeMenu(); deleteNames([resource.name]); }} /> + ); }; @@ -962,6 +972,7 @@ export function FileBrowser({ canShare={canShare(singleSelected)} canPreview={!!singleSelected && isPreviewable(singleSelected.name)} canEdit={!!singleSelected && !!onEditFile && !!isOfficeEditable?.(singleSelected.name)} + pluginFile={singleSelected ? selectedViews[0] : null} iconOptions={iconOptions} getImageUrl={getImageUrl} onClose={() => toggleDetails(false)} @@ -991,6 +1002,8 @@ export function FileBrowser({ onCopy: () => onCopy(selectedList.map((r) => r.name)), onDelete: () => deleteNames(selectedList.map((r) => r.name)), onClear: onClearSelection, + pluginFiles: selectedViews, + pluginMimeType: singleSelected ? (singleSelected.isDirectory ? "" : singleSelected.contentType) : undefined, } : null; return ( @@ -1272,3 +1285,15 @@ function ListSkeleton() { ); } + +/** Plugin entries at the end of a file's menu, after a separator when there are any. */ +function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) { + const hasActions = usePluginActions("file-actions", { mimeType }).length > 0; + if (!hasActions) return null; + return ( + <> + + + + ); +} diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx index 70c35f0b0..5e741b53d 100644 --- a/components/files/file-details-panel.tsx +++ b/components/files/file-details-panel.tsx @@ -1,10 +1,13 @@ "use client"; +import { useMemo } from "react"; import { useTranslations } from "next-intl"; import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { cn, formatFileSize } from "@/lib/utils"; import type { FileResource } from "@/stores/file-store"; +import type { FileResourceView } from "@/lib/plugin-types"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; import { ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions, } from "@/components/files/file-item-parts"; @@ -31,6 +34,8 @@ interface FileDetailsPanelProps { onShare: () => void; onToggleStar: () => void; className?: string; + /** The one selected item as plugins see it (file-preview-section). */ + pluginFile?: FileResourceView | null; } function kindOf(resource: FileResource, t: (key: string, values?: Record) => string): string { @@ -46,9 +51,10 @@ function totalSize(items: FileResource[]): number { export function FileDetailsPanel({ resource, selection, folderName, folderItems, path, isStarred, canShare, canPreview, canEdit, - iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, + iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile, }: FileDetailsPanelProps) { const t = useTranslations("files"); + const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]); const header = (
@@ -155,6 +161,7 @@ export function FileDetailsPanel({ + {pluginProps && }
, ); } diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 212d63a10..c83e2a471 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -8,6 +8,8 @@ import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; +import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; +import type { FileResourceView } from "@/lib/plugin-types"; // pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded // only on the mobile PDF path so pdfjs-dist + its worker never reach the @@ -22,6 +24,8 @@ interface FilePreviewModalProps { onClose: () => void; onDownload: () => Promise | void; getFileContent: () => Promise<{ blob: Blob; contentType: string }>; + /** The previewed file as plugins see it (file-preview-section). */ + file?: FileResourceView | null; } function SimpleMarkdown({ content }: { content: string }) { @@ -115,7 +119,7 @@ function processInline(text: string): React.ReactNode { return parts.length === 1 ? parts[0] : <>{parts}; } -export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) { +export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file }: FilePreviewModalProps) { const t = useTranslations("files"); const [content, setContent] = useState(null); const [objectUrl, setObjectUrl] = useState(null); @@ -381,6 +385,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: /> )} + ); } diff --git a/components/files/file-preview-plugin-section.tsx b/components/files/file-preview-plugin-section.tsx new file mode 100644 index 000000000..ff65b66c3 --- /dev/null +++ b/components/files/file-preview-plugin-section.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useMemo } from "react"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; +import type { FileResourceView } from "@/lib/plugin-types"; + +/** + * file-preview-section in the full-screen previews: a panel under the + * preview. Nothing at all when no plugin offers it or the file is unknown. + */ +export function FilePreviewPluginSection({ file }: { file?: FileResourceView | null }) { + const offered = usePluginSlotOffers("file-preview-section").length > 0; + const extraProps = useMemo(() => (file ? { file } : null), [file]); + if (!offered || !extraProps) return null; + return ( + // The overlay closes on a click; the panel is not part of the backdrop. +
e.stopPropagation()} + > + +
+ ); +} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 8133f2d1d..424d0dc72 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -1,13 +1,13 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, resourceServerRef, type UploadResult } from "@/stores/file-store"; +import { useFileStore, resourceServerRef, resourceToView, type UploadResult } from "@/stores/file-store"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -544,6 +544,13 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const proInterfaceActive = useProInterfaceActive(); const isFocusedProTab = useIsFocusedProTab(); const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile)); + + // The previewed file as plugins see it (file-preview-section). + const previewedName = previewFile ?? previewImage; + const previewedView = useMemo(() => { + const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined; + return resource ? resourceToView(resource, currentPath) : null; + }, [previewedName, resources, currentPath]); useDeepLinkUrl( isEmbedded ? (isFocusedProTab ? filesLinkPath : null) @@ -715,6 +722,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onClose={() => setPreviewImage(null)} onDownload={handleDownload} getImageUrl={getImageUrl} + file={previewedView} /> )} @@ -725,6 +733,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onClose={() => setPreviewFile(null)} onDownload={() => handleDownload(previewFile)} getFileContent={() => getFileContent(previewFile)} + file={previewedView} /> )} diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index c9989adc3..b39ccfd94 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList, @@ -12,6 +12,8 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { PluginActions } from "@/components/plugins/plugin-actions"; +import type { FileResourceView } from "@/lib/plugin-types"; export type SortKey = "name" | "size" | "modified"; export type SortDir = "asc" | "desc"; @@ -39,6 +41,10 @@ interface SelectionActions { onCopy: () => void; onDelete: () => void; onClear: () => void; + /** The selection as plugins see it (file-actions). */ + pluginFiles: FileResourceView[]; + /** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */ + pluginMimeType?: string; } interface FilesHeaderProps { @@ -132,6 +138,7 @@ export function FilesHeader({ +
@@ -426,3 +433,9 @@ function SortItem({ label, checked, onClick, icon: Icon }: { ); } + +/** Plugin buttons in the selection toolbar, icon-only like the app's own. */ +function SelectionPluginActions({ files, mimeType }: { files: FileResourceView[]; mimeType?: string }) { + const context = useMemo(() => ({ files }), [files]); + return ; +} diff --git a/components/files/image-preview-modal.tsx b/components/files/image-preview-modal.tsx index fff8793af..129998a60 100644 --- a/components/files/image-preview-modal.tsx +++ b/components/files/image-preview-modal.tsx @@ -5,15 +5,19 @@ import { X, Download } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useTranslations } from "next-intl"; import { ZoomableImage } from "@/components/files/zoomable-image"; +import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; +import type { FileResourceView } from "@/lib/plugin-types"; interface ImagePreviewModalProps { name: string; onClose: () => void; onDownload: (name: string) => Promise; getImageUrl: (name: string) => Promise; + /** The previewed file as plugins see it (file-preview-section). */ + file?: FileResourceView | null; } -export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) { +export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file }: ImagePreviewModalProps) { const t = useTranslations("files"); const [imageUrl, setImageUrl] = useState(null); const [loading, setLoading] = useState(true); @@ -111,6 +115,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im )}
+ ); } diff --git a/lib/__tests__/plugin-slot-catalog.test.ts b/lib/__tests__/plugin-slot-catalog.test.ts index 75f76d686..5b5581904 100644 --- a/lib/__tests__/plugin-slot-catalog.test.ts +++ b/lib/__tests__/plugin-slot-catalog.test.ts @@ -34,7 +34,7 @@ function isMounted(name: string): boolean { // that finishes an area removes it here; the test then insists every // surface in it has a mount site. const NOT_YET_MOUNTED = new Set([ - 'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings', + 'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings', ]); describe('slot catalogue', () => { diff --git a/stores/file-store.ts b/stores/file-store.ts index 952565f92..6d21d40d6 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -293,7 +293,7 @@ function joinPath(base: string, name: string): string { } /** A listed item as plugins see it; `path` is the folder that holds it. */ -function resourceToView(r: FileResource, path: string): FileResourceView { +export function resourceToView(r: FileResource, path: string): FileResourceView { return fileToView({ id: r.id, parentId: r.parentId,