mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins add entries to file menus and the selection toolbar, and a section to file previews
This commit is contained in:
@@ -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,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user