feat: let plugins add entries to file menus and the selection toolbar, and a section to file previews

This commit is contained in:
Linus Rath
2026-10-14 04:58:21 +02:00
parent 14b1d4ea95
commit eb34caa381
10 changed files with 345 additions and 9 deletions
@@ -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(
<FileBrowser
currentPath="/"
resources={[folder, notes, photo]}
isLoading={false}
error={null}
selectedResources={new Set(selected)}
uploadProgress={null}
clipboard={null}
onNavigate={vi.fn()}
onSetSelection={vi.fn()}
onToggleSelect={vi.fn()}
onPreviewFile={vi.fn()}
onDownload={noop}
onCreateFolder={noop}
onUploadFiles={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onBatchDownload={noop}
onRefresh={noop}
onClearSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveTo={noop}
onPreviewImage={vi.fn()}
onCreateTextFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
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<typeof FileDetailsPanel>;
it('shows the selected file in the details panel', () => {
offerSlots('p1', ['file-preview-section']);
render(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
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(
<FilePreviewModal
name="notes.txt"
file={notesView}
onClose={vi.fn()}
onDownload={vi.fn()}
getFileContent={async () => ({ 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(<ImagePreviewModal name="photo.png" file={{ ...notesView, id: 'f-photo' }} onClose={onClose} onDownload={async () => {}} 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(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
cleanup();
offerSlots('p1', ['file-preview-section']);
render(<ImagePreviewModal name="photo.png" onClose={vi.fn()} onDownload={async () => {}} getImageUrl={async () => ''} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
});
});
+26 -1
View File
@@ -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({
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy(names); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_count", { count: names.length })} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
<FilePluginMenuEntries files={menuSelection.map((r) => resourceToView(r, currentPath))} onRan={closeMenu} />
</>
);
}
@@ -800,6 +805,11 @@ export function FileBrowser({
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
<FilePluginMenuEntries
files={[resourceToView(resource, currentPath)]}
mimeType={resource.isDirectory ? "" : resource.contentType}
onRan={closeMenu}
/>
</>
);
};
@@ -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() {
</div>
);
}
/** 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 (
<>
<ContextMenuSeparator />
<PluginActions surface="file-actions" variant="menu" context={{ files }} mimeType={mimeType} onRan={onRan} />
</>
);
}
+8 -1
View File
@@ -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>) => 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 = (
<div className="flex items-center justify-between px-4 h-11 border-b border-border flex-shrink-0">
@@ -155,6 +161,7 @@ export function FileDetailsPanel({
<Row label={t("sharing")} value={sharing} />
<Row label={t("path")} value={path} mono />
</dl>
{pluginProps && <PluginSlot name="file-preview-section" className="w-full" extraProps={pluginProps} />}
</div>,
);
}
+6 -1
View File
@@ -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> | 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<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
@@ -381,6 +385,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/>
)}
</div>
<FilePreviewPluginSection file={file} />
</div>
);
}
@@ -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.
<div
className="max-h-[40vh] w-full flex-shrink-0 overflow-y-auto border-t border-border bg-background pb-[env(safe-area-inset-bottom)]"
onClick={(e) => e.stopPropagation()}
>
<PluginSlot name="file-preview-section" className="w-full" extraProps={extraProps} />
</div>
);
}
+11 -2
View File
@@ -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}
/>
)}
+14 -1
View File
@@ -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({
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
<SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />
</div>
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
@@ -426,3 +433,9 @@ function SortItem({ label, checked, onClick, icon: Icon }: {
</button>
);
}
/** 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 <PluginActions surface="file-actions" context={context} mimeType={mimeType} compact className="flex-nowrap gap-0.5" />;
}
+6 -1
View File
@@ -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<void>;
getImageUrl: (name: string) => Promise<string>;
/** 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<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -111,6 +115,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
)}
</div>
<FilePreviewPluginSection file={file} />
</div>
);
}
+1 -1
View File
@@ -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<SlotArea>([
'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings',
'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings',
]);
describe('slot catalogue', () => {
+1 -1
View File
@@ -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,