Merge branch 'slots/apps' into feat/plugin-system-audit

This commit is contained in:
Linus Rath
2026-10-14 04:58:51 +02:00
27 changed files with 1014 additions and 60 deletions
@@ -0,0 +1,186 @@
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { EventModal } from '../event-modal';
import { EventDetailPopover } from '../event-detail-popover';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { CalendarMonthView } from '../calendar-month-view';
import { MiniCalendar } from '../mini-calendar';
import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// Plugin surfaces in the calendar: a section in an event's details and in
// its form, and the labels and dots plugins put on days.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const calendars = [
{ id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
] as unknown as Calendar[];
const existing = {
id: 'e1',
uid: 'uid-1',
title: 'Planning',
start: '2026-10-12T10:00:00',
duration: 'PT1H',
timeZone: null,
showWithoutTime: false,
calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
afterEach(() => {
cleanup();
clearSlotOffers();
resetDayLabels();
});
describe('calendar-event-detail', () => {
it('shows the event in the detail popover', () => {
offerSlots('p1', ['calendar-event-detail']);
render(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
});
it('shows the event in the modal view, and not in the form', () => {
offerSlots('p1', ['calendar-event-detail']);
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
fireEvent.click(screen.getByText('events.edit'));
expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
});
it('renders nothing when no plugin offers it', () => {
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
});
});
describe('calendar-event-form', () => {
it('hands the plugin the form state of an edited event and follows the fields', async () => {
offerSlots('p1', ['calendar-event-form']);
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
fireEvent.click(screen.getByText('events.edit'));
const frame = screen.getByTestId('plugin-frame-calendar-event-form');
expect(framePropsOf(frame).event).toMatchObject({
id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
});
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
await waitFor(() => {
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
});
});
it('gives a new event an empty id', () => {
offerSlots('p1', ['calendar-event-form']);
render(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultEndDate={new Date('2026-10-12T11:30:00')}
defaultCalendarId="cal-1"
onSave={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
});
});
it('renders nothing when no plugin offers it', () => {
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
});
});
describe('calendar-day-header and mini-calendar-marks', () => {
const week = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
};
function headerOf(dayKey: string): HTMLElement {
const header = document.querySelector<HTMLElement>(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
if (!header) throw new Error(`no header for ${dayKey}`);
return header;
}
it('puts a label under the day name in the week view', () => {
render(<CalendarWeekView {...week} />);
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
const label = headerOf('2026-09-09').querySelector<HTMLElement>('[data-plugin-surface="calendar-day-header"]');
expect(label).toHaveTextContent('Feast day');
expect(label?.title).toBe('A feast (Holidays)');
expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
});
it('puts a label under the day name in the day view', () => {
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
render(<CalendarDayView {...week} windowKey="day:2026-09-09" rangeStart={new Date(2026, 8, 9)} rangeEnd={new Date(2026, 8, 9)} />);
expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
});
it('shows the label in the month cell header on desktop only', () => {
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
const { unmount } = render(<CalendarMonthView {...month} />);
const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
expect(labels).toHaveLength(1);
expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
unmount();
render(<CalendarMonthView {...month} isMobile />);
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
});
it('marks days with a dot in the small month calendar', () => {
render(
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
);
expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
act(() => setDayLabels('hol', 'Holidays', [
{ date: '2026-09-09', dot: true, title: 'A feast' },
{ date: '2026-09-10', text: 'Text only' },
]));
const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
expect(dots).toHaveLength(1);
expect(dots[0].closest('button')).toHaveTextContent('9');
expect(dots[0].closest('button')).toHaveTextContent('A feast');
});
});
@@ -0,0 +1,110 @@
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { TaskModal } from '../task-modal';
import { TaskToolbar } from '../task-toolbar';
import { filterTasksForList } from '../task-list-view';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
// Plugin surfaces for tasks: a section in a task's details and buttons in
// the task list toolbar acting on the tasks the list shows.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[];
function makeTask(id: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
return {
id,
'@type': 'Task',
uid: `uid-${id}`,
title: `Task ${id}`,
description: '',
due: null,
showWithoutTime: true,
priority: 0,
progress: 'needs-action',
calendarIds: { 'cal-1': true },
alerts: null,
...overrides,
} as unknown as CalendarTask;
}
const dispatch = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
dispatch.mockClear();
setActionDispatcher(dispatch);
});
afterEach(() => {
cleanup();
clearSlotOffers();
resetActions();
setActionDispatcher(null);
});
describe('task-detail-section', () => {
it('shows an open task to the plugin', () => {
offerSlots('p1', ['task-detail-section']);
render(<TaskModal task={makeTask('t1', { priority: 5 })} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
const frame = screen.getByTestId('plugin-frame-task-detail-section');
expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false });
});
it('is not shown for a new task or without a plugin', () => {
offerSlots('p1', ['task-detail-section']);
const { unmount } = render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
unmount();
clearSlotOffers();
render(<TaskModal task={makeTask('t1')} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
});
});
describe('task-list-actions', () => {
const toolbar = {
filter: 'all' as const,
showCompleted: false,
onFilterChange: vi.fn(),
onShowCompletedChange: vi.fn(),
onCreateTask: vi.fn(),
};
it('puts plugin buttons after the create button and sends the listed task ids', async () => {
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
render(<TaskToolbar {...toolbar} taskIds={['t2', 't1']} />);
const button = screen.getByRole('button', { name: 'Plan my day' });
expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await act(async () => { fireEvent.click(button); });
expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] });
});
it('draws icon-only buttons when compact', () => {
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
render(<TaskToolbar {...toolbar} taskIds={[]} compact />);
expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
});
it('renders nothing without a plugin', () => {
render(<TaskToolbar {...toolbar} taskIds={['t1']} />);
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
});
it('lists the ids the task list shows, in its order', () => {
const tasks = [
makeTask('done', { progress: 'completed' }),
makeTask('later', { due: '2030-01-02' }),
makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
makeTask('soon', { due: '2030-01-01' }),
];
expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
});
});
+9 -1
View File
@@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { TaskListView } from "@/components/calendar/task-list-view";
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
@@ -1488,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
// The tasks the task list shows; plugin buttons in its toolbar act on them.
const listedTaskIds = useMemo(
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
[taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
);
// Tasks on the month, week and day grids follow the calendar filter like
// the events do (#1107).
const calendarTasks = useMemo(() => {
@@ -1679,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onFilterChange={taskStore.setFilter}
onShowCompletedChange={taskStore.setShowCompleted}
onCreateTask={openCreateTaskModal}
taskIds={listedTaskIds}
compact={isMobile}
/>
<TaskListView
tasks={taskStore.tasks}
@@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
import { PluginDayLabel } from "./plugin-day-label";
interface CalendarDayViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -334,6 +335,7 @@ export function CalendarDayView({
>
{format(day, "d")}
</div>
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import { PluginDayLabel } from "./plugin-day-label";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -379,6 +380,7 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
</button>
{!isMobile && <PluginDayLabel surface="calendar-day-header" date={key} className="min-w-0 flex-1" />}
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
import { PluginDayLabel } from "./plugin-day-label";
interface CalendarWeekViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -436,6 +437,7 @@ export function CalendarWeekView({
>
{format(day, "d")}
</button>
<PluginDayLabel surface="calendar-day-header" date={dayKey(day)} className="w-full px-1 text-center" />
</div>
);
})}
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -145,6 +147,7 @@ export function EventDetailPopover({
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = useMemo(() => findMeetingLink(event), [event]);
const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
const virtualLocation = meeting?.uri ?? null;
// A URL opens as a link, a location that only names the service joins the
@@ -580,6 +583,8 @@ export function EventDetailPopover({
</p>
</div>
)}
<PluginSlot name="calendar-event-detail" className="w-full" extraProps={pluginProps} />
</div>
{/* Quick Note */}
+56 -1
View File
@@ -39,7 +39,7 @@ import {
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
import { eventToView } from "@/lib/plugin-projection";
export interface PendingEventPreview {
@@ -446,6 +446,55 @@ export function EventModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// calendar-event-detail: the event as plugins see it, in the read views.
const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
// calendar-event-form: the form's current state in the same shape. Typing
// would hand the plugin iframe new props on every key, so the view settles
// for half a second first.
const formEventView = useMemo<CalendarEventView>(() => {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
const start = new Date(startStr);
let duration = event?.duration ?? "PT1H";
if (!isNaN(start.getTime())) {
if (allDay) {
const inclusiveEnd = new Date(`${endDate}T00:00:00`);
if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
} else {
const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
if (!isNaN(end.getTime())) duration = buildDuration(start, end);
}
}
const base = event ? eventToView(event) : null;
return {
id: event?.id ?? "",
uid: event?.uid ?? "",
calendarId,
title,
description,
start: startStr,
duration,
timeZone: getEffectiveTimeZone(),
utcStart: null,
utcEnd: null,
isAllDay: allDay,
location,
status: base?.status ?? "confirmed",
participants: attendees.map((a) => {
const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
}),
isRecurring: recurrence !== "none",
keywords: base?.keywords ?? [],
};
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
useEffect(() => {
const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
return () => clearTimeout(timer);
}, [formEventView]);
const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -823,6 +872,8 @@ export function EventModal({
</div>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -1000,6 +1051,8 @@ export function EventModal({
</p>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -1347,6 +1400,8 @@ export function EventModal({
</label>
</div>
)}
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
</div>
</div>
+2
View File
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { PluginDayLabel } from "./plugin-day-label";
type PickerView = "days" | "months" | "years";
@@ -199,6 +200,7 @@ export function MiniCalendar({
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
</button>
</Fragment>
);
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
const NONE: RegisteredDayLabel[] = [];
const EMPTY_MAP = new Map<string, RegisteredDayLabel[]>();
/** The labels plugins put on one day (api.ui.setDayLabels). */
export function useDayLabels(date: string): RegisteredDayLabel[] {
const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
return byDate.get(date) ?? NONE;
}
function tooltip(labels: RegisteredDayLabel[]): string {
return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
}
interface PluginDayLabelProps {
/**
* calendar-day-header: the label text under a day's name (day, week and
* month views). mini-calendar-marks: a dot on the day in the small month
* calendar; the parent must be positioned.
*/
surface: "calendar-day-header" | "mini-calendar-marks";
/** YYYY-MM-DD */
date: string;
className?: string;
}
/** What plugins put on a calendar day. Renders nothing without labels. */
export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
const labels = useDayLabels(date);
if (surface === "mini-calendar-marks") {
const marks = labels.filter((l) => l.dot);
if (marks.length === 0) return null;
return (
<>
<span
aria-hidden="true"
data-plugin-surface="mini-calendar-marks"
title={tooltip(marks)}
className={cn("pointer-events-none absolute top-0.5 end-0.5 h-1 w-1 rounded-full bg-current opacity-70", className)}
/>
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
</>
);
}
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
<div
data-plugin-surface="calendar-day-header"
title={tooltip(withText)}
className={cn("max-w-full truncate text-[10px] leading-tight text-muted-foreground", className)}
>
{withText.map((l) => l.text).join(" · ")}
</div>
);
}
+58 -47
View File
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
};
}
/** The tasks the list shows, in its order: filtered by calendar and status. */
export function filterTasksForList(
tasks: CalendarTask[],
selectedCalendarIds: string[],
filter: TaskViewFilter,
showCompleted: boolean,
): CalendarTask[] {
let result = tasks.filter(task => {
const calIds = Object.keys(task.calendarIds);
return calIds.some(id => selectedCalendarIds.includes(id));
});
if (!showCompleted) {
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
}
switch (filter) {
case "pending":
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
break;
case "completed":
result = result.filter(task => task.progress === "completed");
break;
case "overdue":
result = result.filter(task => {
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
});
break;
}
// Sort: overdue first, then by due date (no due date last), then by priority
result.sort((a, b) => {
// Completed tasks at the bottom
if (a.progress === "completed" && b.progress !== "completed") return 1;
if (a.progress !== "completed" && b.progress === "completed") return -1;
// Tasks with due dates before those without
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
if (a.due && b.due) {
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
if (dateCompare !== 0) return dateCompare;
}
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
const aPri = a.priority || 10;
const bPri = b.priority || 10;
return aPri - bPri;
});
return result;
}
export function TaskListView({
tasks,
calendars,
@@ -77,53 +131,10 @@ export function TaskListView({
const timeFormat = useSettingsStore((s) => s.timeFormat);
const [quickAddTitle, setQuickAddTitle] = useState("");
const filteredTasks = useMemo(() => {
let result = tasks.filter(task => {
const calIds = Object.keys(task.calendarIds);
return calIds.some(id => selectedCalendarIds.includes(id));
});
if (!showCompleted) {
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
}
switch (filter) {
case "pending":
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
break;
case "completed":
result = result.filter(task => task.progress === "completed");
break;
case "overdue":
result = result.filter(task => {
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
});
break;
}
// Sort: overdue first, then by due date (no due date last), then by priority
result.sort((a, b) => {
// Completed tasks at the bottom
if (a.progress === "completed" && b.progress !== "completed") return 1;
if (a.progress !== "completed" && b.progress === "completed") return -1;
// Tasks with due dates before those without
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
if (a.due && b.due) {
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
if (dateCompare !== 0) return dateCompare;
}
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
const aPri = a.priority || 10;
const bPri = b.priority || 10;
return aPri - bPri;
});
return result;
}, [tasks, selectedCalendarIds, filter, showCompleted]);
const filteredTasks = useMemo(
() => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
[tasks, selectedCalendarIds, filter, showCompleted],
);
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
e.stopPropagation();
+7 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +8,8 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { taskToView } from "@/lib/plugin-projection";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -48,6 +50,8 @@ export function TaskModal({
const t = useTranslations("calendar");
const isEdit = !!task;
const titleRef = useRef<HTMLInputElement>(null);
// task-detail-section: the saved task, for an existing task only.
const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
@@ -302,6 +306,8 @@ export function TaskModal({
<option value="1440">{t("tasks.alert_1day")}</option>
</select>
</div>
{pluginTaskProps && <PluginSlot name="task-detail-section" className="w-full" extraProps={pluginTaskProps} />}
</div>
{/* Footer */}
+13
View File
@@ -1,9 +1,11 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Plus } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
import type { TaskViewFilter } from "@/stores/task-store";
interface TaskToolbarProps {
@@ -12,8 +14,14 @@ interface TaskToolbarProps {
onFilterChange: (filter: TaskViewFilter) => void;
onShowCompletedChange: (show: boolean) => void;
onCreateTask: () => void;
/** The tasks the list shows, in order: what plugin buttons here act on. */
taskIds?: string[];
/** Icon-only plugin buttons (phones). */
compact?: boolean;
}
const NO_IDS: string[] = [];
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
export function TaskToolbar({
@@ -22,8 +30,11 @@ export function TaskToolbar({
onFilterChange,
onShowCompletedChange,
onCreateTask,
taskIds = NO_IDS,
compact,
}: TaskToolbarProps) {
const t = useTranslations("calendar");
const pluginContext = useMemo(() => ({ taskIds }), [taskIds]);
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
@@ -60,6 +71,8 @@ export function TaskToolbar({
<Plus className="w-4 h-4 me-1" />
{t("tasks.create")}
</Button>
<PluginActions surface="task-list-actions" context={pluginContext} compact={compact} />
</div>
);
}
@@ -0,0 +1,108 @@
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { ContactDetail } from '../contact-detail';
import { ContactList } from '../contact-list';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import type { ContactCard } from '@/lib/jmap/types';
// Plugin surfaces in contacts: a section in a contact's details and buttons
// in the list's selection toolbar.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const alice: ContactCard = {
id: '1',
uid: 'uid-alice',
addressBookIds: { ab1: true },
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
notes: { n0: { note: 'VIP customer' } },
};
const bob: ContactCard = {
id: '2',
addressBookIds: { ab1: true },
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
};
const dispatch = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
dispatch.mockClear();
setActionDispatcher(dispatch);
});
afterEach(() => {
cleanup();
clearSlotOffers();
resetActions();
setActionDispatcher(null);
});
describe('contact-detail-section', () => {
it('shows the contact to the plugin in its details', () => {
offerSlots('p1', ['contact-detail-section']);
render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
const frame = screen.getByTestId('plugin-frame-contact-detail-section');
expect(framePropsOf(frame).contact).toMatchObject({
id: '1', uid: 'uid-alice', addressBookId: 'ab1', firstName: 'Alice', lastName: 'Smith', emails: ['alice@example.com'],
});
// Below the contact's own fields.
expect(screen.getByText('VIP customer').compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('renders nothing without a plugin or without a contact', () => {
const { unmount } = render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
unmount();
offerSlots('p1', ['contact-detail-section']);
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
});
});
describe('contact-list-actions', () => {
const props = {
contacts: [alice, bob],
selectedContactId: null,
searchQuery: '',
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
onSelectAll: vi.fn(),
onClearSelection: vi.fn(),
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
onEditContact: vi.fn(),
onDeleteContact: vi.fn(),
onAddContactToGroup: vi.fn(),
};
it('puts plugin buttons in the selection toolbar and sends the selected ids', async () => {
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
render(<ContactList {...props} selectedContactIds={new Set(['1', '2'])} />);
const button = screen.getByRole('button', { name: 'Sync to CRM' });
// Next to the app's own bulk buttons.
expect(button.closest('div.flex-wrap')?.parentElement).toContainElement(screen.getByText('bulk.delete'));
await act(async () => { fireEvent.click(button); });
expect(dispatch).toHaveBeenCalledWith('crm', 'contact-list-actions', 'sync', { contactIds: ['1', '2'] });
});
it('shows no plugin buttons without a selection or without a plugin', () => {
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
const { unmount } = render(<ContactList {...props} selectedContactIds={new Set()} />);
expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
unmount();
resetActions();
render(<ContactList {...props} selectedContactIds={new Set(['1'])} />);
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
@@ -125,6 +125,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
// Tell plugins once per contact shown here, not on every edit of that card.
const latestContact = useRef(contact);
@@ -509,6 +510,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</Section>
)}
{pluginContactProps && (
<PluginSlot name="contact-detail-section" className="w-full py-6" extraProps={pluginContactProps} />
)}
<ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
+3
View File
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
@@ -268,6 +269,7 @@ export function ContactList({
}, [sorted, locale, sortByLastName]);
const hasSelection = selectedContactIds.size > 0;
const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
@@ -521,6 +523,7 @@ export function ContactList({
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
</Button>
<PluginActions surface="contact-list-actions" context={pluginContext} />
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
<X className="w-3.5 h-3.5" />
</Button>
@@ -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>
);
}
@@ -0,0 +1,37 @@
// Test helpers for mount-site tests: a plugin that offers a slot, and a
// stand-in for the sandboxed iframe that shows which props it was handed.
//
// Use with:
// vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import type { SlotName } from '@/lib/plugin-types';
const registered = new Set<string>();
/** Registers an active plugin that offers these slots. */
export function offerSlots(pluginId: string, slots: SlotName[]): void {
register({
plugin: { id: pluginId, name: pluginId },
code: '',
tier: 'untrusted',
background: {},
slotOffers: slots.map((name) => ({ name, order: 100, hasShouldShow: false })),
hookDisposables: [],
} as unknown as ActivePlugin);
registered.add(pluginId);
}
export function clearSlotOffers(): void {
for (const id of registered) deregister(id);
registered.clear();
}
/** Drop-in for PluginIframeSlot: no iframe, the props as JSON. */
export function PluginIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record<string, unknown> }) {
return <div data-testid={`plugin-frame-${slot}`} data-plugin-id={pluginId} data-props={JSON.stringify(extraProps ?? {})} />;
}
/** The props the fake iframe for this slot received. */
export function framePropsOf(el: HTMLElement): Record<string, unknown> {
return JSON.parse(el.getAttribute('data-props') || '{}') as Record<string, unknown>;
}
+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,