feat: rework pro split-screen shell, per-pane tab strips, drag & drop, pane-scoped overlays

This commit is contained in:
Linus Rath
2026-08-16 03:03:40 +02:00
parent 3a6d14871f
commit 47cf0ed90d
11 changed files with 1743 additions and 584 deletions
+179 -120
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useEffect, useMemo, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -13,8 +13,17 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { PaneIdContext } from "@/hooks/use-pane-context";
import { ProTabBar } from "@/components/pro/pro-tab-bar";
import {
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
dragKindFromTypes,
parseEmailIdsPayload,
resolveBodyDropTarget,
type BodyDropTarget,
} from "@/components/pro/pro-shell-drop";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProSplitSide } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
@@ -37,8 +46,6 @@ const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
settings: SettingsApp,
};
type DropTarget = 'left' | 'right' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
@@ -50,6 +57,40 @@ function renderTabBody(tab: ProTab): React.ReactNode {
return Component ? <Component /> : null;
}
/**
* Open dragged emails as Pro tabs in `pane`. A single email reuses the
* shared reader tab (so repeated drags/double-clicks drive one tab); a
* multi-selection opens one tab per message, capped so a stray
* select-all drag can't explode the tab strip.
*/
function openDroppedEmails(
emailIds: string[],
pane: ProPaneId,
side: ProSplitSide | undefined,
fallbackTitle: string,
) {
const { setSplitSide, openEmailTab } = useProTabStore.getState();
const known = useEmailStore.getState().emails;
const targets = emailIds
.map((id) => known.find((e) => e.id === id))
.filter((e): e is NonNullable<typeof e> => !!e)
.slice(0, 10);
if (targets.length === 0) return;
if (side) setSplitSide(side);
const reuseReader = targets.length === 1;
for (const email of targets) {
openEmailTab(
{
accountId: email.accountId ?? '',
emailId: email.id,
mailboxId: null,
title: email.subject?.trim() || fallbackTitle,
},
{ pane, reuseReader },
);
}
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
@@ -60,7 +101,9 @@ interface PaneProps {
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Held in state (not a ref) so the ResizeObserver effect re-runs once the
// element exists.
const [paneEl, setPaneEl] = useState<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport - and inner pages collapse to their mobile/tablet layouts when
@@ -68,9 +111,8 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (!paneEl || typeof ResizeObserver === "undefined") return;
const initialRect = paneEl.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
@@ -78,32 +120,34 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
ro.observe(paneEl);
return () => ro.disconnect();
}, []);
}, [paneEl]);
return (
<div
ref={paneRef}
ref={setPaneEl}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
<PaneIdContext.Provider value={paneId}>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</PaneIdContext.Provider>
</div>
);
}
@@ -138,8 +182,8 @@ export default function ProHome() {
const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const splitSide = useProTabStore((s) => s.splitSide);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
@@ -147,10 +191,9 @@ export default function ProHome() {
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
/** Whether the split pane visually renders before (true) or after (false) main. */
const [splitLeading, setSplitLeading] = useState(false);
/** Tab id currently being dragged from either strip, if any. */
const [draggedTabId, setDraggedTabId] = useState<string | null>(null);
const [bodyDropTarget, setBodyDropTarget] = useState<BodyDropTarget>(null);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
@@ -182,6 +225,7 @@ export default function ProHome() {
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const isSplit = splitTabs.length > 0;
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
@@ -200,59 +244,88 @@ export default function ProHome() {
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
// ---- Body-level drop targets (tab drags and email drags) ----
// ---- Body-level drop targets ----
const emailFallbackTitle = t('email_composer.new_message');
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const resolveTargetForEvent = (e: DragEvent<HTMLDivElement>): BodyDropTarget => {
const kind = dragKindFromTypes(e.dataTransfer.types);
if (!kind) return null;
const rect = e.currentTarget.getBoundingClientRect();
const xFrac = (e.clientX - rect.left) / rect.width;
return xFrac < 0.5 ? 'left' : 'right';
const xFrac = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
return resolveBodyDropTarget({
xFrac,
isSplit,
splitSide,
kind,
// A tab can only create a split if another tab stays behind; emails
// always can (the mail list keeps existing).
canCreateSplit: kind === 'email' ? true : tabs.length > 1,
draggedTabPane: kind === 'tab'
? (tabs.find((t) => t.id === draggedTabId)?.paneId ?? null)
: null,
});
};
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
if (!target || !isSplit) return null;
const leftIsSplit = splitLeading;
if (target === 'left') return leftIsSplit ? 'split' : 'main';
return leftIsSplit ? 'main' : 'split';
};
const sameTarget = (a: BodyDropTarget, b: BodyDropTarget) =>
a === b || (!!a && !!b && a.type === b.type && a.side === b.side
&& (a.type !== 'pane' || b.type !== 'pane' || a.pane === b.pane));
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
// Something inside the pane (a folder drop target, a tab strip) already
// claimed this position - stand down so we never double-handle.
if (e.defaultPrevented) {
if (bodyDropTarget) setBodyDropTarget(null);
return;
}
const kind = dragKindFromTypes(e.dataTransfer.types);
if (!kind) return;
const target = resolveTargetForEvent(e);
if (!target) {
// Not calling preventDefault leaves the browser's no-drop feedback in
// place - an illegal drop is visibly illegal, never a silent no-op.
if (bodyDropTarget) setBodyDropTarget(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
e.dataTransfer.dropEffect = kind === 'tab' ? 'move' : 'copy';
setBodyDropTarget((prev) => (sameTarget(prev, target) ? prev : target));
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
setBodyDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
setBodyDropTarget(null);
if (e.defaultPrevented) return;
const kind = dragKindFromTypes(e.dataTransfer.types);
if (!kind) return;
const target = resolveTargetForEvent(e);
setDraggedTabId(null);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (isSplit) {
// Move tab to whichever pane occupies the dropped side.
const destPane = targetPaneFromDrop(target);
if (destPane) moveTabToPane(draggedId, destPane);
if (kind === 'tab') {
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (target.type === 'create-split') {
moveTabToPane(draggedId, 'split', { side: target.side });
} else {
moveTabToPane(draggedId, target.pane);
}
return;
}
// Create a new side-by-side split. `splitLeading` controls which side
// visually hosts the split pane.
moveTabToPane(draggedId, 'split', 'vertical');
setSplitLeading(target === 'left');
const ids = parseEmailIdsPayload(e.dataTransfer.getData(EMAIL_IDS_DRAG_MIME));
if (ids.length === 0) return;
if (target.type === 'create-split') {
openDroppedEmails(ids, 'split', target.side, emailFallbackTitle);
} else {
openDroppedEmails(ids, target.pane, undefined, emailFallbackTitle);
}
};
// Loading state (matches standard page exactly)
@@ -269,37 +342,39 @@ export default function ProHome() {
if (!isDesktop) return null;
// Each pane renders as a column: its own tab strip directly above its
// body, so a tab visibly belongs to the pane beneath it and clicking a
// tab only ever changes that pane.
//
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// list changes shape. Without keys, React would reuse the column at
// index 0 - repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
const renderColumn = (paneId: ProPaneId) => (
<div key={`col-${paneId}`} className="flex flex-1 flex-col overflow-hidden min-w-0 min-h-0">
<ProTabBar
paneId={paneId}
tabs={paneId === 'main' ? mainTabs : splitTabs}
activeTabId={paneId === 'main' ? activeMainTabId : activeSplitTabId}
isFocused={focusedPaneId === paneId}
onActivate={setActiveTab}
onClose={requestCloseTab}
onDragStateChange={(dragging, tabId) => setDraggedTabId(dragging ? tabId : null)}
onEmailDrop={(ids) => openDroppedEmails(ids, paneId, undefined, emailFallbackTitle)}
/>
<Pane
paneId={paneId}
tabs={paneId === 'main' ? mainTabs : splitTabs}
activeTabId={paneId === 'main' ? activeMainTabId : activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === paneId}
/>
</div>
);
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
key="pane-divider"
@@ -309,12 +384,9 @@ export default function ProHome() {
/>
) : null;
// Drop-zone overlay: a single half-body preview of where the dragged tab
// would land. The whole body is always a drop target (the entire surface
// maps to one of the four sides), so we only render the active side.
const dropZone = isTabDragging && splitDropTarget ? (
<DropZone side={splitDropTarget} />
) : null;
// Drop-zone overlay: a half-body preview of where the dragged tab/email
// would land, driven by the same resolver that performs the drop.
const dropZone = bodyDropTarget ? <DropZone side={bodyDropTarget.side} /> : null;
return (
<EmbeddedContext.Provider value={true}>
@@ -350,32 +422,19 @@ export default function ProHome() {
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={requestCloseTab}
onDragStateChange={setIsTabDragging}
/>
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitSide === 'left'
? <>{renderColumn('split')}{splitDivider}{renderColumn('main')}</>
: <>{renderColumn('main')}{splitDivider}{renderColumn('split')}</>)
: renderColumn('main')}
{/* Panes container - accepts body drops for split/move. */}
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZone}
</div>
{dropZone}
</div>
)}
</div>
+12 -3
View File
@@ -24,6 +24,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
@@ -199,6 +200,14 @@ export function EventModal({
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
// Inside a Pro pane the "mobile" layout must cover the pane, not the
// viewport - a `fixed inset-0` editor from a split pane would take over
// the whole app. The absolute variant anchors to the pane's tab-body
// wrapper (the nearest positioned ancestor), covering exactly the pane.
const isPaneScoped = useIsPaneScoped();
const mobileRootClass = isPaneScoped
? "absolute inset-0 z-40 flex flex-col bg-background"
: "fixed inset-0 z-50 flex flex-col bg-background";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
@@ -690,7 +699,7 @@ export function EventModal({
const participants = getParticipantList(event);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
@@ -834,7 +843,7 @@ export function EventModal({
const color = getEventColor(event, eventCalendar);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
{/* Color accent bar */}
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
@@ -1028,7 +1037,7 @@ export function EventModal({
}
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
+157 -37
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import DOMPurify from "dompurify";
@@ -87,6 +87,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
@@ -97,7 +98,6 @@ import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
@@ -114,6 +114,12 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
/** The More menu's two drill-downs: a folder list and a tag list. */
type MoreMenuSub = 'move' | 'tag';
/** Whatever a sub-view offers to act on, in the order it is read out. */
const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
interface EmailViewerProps {
email: Email | null;
isLoading?: boolean;
@@ -710,6 +716,12 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
// Inside a Pro pane, `isMobile` above is pane-width based: overlays that
// would go viewport-fixed must instead cover just the pane (via
// PaneOverlay + absolute positioning), and viewport CSS breakpoints like
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
// while the pane is phone-sized.
const isPaneScoped = useIsPaneScoped();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
@@ -772,7 +784,6 @@ export function EmailViewer({
}, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const isEmbedded = useIsEmbedded();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -803,7 +814,7 @@ export function EmailViewer({
};
const [showSourceModal, setShowSourceModal] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null);
const [moreMenuSub, setMoreMenuSub] = useState<MoreMenuSub | null>(null);
const [tagMenuOpen, setTagMenuOpen] = useState(false);
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
@@ -811,6 +822,12 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const moreButtonRef = useRef<HTMLButtonElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
// The rows that open a sub-view, and the pieces of the mobile panel a
// sub-view replaces. Desktop and mobile never render their More menu at the
// same time, so one map serves both.
const moreEntryRefs = useRef<Record<MoreMenuSub, HTMLButtonElement | null>>({ move: null, tag: null });
const mobileSubBackRef = useRef<HTMLButtonElement>(null);
const mobileSubListRef = useRef<HTMLDivElement>(null);
// Pro can mount two reading panes side by side, so the menu id has to be
// per-instance for aria-controls to point at the right one.
const moreMenuId = useId();
@@ -834,6 +851,54 @@ export function EmailViewer({
onClose: closeMoveMenu,
triggerRef: moveButtonRef,
});
// Leaving a sub-view unmounts the row that opened it, so focus has to be put
// back by hand or it falls to <body> and a screen reader is left with nothing
// to read (#779).
const leaveMoreMenuSub = useCallback(() => {
const sub = moreMenuSub;
setMoreMenuSub(null);
if (!sub) return;
// The mobile panel unmounts the entry while its sub-view is on screen, so
// the ref only points at a button again once the top level is back.
requestAnimationFrame(() => moreEntryRefs.current[sub]?.focus());
}, [moreMenuSub]);
// Escape inside a sub-view backs out of it; only an Escape on the top level
// dismisses the whole menu.
const withSubMenuEscape = useCallback(
(next: (e: React.KeyboardEvent) => void) => (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && moreMenuSub) {
e.preventDefault();
e.stopPropagation();
leaveMoreMenuSub();
return;
}
next(e);
},
[moreMenuSub, leaveMoreMenuSub],
);
const handleMoreMenuKeyDown = useMemo(
() => withSubMenuEscape(onMoreMenuKeyDown),
[withSubMenuEscape, onMoreMenuKeyDown],
);
const handleMobileMoreKeyDown = useMemo(
() => withSubMenuEscape(onMobileMoreKeyDown),
[withSubMenuEscape, onMobileMoreKeyDown],
);
// The mobile panel swaps its whole body for the sub-view, so entering one
// leaves the user nowhere unless focus follows it in. Desktop keeps the entry
// button mounted beside its flyout and needs no help.
const previousMoreMenuSub = useRef<MoreMenuSub | null>(null);
useEffect(() => {
const previous = previousMoreMenuSub.current;
previousMoreMenuSub.current = moreMenuSub;
if (!isMobile || !moreMenuOpen || !moreMenuSub || previous === moreMenuSub) return;
// A frame of slack lets the sub-view render before we look for its items.
const frame = requestAnimationFrame(() => {
const first = mobileSubListRef.current?.querySelector<HTMLElement>(SUB_MENU_ITEM_SELECTOR);
(first ?? mobileSubBackRef.current)?.focus();
});
return () => cancelAnimationFrame(frame);
}, [moreMenuSub, moreMenuOpen, isMobile]);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
@@ -940,7 +1005,13 @@ export function EmailViewer({
useEffect(() => {
if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return;
function handleClickOutside(e: MouseEvent) {
if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
// On mobile the More menu is an off-canvas panel rendered as a sibling of
// the toolbar, so it is not inside `moreMenuRef`. Without counting it as
// part of the menu every tap on one of its rows read as a click away and
// tore the menu down before the row's own click could open its sub-view -
// "tag" and "move" just dropped the user back on the trigger (#779).
const moreRoots = [moreMenuRef.current, mobileMoreRef.current].filter((el): el is HTMLDivElement => el !== null);
if (moreMenuOpen && moreRoots.length > 0 && !moreRoots.some((root) => root.contains(e.target as Node))) {
setMoreMenuOpen(false);
setMoreMenuSub(null);
}
@@ -953,7 +1024,7 @@ export function EmailViewer({
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [moreMenuOpen, tagMenuOpen, moveMenuOpen]);
}, [moreMenuOpen, tagMenuOpen, moveMenuOpen, mobileMoreRef]);
// Close dropdowns when email changes
useEffect(() => {
@@ -2807,23 +2878,24 @@ export function EmailViewer({
</div>
);
}
// Rendered in Pro (embedded) too: a silent void here read as "broken"
// in a split pane - the empty reading pane should always say what it is
// and offer a way forward.
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
{!isEmbedded && (
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 me-2" />
{t('compose')}
</Button>
)}
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
)}
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 me-2" />
{t('compose')}
</Button>
)}
</div>
</div>
);
}
@@ -3148,7 +3220,7 @@ export function EmailViewer({
{moreMenuOpen && !isMobile && (
<div
ref={moreMenuListRef}
onKeyDown={onMoreMenuKeyDown}
onKeyDown={handleMoreMenuKeyDown}
id={moreMenuId}
role="menu"
aria-label={t('more_actions')}
@@ -3206,10 +3278,11 @@ export function EmailViewer({
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
ref={(el) => { moreEntryRefs.current.move = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'move'}
onClick={() => setMoreMenuSub(moreMenuSub === 'move' ? null : 'move')}
onClick={() => { if (moreMenuSub === 'move') leaveMoreMenuSub(); else setMoreMenuSub('move'); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<FolderInput className="w-4 h-4" />
@@ -3217,7 +3290,11 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'move' && (
<div className="absolute end-full top-0 me-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10">
<div
role="menu"
aria-label={t('move_to')}
className="absolute end-full top-0 me-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10"
>
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -3262,10 +3339,11 @@ export function EmailViewer({
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
ref={(el) => { moreEntryRefs.current.tag = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'tag'}
onClick={() => setMoreMenuSub(moreMenuSub === 'tag' ? null : 'tag')}
onClick={() => { if (moreMenuSub === 'tag') leaveMoreMenuSub(); else setMoreMenuSub('tag'); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Tag className="w-4 h-4" />
@@ -3273,7 +3351,11 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<div
role="menu"
aria-label={t('tag')}
className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10"
>
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
@@ -3403,26 +3485,37 @@ export function EmailViewer({
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
>
{/* Mobile More menu sidebar overlay */}
{/* Mobile More menu sidebar overlay. Inside a Pro pane both pieces
anchor to the viewer's relative root with absolute positioning: the
viewport is desktop-sized there even though the pane is phone-sized,
so neither viewport-fixed positioning nor the viewport-based
`sm:hidden` guard can be trusted (they used to leave the More button
opening an invisible panel in a split pane). */}
{!isScheduled && isMobile && moreMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
className={cn(
"bg-black/50 z-[60]",
isPaneScoped ? "absolute inset-0" : "fixed inset-0 sm:hidden",
)}
onClick={() => setMoreMenuOpen(false)}
/>
)}
{!isScheduled && isMobile && (
<div
ref={mobileMoreRef}
onKeyDown={onMobileMoreKeyDown}
onKeyDown={handleMobileMoreKeyDown}
id={moreMenuId}
role="menu"
aria-label={t('more_actions')}
/* A sub-view replaces the panel wholesale, so the panel takes its name
rather than pretending the top level is still on screen. */
aria-label={moreMenuSub === 'move' ? t('move_to') : moreMenuSub === 'tag' ? t('tag') : t('more_actions')}
/* The panel is only slid off-screen, so without `inert` every action in
it stays permanently exposed to screen readers - and lands near the
top of the reading order, far from the toolbar it belongs to (#720). */
inert={!moreMenuOpen}
className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-s border-border z-[70] sm:hidden",
"bg-background border-s border-border z-[70]",
isPaneScoped ? "absolute inset-y-0 right-0 w-72" : "fixed inset-y-0 right-0 w-72 sm:hidden",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full"
@@ -3430,8 +3523,9 @@ export function EmailViewer({
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
{moreMenuSub ? (
<button
ref={mobileSubBackRef}
role="menuitem"
onClick={() => setMoreMenuSub(null)}
onClick={leaveMoreMenuSub}
className="flex items-center gap-1 -ms-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
@@ -3451,7 +3545,7 @@ export function EmailViewer({
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div ref={mobileSubListRef} className="flex-1 overflow-y-auto py-2">
{moreMenuSub === null && (
<>
{/* Star toggle */}
@@ -3463,12 +3557,29 @@ export function EmailViewer({
<Star className={cn("w-5 h-5", isStarred && "fill-yellow-400 text-yellow-400")} />
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Move to folder (opens sub-view). The toolbar's own Move button
is the first thing dropped when the toolbar runs out of room,
and on mobile the overflow has nowhere else to go - without
this row a narrow screen loses the action entirely (#779). */}
{moveTree.length > 0 && onMoveToMailbox && (
<button
ref={(el) => { moreEntryRefs.current.move = el; }}
role="menuitem"
aria-haspopup="menu"
onClick={() => setMoreMenuSub('move')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<FolderInput className="w-5 h-5" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Tag (opens sub-view) */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
ref={(el) => { moreEntryRefs.current.tag = el; }}
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'tag'}
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -5018,10 +5129,14 @@ export function EmailViewer({
</div>
</div>
{/* Email Source Modal */}
{/* Email Source Modal - pane-scoped inside a Pro pane so it never
covers the neighbouring split pane. */}
{showSourceModal && email && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
className={cn(
"bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4",
isPaneScoped ? "absolute inset-0" : "fixed inset-0",
)}
onClick={() => setShowSourceModal(false)}
>
<div
@@ -5066,9 +5181,14 @@ export function EmailViewer({
)}
</div>
{/* Mobile bottom action bar */}
{/* Mobile bottom action bar - pane-scoped inside a Pro pane (the
viewport `sm:hidden` guard would otherwise hide it there, and
`fixed` would span the whole app instead of the pane). */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border sm:hidden overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]">
<nav className={cn(
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
)}>
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
+39 -6
View File
@@ -25,6 +25,8 @@ import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -280,6 +282,19 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const isEmbedded = useIsEmbedded();
// Pane hosting (Pro shell). When this app renders inside a Pro pane, the
// pane publishes its width and id; `isMobile` above is then pane-based,
// and layout that would use viewport-fixed positioning must scope itself
// to the pane instead (`paneMobile` below).
const paneWidth = usePaneSize();
const proPaneId = usePaneId();
const isPaneScoped = paneWidth !== null;
const paneMobile = isPaneScoped && isMobile;
// Persisted column widths are viewport-sized; inside a (possibly split)
// pane they must never exceed the pane, or the layout stays "squeezed"
// after a split closes and reopens at a different size.
const clampEmailListWidth = (width: number) =>
paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width;
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const {
emails,
@@ -3265,7 +3280,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
{/* Main Content Area */}
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
<div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
<div className={cn("relative flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<div
className={cn(
@@ -3276,6 +3291,11 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Pane-mobile (narrow Pro pane on a wide viewport): the
// `max-md:` viewport classes above never apply, so mirror
// them with pane-scoped equivalents.
paneMobile && "flex-1 border-e-0 border-b-0",
paneMobile && activeView !== "list" && "hidden",
// Tablet/Desktop: fixed width with collapse animation
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
@@ -3289,7 +3309,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
? undefined
: isHorizontalMailLayout
? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined)
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: clampEmailListWidth(emailListWidth) } : undefined)
}
>
{/* Mobile Header for List View */}
@@ -3576,12 +3596,21 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
}}
onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({
const store = useProTabStore.getState();
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
// In a split, open on the *other* side so this list stays
// visible; the shared reader tab keeps repeated
// double-clicks driving a single tab instead of piling up.
const sourcePane = proPaneId ?? 'main';
const targetPane = hasSplit
? (sourcePane === 'main' ? 'split' : 'main')
: sourcePane;
store.openEmailTab({
accountId: email.accountId ?? '',
emailId: email.id,
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
});
}, { pane: targetPane, reuseReader: hasSplit });
}) : undefined}
onOpenConversation={handleOpenConversation}
// Context menu handlers
@@ -3690,8 +3719,12 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative",
// Tablet/Desktop: relative. Pane-mobile instead overlays the
// pane (absolute within the relative content row above) - the
// viewport `max-md:` classes never fire there, and `fixed`
// would escape the pane.
!paneMobile && "md:relative",
paneMobile && (activeView === "viewer" ? "absolute inset-0 z-30 h-full" : "hidden"),
shouldHideViewerPane && "md:hidden",
shouldHideHorizontalViewerPane && "md:hidden"
)}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
dragKindFromTypes,
paneOnSide,
parseEmailIdsPayload,
resolveBodyDropTarget,
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
SPLIT_EDGE_FRACTION,
} from '@/components/pro/pro-shell-drop';
describe('dragKindFromTypes', () => {
it('recognises tab and email drags, tab winning when both are present', () => {
expect(dragKindFromTypes([PRO_TAB_DRAG_MIME])).toBe('tab');
expect(dragKindFromTypes([EMAIL_IDS_DRAG_MIME, 'text/plain'])).toBe('email');
expect(dragKindFromTypes([EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME])).toBe('tab');
expect(dragKindFromTypes(['text/plain', 'Files'])).toBeNull();
});
});
describe('parseEmailIdsPayload', () => {
it('parses id arrays and rejects malformed payloads', () => {
expect(parseEmailIdsPayload('["a","b"]')).toEqual(['a', 'b']);
expect(parseEmailIdsPayload('["a", 42, "", "b"]')).toEqual(['a', 'b']);
expect(parseEmailIdsPayload('{"not":"an array"}')).toEqual([]);
expect(parseEmailIdsPayload('not json')).toEqual([]);
expect(parseEmailIdsPayload('')).toEqual([]);
});
});
describe('paneOnSide', () => {
it('maps a visual side to the pane rendered there', () => {
expect(paneOnSide('left', 'right')).toBe('main');
expect(paneOnSide('right', 'right')).toBe('split');
expect(paneOnSide('left', 'left')).toBe('split');
expect(paneOnSide('right', 'left')).toBe('main');
});
});
describe('resolveBodyDropTarget - no split', () => {
const base = { isSplit: false, splitSide: 'right' as const, canCreateSplit: true };
it('edge zones create a split on that side', () => {
expect(resolveBodyDropTarget({ ...base, xFrac: 0.05, kind: 'tab' }))
.toEqual({ type: 'create-split', side: 'left' });
expect(resolveBodyDropTarget({ ...base, xFrac: 0.95, kind: 'email' }))
.toEqual({ type: 'create-split', side: 'right' });
// Exactly on the threshold still counts as the edge.
expect(resolveBodyDropTarget({ ...base, xFrac: SPLIT_EDGE_FRACTION, kind: 'tab' }))
.toEqual({ type: 'create-split', side: 'left' });
});
it('the middle of the body is not a target', () => {
expect(resolveBodyDropTarget({ ...base, xFrac: 0.5, kind: 'tab' })).toBeNull();
expect(resolveBodyDropTarget({ ...base, xFrac: 0.5, kind: 'email' })).toBeNull();
});
it('refuses to create an impossible split (dragging the only tab)', () => {
expect(resolveBodyDropTarget({ ...base, xFrac: 0.05, kind: 'tab', canCreateSplit: false }))
.toBeNull();
});
it('clamps out-of-range positions', () => {
expect(resolveBodyDropTarget({ ...base, xFrac: -0.3, kind: 'email' }))
.toEqual({ type: 'create-split', side: 'left' });
expect(resolveBodyDropTarget({ ...base, xFrac: 1.4, kind: 'email' }))
.toEqual({ type: 'create-split', side: 'right' });
});
});
describe('resolveBodyDropTarget - split active', () => {
const base = { isSplit: true, canCreateSplit: true };
it('each half targets the pane rendered there (split on the right)', () => {
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.25, kind: 'email' }))
.toEqual({ type: 'pane', pane: 'main', side: 'left' });
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.75, kind: 'email' }))
.toEqual({ type: 'pane', pane: 'split', side: 'right' });
});
it('side mapping follows the persisted splitSide (split on the left)', () => {
expect(resolveBodyDropTarget({ ...base, splitSide: 'left', xFrac: 0.25, kind: 'email' }))
.toEqual({ type: 'pane', pane: 'split', side: 'left' });
expect(resolveBodyDropTarget({ ...base, splitSide: 'left', xFrac: 0.75, kind: 'email' }))
.toEqual({ type: 'pane', pane: 'main', side: 'right' });
});
it('a tab hovering its own pane is not a target (no-op move)', () => {
expect(resolveBodyDropTarget({
...base, splitSide: 'right', xFrac: 0.25, kind: 'tab', draggedTabPane: 'main',
})).toBeNull();
expect(resolveBodyDropTarget({
...base, splitSide: 'right', xFrac: 0.75, kind: 'tab', draggedTabPane: 'main',
})).toEqual({ type: 'pane', pane: 'split', side: 'right' });
});
it('emails can drop on either pane regardless of source', () => {
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.25, kind: 'email' }))
.not.toBeNull();
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.75, kind: 'email' }))
.not.toBeNull();
});
});
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { ProTabBar } from '@/components/pro/pro-tab-bar';
import { PRO_TAB_DRAG_MIME, EMAIL_IDS_DRAG_MIME } from '@/components/pro/pro-shell-drop';
import { useProTabStore, type ProTab } from '@/stores/pro-tab-store';
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
class MockDataTransfer {
private _data: Record<string, string> = {};
types: string[] = [];
effectAllowed = '';
dropEffect = '';
setData(type: string, data: string) {
this._data[type] = data;
if (!this.types.includes(type)) this.types.push(type);
}
getData(type: string): string {
return this._data[type] ?? '';
}
setDragImage(_image: Element, _x: number, _y: number) {}
}
const HOME: ProTab = { id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' };
const CAL: ProTab = { id: 'cal', kind: 'calendar', labelKey: 'calendar', closeable: true, paneId: 'main' };
const EM: ProTab = {
id: 'em', kind: 'email', labelKey: '', title: 'Some subject', closeable: true, paneId: 'split',
emailData: { accountId: 'acc', emailId: 'msg-1', mailboxId: null, title: 'Some subject' },
};
function seedStore(tabs: ProTab[]) {
useProTabStore.setState({
tabs,
activeTabId: tabs.find((t) => t.paneId === 'main')!.id,
activeSplitTabId: tabs.find((t) => t.paneId === 'split')?.id ?? null,
focusedPaneId: 'main',
splitOrientation: tabs.some((t) => t.paneId === 'split') ? 'vertical' : null,
splitSide: 'right',
readerTabId: null,
loadedTabIds: tabs.map((t) => t.id),
});
}
describe('ProTabBar (per-pane strip)', () => {
beforeEach(() => {
seedStore([HOME, CAL, EM]);
});
it("renders only its own pane's tabs", () => {
render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={() => {}}
onClose={() => {}}
/>,
);
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(screen.queryByText('Some subject')).toBeNull();
});
it('clicking a tab activates it; middle-click closes closeable tabs', () => {
const onActivate = vi.fn();
const onClose = vi.fn();
render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={onActivate}
onClose={onClose}
/>,
);
const calTab = screen.getAllByRole('tab')[1];
fireEvent.click(calTab);
expect(onActivate).toHaveBeenCalledWith('cal');
fireEvent.mouseDown(calTab, { button: 1 });
expect(onClose).toHaveBeenCalledWith('cal');
// The pinned home tab has no close affordance.
fireEvent.mouseDown(screen.getAllByRole('tab')[0], { button: 1 });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('dropping a dragged tab on another tab reorders through the store', () => {
render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={() => {}}
onClose={() => {}}
/>,
);
const [homeEl, calEl] = screen.getAllByRole('tab');
const dt = new MockDataTransfer();
fireEvent.dragStart(calEl, { dataTransfer: dt });
expect(dt.getData(PRO_TAB_DRAG_MIME)).toBe('cal');
// jsdom rects are all-zero, so clientX 0 is not < left+width/2 -> 'after'
// is irrelevant here; drop directly with the recorded payload.
fireEvent.dragOver(homeEl, { dataTransfer: dt, clientX: 0 });
fireEvent.drop(homeEl, { dataTransfer: dt });
const order = useProTabStore.getState().tabs.map((t) => t.id);
expect(order.indexOf('cal')).toBeLessThan(order.indexOf('home-mail') + 2);
expect(order).toContain('cal');
expect(order).toContain('home-mail');
});
it('dropping a tab from the other pane onto this strip moves it across panes', () => {
render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={() => {}}
onClose={() => {}}
/>,
);
const dt = new MockDataTransfer();
dt.setData(PRO_TAB_DRAG_MIME, 'em');
fireEvent.drop(screen.getAllByRole('tab')[1], { dataTransfer: dt });
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === 'em')?.paneId).toBe('main');
// The split emptied, so it collapsed.
expect(s.splitOrientation).toBeNull();
});
it('dropping dragged emails on the strip reports their ids', () => {
const onEmailDrop = vi.fn();
render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={() => {}}
onClose={() => {}}
onEmailDrop={onEmailDrop}
/>,
);
const dt = new MockDataTransfer();
dt.setData(EMAIL_IDS_DRAG_MIME, JSON.stringify(['m1', 'm2']));
fireEvent.drop(screen.getAllByRole('tab')[0], { dataTransfer: dt });
expect(onEmailDrop).toHaveBeenCalledWith(['m1', 'm2']);
});
it("marks the focused pane's active tab with the accent edge", () => {
const { container, rerender } = render(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused
onActivate={() => {}}
onClose={() => {}}
/>,
);
expect(container.querySelector('.bg-primary')).not.toBeNull();
rerender(
<ProTabBar
paneId="main"
tabs={[HOME, CAL]}
activeTabId={HOME.id}
isFocused={false}
onActivate={() => {}}
onClose={() => {}}
/>,
);
expect(container.querySelector('.bg-primary')).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { ProPaneId, ProSplitSide } from "@/stores/pro-tab-store";
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
/**
* MIME type email rows put on the drag (see hooks/use-email-drag.ts). The
* Pro shell accepts these so a message can be dragged straight into a pane
* or an edge zone to open it there / split the screen with it.
*/
export const EMAIL_IDS_DRAG_MIME = "application/x-email-ids";
/** Fraction of the body width on each side that acts as a "create split" edge zone. */
export const SPLIT_EDGE_FRACTION = 0.2;
export type ShellDragKind = "tab" | "email";
export type BodyDropTarget =
| { type: "pane"; pane: ProPaneId; side: ProSplitSide }
| { type: "create-split"; side: ProSplitSide }
| null;
/** Classify a drag by its payload types. Tab drags win over email drags. */
export function dragKindFromTypes(types: readonly string[]): ShellDragKind | null {
if (types.includes(PRO_TAB_DRAG_MIME)) return "tab";
if (types.includes(EMAIL_IDS_DRAG_MIME)) return "email";
return null;
}
/** Which pane renders on a given visual side. */
export function paneOnSide(side: ProSplitSide, splitSide: ProSplitSide): ProPaneId {
return side === splitSide ? "split" : "main";
}
/**
* Pure resolution of a body-level drag position to a drop outcome.
*
* - Split active: each half of the body targets the pane rendered there.
* A tab hovering its own pane resolves to null (moving a tab onto the
* pane it's already in is a no-op, so no drop affordance is shown).
* - No split: only the left/right edge zones are live - dropping there
* creates a split on that side. `canCreateSplit` is false when the drag
* could never produce two non-empty panes (e.g. dragging the only tab),
* which resolves to null so the browser shows no-drop feedback instead
* of silently ignoring the gesture.
*/
export function resolveBodyDropTarget(args: {
/** Horizontal position within the panes container, 0..1. */
xFrac: number;
isSplit: boolean;
splitSide: ProSplitSide;
kind: ShellDragKind;
canCreateSplit: boolean;
/** For tab drags: the pane the dragged tab currently lives in. */
draggedTabPane?: ProPaneId | null;
}): BodyDropTarget {
const x = Math.min(1, Math.max(0, args.xFrac));
if (args.isSplit) {
const side: ProSplitSide = x < 0.5 ? "left" : "right";
const pane = paneOnSide(side, args.splitSide);
if (args.kind === "tab" && args.draggedTabPane === pane) return null;
return { type: "pane", pane, side };
}
let side: ProSplitSide | null = null;
if (x <= SPLIT_EDGE_FRACTION) side = "left";
else if (x >= 1 - SPLIT_EDGE_FRACTION) side = "right";
if (!side) return null;
if (!args.canCreateSplit) return null;
return { type: "create-split", side };
}
/** Parse the email-ids drag payload; returns [] for anything malformed. */
export function parseEmailIdsPayload(raw: string): string[] {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === "string" && id.length > 0);
} catch {
return [];
}
}
+77 -25
View File
@@ -4,19 +4,29 @@ import { useRef, useState, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProTabStore, type ProTab, type ProTabKind } from "@/stores/pro-tab-store";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import {
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
dragKindFromTypes,
parseEmailIdsPayload,
} from "@/components/pro/pro-shell-drop";
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
export { PRO_TAB_DRAG_MIME };
interface ProTabBarProps {
/** All tabs (both panes). Order in the array is the order in the bar. */
/** The pane this strip belongs to - it renders only that pane's tabs. */
paneId: ProPaneId;
tabs: ProTab[];
activeMainTabId: string | null;
activeSplitTabId: string | null;
activeTabId: string | null;
/** Whether this strip's pane holds keyboard/interaction focus. */
isFocused: boolean;
onActivate: (id: string) => void;
onClose: (id: string) => void;
onDragStateChange?: (dragging: boolean) => void;
/** Fires on tab drag start/end so the shell can show body drop zones. */
onDragStateChange?: (dragging: boolean, tabId: string | null) => void;
/** Emails dragged from a message list and dropped on this strip. */
onEmailDrop?: (emailIds: string[]) => void;
className?: string;
}
@@ -33,38 +43,47 @@ const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
export function ProTabBar({
paneId,
tabs,
activeMainTabId,
activeSplitTabId,
activeTabId,
isFocused,
onActivate,
onClose,
onDragStateChange,
onEmailDrop,
className,
}: ProTabBarProps) {
const tSidebar = useTranslations("sidebar");
const reorderTab = useProTabStore((s) => s.reorderTab);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
const [isEmailDragOver, setIsEmailDragOver] = useState(false);
const dragLeaveTimer = useRef<number | null>(null);
const isProTabDrag = (e: DragEvent) =>
e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const dragKind = (e: DragEvent) => dragKindFromTypes(e.dataTransfer.types);
const handleDragStart = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
e.dataTransfer.setData(PRO_TAB_DRAG_MIME, tab.id);
e.dataTransfer.effectAllowed = "move";
onDragStateChange?.(true);
onDragStateChange?.(true, tab.id);
};
const handleDragEnd = () => {
setDropIndicator(null);
onDragStateChange?.(false);
setIsEmailDragOver(false);
onDragStateChange?.(false, null);
};
const handleTabDragOver = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
if (!isProTabDrag(e)) return;
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
// Emails can land anywhere on the strip - no per-tab insertion caret.
e.dataTransfer.dropEffect = "copy";
setIsEmailDragOver(true);
return;
}
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const edge: "before" | "after" =
@@ -86,13 +105,27 @@ export function ProTabBar({
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
dragLeaveTimer.current = window.setTimeout(() => {
setDropIndicator(null);
setIsEmailDragOver(false);
dragLeaveTimer.current = null;
}, 40);
};
const handleEmailDrop = (e: DragEvent<HTMLDivElement>): boolean => {
if (!e.dataTransfer.types.includes(EMAIL_IDS_DRAG_MIME)) return false;
const ids = parseEmailIdsPayload(e.dataTransfer.getData(EMAIL_IDS_DRAG_MIME));
if (ids.length > 0) onEmailDrop?.(ids);
return true;
};
const handleTabDrop = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
if (!isProTabDrag(e)) return;
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
handleEmailDrop(e);
handleDragEnd();
return;
}
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId || draggedId === tab.id) {
handleDragEnd();
@@ -104,8 +137,14 @@ export function ProTabBar({
};
const handleStripEndDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
handleEmailDrop(e);
handleDragEnd();
return;
}
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) {
handleDragEnd();
@@ -119,8 +158,14 @@ export function ProTabBar({
};
const handleStripEndDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
e.dataTransfer.dropEffect = "copy";
setIsEmailDragOver(true);
return;
}
e.dataTransfer.dropEffect = "move";
const last = tabs[tabs.length - 1];
if (last) {
@@ -131,21 +176,19 @@ export function ProTabBar({
return (
<div
className={cn(
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
"relative flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
isEmailDragOver && "bg-primary/10",
className,
)}
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
role="tablist"
data-pane-strip={paneId}
onDragLeave={handleStripDragLeave}
>
{tabs.map((tab) => {
const Icon = TAB_ICONS[tab.kind];
const isActiveMain = tab.id === activeMainTabId && tab.paneId === 'main';
const isActiveSplit = tab.id === activeSplitTabId && tab.paneId === 'split';
const isActive = isActiveMain || isActiveSplit;
const isFocusedActive =
(isActiveMain && focusedPaneId === 'main')
|| (isActiveSplit && focusedPaneId === 'split');
const isActive = tab.id === activeTabId;
const isFocusedActive = isActive && isFocused;
const label = tab.title ?? tSidebar(tab.labelKey);
const showBefore = dropIndicator?.targetId === tab.id && dropIndicator.edge === "before";
const showAfter = dropIndicator?.targetId === tab.id && dropIndicator.edge === "after";
@@ -210,6 +253,15 @@ export function ProTabBar({
aria-hidden="true"
/>
)}
{/* Focused pane affordance: the active tab of the focused pane
carries an accent top edge, so it's always visible which side
of a split the next click/open will affect. */}
{isFocusedActive && (
<span
className="absolute left-0 right-0 top-0 h-0.5 bg-primary"
aria-hidden="true"
/>
)}
{showBefore && (
<span
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { createContext, useContext } from "react";
import type { ProPaneId } from "@/stores/pro-tab-store";
/**
* Which Pro pane hosts the current subtree. `null` means "not inside a Pro
* pane" (standard shell). Set by the Pro shell's Pane component so that
* pane-hosted apps can know which side of the split they live on (e.g. the
* mail app routing a double-clicked email to the opposite pane) and so that
* overlay chrome can scope itself to the pane.
*/
export const PaneIdContext = createContext<ProPaneId | null>(null);
export function usePaneId(): ProPaneId | null {
return useContext(PaneIdContext);
}
/**
* True when the current subtree renders inside a Pro pane.
*
* Overlays that go `fixed inset-0` in a mobile layout must switch to
* `absolute` positioning when this is true: inside a pane, "mobile" means
* the *pane* is phone-sized while the viewport is desktop-sized, so a
* viewport-fixed overlay would escape the pane and cover the whole app -
* and viewport CSS breakpoints (`sm:hidden` etc.) must not be trusted for
* the same reason. Each Pro tab body is an `absolute inset-0` wrapper and
* the email viewer root is `relative`, so pane-filling absolute overlays
* resolve to the pane without needing portals (and stay hidden with their
* tab when it's inactive).
*/
export function useIsPaneScoped(): boolean {
return useContext(PaneIdContext) !== null;
}
+446
View File
@@ -0,0 +1,446 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
useProTabStore,
normalizeProTabState,
type ProTab,
type ProTabCoreState,
type ProPaneId,
} from '@/stores/pro-tab-store';
const HOME_ID = 'home-mail';
function homeTab(): ProTab {
return { id: HOME_ID, kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' };
}
function emailTab(id: string, paneId: ProPaneId = 'main'): ProTab {
return {
id,
kind: 'email',
labelKey: '',
title: id,
closeable: true,
paneId,
emailData: { accountId: 'acc', emailId: `msg-${id}`, mailboxId: null, title: id },
};
}
function appTab(id: string, kind: 'settings' | 'calendar' | 'contacts' | 'files', paneId: ProPaneId = 'main'): ProTab {
return { id, kind, labelKey: kind, closeable: true, paneId };
}
function seed(state: Partial<ProTabCoreState> & { tabs: ProTab[] }) {
useProTabStore.setState({
activeTabId: state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_ID,
activeSplitTabId: state.tabs.find((t) => t.paneId === 'split')?.id ?? null,
focusedPaneId: 'main',
splitOrientation: state.tabs.some((t) => t.paneId === 'split') ? 'vertical' : null,
splitSide: 'right',
readerTabId: null,
loadedTabIds: state.tabs.map((t) => t.id),
...state,
});
}
function reset() {
seed({ tabs: [homeTab()] });
}
/** The invariants from the Pro shell contract (I1-I6). */
function assertInvariants(label = '') {
const s = useProTabStore.getState();
const main = s.tabs.filter((t) => t.paneId === 'main');
const split = s.tabs.filter((t) => t.paneId === 'split');
// I1: never empty.
expect(s.tabs.length, `${label} I1`).toBeGreaterThan(0);
// I4: main never empty.
expect(main.length, `${label} I4`).toBeGreaterThan(0);
// I3: active ids point into their panes.
expect(main.some((t) => t.id === s.activeTabId), `${label} I3 main`).toBe(true);
if (split.length === 0) {
expect(s.activeSplitTabId, `${label} I2/I3 split`).toBeNull();
expect(s.splitOrientation, `${label} I2 orientation`).toBeNull();
// I5: focus can't sit on a nonexistent pane.
expect(s.focusedPaneId, `${label} I5`).toBe('main');
} else {
expect(split.some((t) => t.id === s.activeSplitTabId), `${label} I3 split`).toBe(true);
expect(s.splitOrientation, `${label} I2 orientation`).toBe('vertical');
}
// I6: loaded contains actives, and only existing tabs.
expect(s.loadedTabIds, `${label} I6 active`).toContain(s.activeTabId);
if (s.activeSplitTabId) {
expect(s.loadedTabIds, `${label} I6 split active`).toContain(s.activeSplitTabId);
}
for (const id of s.loadedTabIds) {
expect(s.tabs.some((t) => t.id === id), `${label} I6 ghost ${id}`).toBe(true);
}
// Reader tab must be a live email tab.
if (s.readerTabId) {
expect(
s.tabs.some((t) => t.id === s.readerTabId && t.kind === 'email'),
`${label} reader`,
).toBe(true);
}
}
beforeEach(reset);
describe('openTab', () => {
it('creates an app tab in the focused pane and activates it', () => {
const id = useProTabStore.getState().openTab('settings');
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === id)?.kind).toBe('settings');
expect(s.activeTabId).toBe(id);
expect(s.focusedPaneId).toBe('main');
assertInvariants();
});
it('is a singleton across panes: re-opening focuses the existing tab instead of duplicating', () => {
seed({ tabs: [homeTab(), appTab('set', 'settings', 'split')], focusedPaneId: 'main' });
const id = useProTabStore.getState().openTab('settings');
const s = useProTabStore.getState();
expect(id).toBe('set');
expect(s.tabs.filter((t) => t.kind === 'settings')).toHaveLength(1);
// Focus follows the tab to its pane.
expect(s.focusedPaneId).toBe('split');
expect(s.activeSplitTabId).toBe('set');
assertInvariants();
});
it('focused in split with settings open in main: focuses main instead of opening a second settings tab', () => {
seed({
tabs: [homeTab(), appTab('set', 'settings', 'main'), emailTab('e1', 'split')],
focusedPaneId: 'split',
});
const id = useProTabStore.getState().openTab('settings');
const s = useProTabStore.getState();
expect(id).toBe('set');
expect(s.tabs.filter((t) => t.kind === 'settings')).toHaveLength(1);
expect(s.activeTabId).toBe('set');
expect(s.focusedPaneId).toBe('main');
assertInvariants();
});
it('opens new tabs into the focused pane when a split exists', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')], focusedPaneId: 'split' });
const id = useProTabStore.getState().openTab('calendar');
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === id)?.paneId).toBe('split');
expect(s.activeSplitTabId).toBe(id);
assertInvariants();
});
});
describe('openEmailTab', () => {
const data = (n: number) => ({
accountId: 'acc',
emailId: `msg-${n}`,
mailboxId: null,
title: `Mail ${n}`,
});
it('opens in the focused pane by default', () => {
const id = useProTabStore.getState().openEmailTab(data(1));
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === id)?.paneId).toBe('main');
expect(s.activeTabId).toBe(id);
assertInvariants();
});
it('focuses an already-open email instead of opening it twice', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')], focusedPaneId: 'main' });
const existing = useProTabStore.getState().tabs.find((t) => t.id === 'e1')!;
const id = useProTabStore.getState().openEmailTab({
accountId: existing.emailData!.accountId,
emailId: existing.emailData!.emailId,
mailboxId: null,
title: 'whatever',
});
const s = useProTabStore.getState();
expect(id).toBe('e1');
expect(s.activeSplitTabId).toBe('e1');
expect(s.focusedPaneId).toBe('split');
assertInvariants();
});
it("pane: 'split' with no split creates one", () => {
const id = useProTabStore.getState().openEmailTab(data(2), { pane: 'split' });
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === id)?.paneId).toBe('split');
expect(s.splitOrientation).toBe('vertical');
expect(s.activeSplitTabId).toBe(id);
assertInvariants();
});
it('reuseReader drives a single tab across repeated opens in the same pane', () => {
const first = useProTabStore.getState().openEmailTab(data(3), { pane: 'split', reuseReader: true });
const second = useProTabStore.getState().openEmailTab(data(4), { pane: 'split', reuseReader: true });
const s = useProTabStore.getState();
expect(second).toBe(first);
const reader = s.tabs.find((t) => t.id === first)!;
expect(reader.emailData?.emailId).toBe('msg-4');
expect(reader.title).toBe('Mail 4');
expect(s.tabs.filter((t) => t.kind === 'email')).toHaveLength(1);
assertInvariants();
});
it('creates a fresh reader when the old one moved to another pane', () => {
const first = useProTabStore.getState().openEmailTab(data(5), { pane: 'split', reuseReader: true });
useProTabStore.getState().moveTabToPane(first, 'main');
const second = useProTabStore.getState().openEmailTab(data(6), { pane: 'split', reuseReader: true });
const s = useProTabStore.getState();
expect(second).not.toBe(first);
expect(s.tabs.find((t) => t.id === first)?.emailData?.emailId).toBe('msg-5');
expect(s.readerTabId).toBe(second);
assertInvariants();
});
});
describe('closeTab', () => {
it('activates a neighbour when the active tab closes', () => {
seed({ tabs: [homeTab(), appTab('a', 'calendar'), appTab('b', 'contacts')], activeTabId: 'a' });
useProTabStore.getState().closeTab('a');
const s = useProTabStore.getState();
expect(s.tabs.map((t) => t.id)).toEqual([HOME_ID, 'b']);
expect(s.activeTabId).toBe('b');
assertInvariants();
});
it('collapses the split when its last tab closes', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')], focusedPaneId: 'split' });
useProTabStore.getState().closeTab('e1');
const s = useProTabStore.getState();
expect(s.splitOrientation).toBeNull();
expect(s.activeSplitTabId).toBeNull();
expect(s.focusedPaneId).toBe('main');
assertInvariants();
});
it('never closes the pinned home tab', () => {
useProTabStore.getState().closeTab(HOME_ID);
expect(useProTabStore.getState().tabs.some((t) => t.id === HOME_ID)).toBe(true);
assertInvariants();
});
it('clears a stale readerTabId when the reader closes', () => {
const id = useProTabStore.getState().openEmailTab(
{ accountId: 'acc', emailId: 'm', mailboxId: null, title: 'm' },
{ reuseReader: true },
);
expect(useProTabStore.getState().readerTabId).toBe(id);
useProTabStore.getState().closeTab(id);
expect(useProTabStore.getState().readerTabId).toBeNull();
assertInvariants();
});
});
describe('moveTabToPane', () => {
it('creates a split on the requested side', () => {
seed({ tabs: [homeTab(), appTab('cal', 'calendar')] });
useProTabStore.getState().moveTabToPane('cal', 'split', { side: 'left' });
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === 'cal')?.paneId).toBe('split');
expect(s.splitSide).toBe('left');
expect(s.activeSplitTabId).toBe('cal');
expect(s.focusedPaneId).toBe('split');
assertInvariants();
});
it('keeps the persisted side when extending an existing split', () => {
seed({ tabs: [homeTab(), appTab('cal', 'calendar'), emailTab('e1', 'split')], splitSide: 'left' });
useProTabStore.getState().moveTabToPane('cal', 'split', { side: 'right' });
expect(useProTabStore.getState().splitSide).toBe('left');
assertInvariants();
});
it('merges panes instead of refusing when the last main tab moves into the split', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split'), emailTab('e2', 'split')] });
useProTabStore.getState().moveTabToPane(HOME_ID, 'split');
const s = useProTabStore.getState();
// Everything ended up in one pane: no split remains.
expect(s.tabs.every((t) => t.paneId === 'main')).toBe(true);
expect(s.splitOrientation).toBeNull();
// The moved tab is the active one - the user's drag intent is honoured.
expect(s.activeTabId).toBe(HOME_ID);
assertInvariants();
});
it('collapses the split when its last tab moves back to main', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')] });
useProTabStore.getState().moveTabToPane('e1', 'main');
const s = useProTabStore.getState();
expect(s.splitOrientation).toBeNull();
expect(s.activeTabId).toBe('e1');
assertInvariants();
});
});
describe('reorderTab', () => {
it('reorders within a pane', () => {
seed({ tabs: [homeTab(), appTab('a', 'calendar'), appTab('b', 'contacts')] });
useProTabStore.getState().reorderTab('b', HOME_ID, 'after');
expect(useProTabStore.getState().tabs.map((t) => t.id)).toEqual([HOME_ID, 'b', 'a']);
assertInvariants();
});
it('moves across panes and activates the moved tab there', () => {
seed({ tabs: [homeTab(), appTab('a', 'calendar'), emailTab('e1', 'split')] });
useProTabStore.getState().reorderTab('a', 'e1', 'after');
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === 'a')?.paneId).toBe('split');
expect(s.activeSplitTabId).toBe('a');
expect(s.focusedPaneId).toBe('split');
assertInvariants();
});
it('merges panes when a cross-pane reorder empties main', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')] });
useProTabStore.getState().reorderTab(HOME_ID, 'e1', 'before');
const s = useProTabStore.getState();
expect(s.tabs.every((t) => t.paneId === 'main')).toBe(true);
expect(s.splitOrientation).toBeNull();
// Order carries the drop position.
expect(s.tabs.map((t) => t.id)).toEqual([HOME_ID, 'e1']);
assertInvariants();
});
});
describe('focus and activation', () => {
it('setFocusedPane(split) without a split is normalized back to main', () => {
useProTabStore.getState().setFocusedPane('split');
expect(useProTabStore.getState().focusedPaneId).toBe('main');
assertInvariants();
});
it('setActiveTab activates in the correct pane and focuses it', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split')], focusedPaneId: 'main' });
useProTabStore.getState().setActiveTab('e1');
const s = useProTabStore.getState();
expect(s.activeSplitTabId).toBe('e1');
expect(s.focusedPaneId).toBe('split');
// Main pane's active tab is untouched.
expect(s.activeTabId).toBe(HOME_ID);
assertInvariants();
});
it('collapseSplit returns every split tab to main', () => {
seed({ tabs: [homeTab(), emailTab('e1', 'split'), emailTab('e2', 'split')] });
useProTabStore.getState().collapseSplit();
const s = useProTabStore.getState();
expect(s.tabs.every((t) => t.paneId === 'main')).toBe(true);
expect(s.splitOrientation).toBeNull();
assertInvariants();
});
});
describe('normalizeProTabState', () => {
const base: ProTabCoreState = {
tabs: [],
activeTabId: 'nope',
activeSplitTabId: 'nope',
focusedPaneId: 'split',
splitOrientation: 'vertical',
splitSide: 'right',
readerTabId: 'nope',
loadedTabIds: ['ghost'],
};
it('restores the home tab when everything is gone', () => {
const s = normalizeProTabState(base);
expect(s.tabs.map((t) => t.id)).toEqual([HOME_ID]);
expect(s.activeTabId).toBe(HOME_ID);
expect(s.activeSplitTabId).toBeNull();
expect(s.splitOrientation).toBeNull();
expect(s.focusedPaneId).toBe('main');
expect(s.loadedTabIds).toEqual([HOME_ID]);
});
it('promotes split tabs to main when main is empty (the "invisible tabs" healing)', () => {
const s = normalizeProTabState({
...base,
tabs: [emailTab('e1', 'split'), emailTab('e2', 'split')],
activeSplitTabId: 'e2',
});
expect(s.tabs.every((t) => t.paneId === 'main')).toBe(true);
expect(s.activeTabId).toBe('e2');
expect(s.activeSplitTabId).toBeNull();
});
it('repairs invalid active ids, stale focus, ghost loaded ids and dead reader ids', () => {
const s = normalizeProTabState({
...base,
tabs: [homeTab(), emailTab('e1', 'split')],
activeTabId: 'e1', // wrong pane
activeSplitTabId: 'missing',
loadedTabIds: ['ghost', HOME_ID],
readerTabId: HOME_ID, // not an email tab
});
expect(s.activeTabId).toBe(HOME_ID);
expect(s.activeSplitTabId).toBe('e1');
expect(s.splitOrientation).toBe('vertical');
expect(s.focusedPaneId).toBe('split');
expect(s.loadedTabIds).toEqual([HOME_ID, 'e1']);
expect(s.readerTabId).toBeNull();
});
it('backfills a missing paneId (v2 persisted tabs)', () => {
const legacy = { ...homeTab() } as ProTab & { paneId?: ProPaneId };
delete (legacy as { paneId?: ProPaneId }).paneId;
const s = normalizeProTabState({ ...base, tabs: [legacy as ProTab], focusedPaneId: 'main' });
expect(s.tabs[0].paneId).toBe('main');
});
});
describe('property: random operation sequences never violate the invariants', () => {
function mulberry32(seed: number) {
let a = seed;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
it.each([1, 7, 42, 1337])('seed %i', (seedValue) => {
const rand = mulberry32(seedValue);
reset();
const kinds = ['mail', 'calendar', 'contacts', 'files', 'settings'] as const;
for (let i = 0; i < 400; i++) {
const s = useProTabStore.getState();
const anyTab = s.tabs[Math.floor(rand() * s.tabs.length)];
const otherTab = s.tabs[Math.floor(rand() * s.tabs.length)];
const op = Math.floor(rand() * 9);
switch (op) {
case 0: s.openTab(kinds[Math.floor(rand() * kinds.length)]); break;
case 1:
s.openEmailTab(
{ accountId: 'acc', emailId: `m-${Math.floor(rand() * 25)}`, mailboxId: null, title: 'x' },
rand() < 0.5
? { pane: rand() < 0.5 ? 'main' : 'split', reuseReader: rand() < 0.5 }
: undefined,
);
break;
case 2: s.openComposeTab({ sessionId: i, mode: 'compose', title: 'compose' }); break;
case 3: if (anyTab) s.closeTab(anyTab.id); break;
case 4:
if (anyTab) {
s.moveTabToPane(anyTab.id, rand() < 0.5 ? 'main' : 'split', {
side: rand() < 0.5 ? 'left' : 'right',
});
}
break;
case 5:
if (anyTab && otherTab) {
s.reorderTab(anyTab.id, otherTab.id, rand() < 0.5 ? 'before' : 'after');
}
break;
case 6: s.setFocusedPane(rand() < 0.5 ? 'main' : 'split'); break;
case 7: if (anyTab) s.setActiveTab(anyTab.id); break;
case 8: s.collapseSplit(); break;
}
assertInvariants(`seed ${seedValue} step ${i} op ${op}`);
}
});
});
+436 -393
View File
@@ -6,7 +6,13 @@ export type ProTabKind =
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
| 'compose' | 'email';
export type ProAppTabKind = Exclude<ProTabKind, 'compose' | 'email'>;
export type ProPaneId = 'main' | 'split';
/** Which visual side of the shell the split pane renders on. Persisted, so a reload never flips the layout. */
export type ProSplitSide = 'left' | 'right';
/**
* Pro split layout. Only side-by-side is supported - the pane that "splits
* off" always lives next to the main pane on the horizontal axis. Kept as
@@ -73,19 +79,44 @@ export interface ProTab {
paneId: ProPaneId;
}
interface ProTabState {
export interface OpenEmailTabOptions {
/**
* Force the tab into a specific pane. Passing 'split' when no split exists
* creates one (used by the shell's drop zones and by double-click routing).
*/
pane?: ProPaneId;
/**
* Reuse the shared "reader" email tab in the target pane instead of piling
* up a new tab per opened message (VS Code preview-tab behaviour). The
* reader tab is whatever email tab this flow created last; explicit flows
* that want a durable tab simply don't pass this.
*/
reuseReader?: boolean;
}
/**
* The data half of the store - everything `normalizeProTabState` operates on.
* Kept separate from the actions so the normalizer stays a pure function.
*/
export interface ProTabCoreState {
tabs: ProTab[];
/** Active tab in each pane. `split` is null when there is no split. */
activeTabId: string;
activeSplitTabId: string | null;
/** When the user last clicked into a tab/body, which pane was it? */
focusedPaneId: ProPaneId;
/** Derived: non-null exactly when the split pane has tabs. */
splitOrientation: ProSplitOrientation | null;
splitSide: ProSplitSide;
/** The reusable double-click/drop "reader" email tab, if it still exists. */
readerTabId: string | null;
loadedTabIds: string[];
}
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
interface ProTabState extends ProTabCoreState {
openTab: (kind: ProAppTabKind) => string;
openComposeTab: (data: ProComposeTabData) => string;
openEmailTab: (data: ProEmailTabData) => string;
openEmailTab: (data: ProEmailTabData, opts?: OpenEmailTabOptions) => string;
closeTab: (id: string) => void;
/**
* Request closing a tab, honouring any registered close interceptor (e.g. a
@@ -95,6 +126,7 @@ interface ProTabState {
requestCloseTab: (id: string) => void;
setActiveTab: (id: string) => void;
setFocusedPane: (paneId: ProPaneId) => void;
setSplitSide: (side: ProSplitSide) => void;
/**
* Move a tab next to another tab. `edge` controls whether it lands before
@@ -105,14 +137,12 @@ interface ProTabState {
reorderTab: (draggedId: string, targetTabId: string, edge: 'before' | 'after') => void;
/**
* Move a tab to a specific pane. If moving into `split` and no split
* exists, opens a new split using the supplied orientation.
* Move a tab to a specific pane. Moving into `split` when no split exists
* opens one (on `opts.side` if given, else the persisted side). Moving the
* last main-pane tab into the split merges the panes instead of refusing -
* no drag is ever a silent no-op.
*/
moveTabToPane: (
tabId: string,
paneId: ProPaneId,
orientation?: ProSplitOrientation,
) => void;
moveTabToPane: (tabId: string, paneId: ProPaneId, opts?: { side?: ProSplitSide }) => void;
/** Collapse the split: every split-pane tab returns to main. */
collapseSplit: () => void;
@@ -121,7 +151,7 @@ interface ProTabState {
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
}
const TAB_BLUEPRINTS: Record<'mail' | 'calendar' | 'contacts' | 'files' | 'settings', { labelKey: string }> = {
const TAB_BLUEPRINTS: Record<ProAppTabKind, { labelKey: string }> = {
mail: { labelKey: 'mail' },
calendar: { labelKey: 'calendar' },
contacts: { labelKey: 'contacts' },
@@ -166,410 +196,423 @@ function neighborInPane(tabs: ProTab[], removedId: string, paneId: ProPaneId): s
return (inPane[idx + 1] ?? inPane[idx - 1])?.id ?? null;
}
export const useProTabStore = create<ProTabState>()(
persist(
(set, get) => ({
/**
* The single invariant enforcer. Every mutation builds its intended next
* state and runs it through here; rehydration runs through here too. The
* guarantees (see the Pro shell plan):
*
* I1 At least one tab always exists (the pinned home Mail tab is
* recreated if the list somehow empties).
* I2 Every tab belongs to a pane; the split pane exists iff it has tabs.
* I3 Each pane's active tab id points at a tab in that pane.
* I4 If main empties while split has tabs, the split pane *becomes* the
* main pane (pane merge) - callers never have to special-case it.
* I5 `focusedPaneId` points at an existing pane.
* I6 `loadedTabIds` contains both active tabs and only existing tabs.
*
* Pure function - exported for direct unit/property testing.
*/
export function normalizeProTabState(input: ProTabCoreState): ProTabCoreState {
let tabs = input.tabs.map((t) => (t.paneId ? t : { ...t, paneId: 'main' as const }));
// I1: never empty.
if (tabs.length === 0) {
return {
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
activeSplitTabId: null,
focusedPaneId: 'main',
splitOrientation: null,
splitSide: input.splitSide === 'left' ? 'left' : 'right',
readerTabId: null,
loadedTabIds: [HOME_TAB.id],
};
}
openTab: (kind) => {
const state = get();
const targetPane = state.focusedPaneId;
const existing = state.tabs.find((tab) => tab.kind === kind && tab.paneId === targetPane);
if (existing) {
if (targetPane === 'main') {
set({
activeTabId: existing.id,
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
} else {
set({
activeSplitTabId: existing.id,
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
let activeTabId = input.activeTabId;
let activeSplitTabId = input.activeSplitTabId;
// I4: main never sits empty next to a populated split - the split pane
// takes over as main. The user's split-side focus carries across.
if (!tabs.some((t) => t.paneId === 'main')) {
tabs = tabs.map((t) => (t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t));
if (activeSplitTabId) activeTabId = activeSplitTabId;
activeSplitTabId = null;
}
const mainTabs = tabs.filter((t) => t.paneId === 'main');
const splitTabs = tabs.filter((t) => t.paneId === 'split');
const hasSplit = splitTabs.length > 0;
// I3: per-pane active ids must exist in their pane.
if (!mainTabs.some((t) => t.id === activeTabId)) {
activeTabId = mainTabs[0].id;
}
if (!hasSplit) {
activeSplitTabId = null;
} else if (!splitTabs.some((t) => t.id === activeSplitTabId)) {
activeSplitTabId = splitTabs[0].id;
}
// I2 (derived flag) + I5.
const splitOrientation: ProSplitOrientation | null = hasSplit ? 'vertical' : null;
const focusedPaneId: ProPaneId =
input.focusedPaneId === 'split' && hasSplit ? 'split' : 'main';
// Reader tab must still exist and still be an email tab.
const readerTabId = tabs.some((t) => t.id === input.readerTabId && t.kind === 'email')
? input.readerTabId
: null;
// I6: prune ghosts, keep both actives renderable.
const ids = new Set(tabs.map((t) => t.id));
let loadedTabIds = input.loadedTabIds.filter((id) => ids.has(id));
if (!loadedTabIds.includes(activeTabId)) loadedTabIds = [...loadedTabIds, activeTabId];
if (activeSplitTabId && !loadedTabIds.includes(activeSplitTabId)) {
loadedTabIds = [...loadedTabIds, activeSplitTabId];
}
return {
tabs,
activeTabId,
activeSplitTabId,
focusedPaneId,
splitOrientation,
splitSide: input.splitSide === 'left' ? 'left' : 'right',
readerTabId,
loadedTabIds,
};
}
/** True when the (normalized) state currently renders two panes. */
export function hasSplitPane(state: Pick<ProTabCoreState, 'tabs'>): boolean {
return state.tabs.some((t) => t.paneId === 'split');
}
function core(state: ProTabState): ProTabCoreState {
return {
tabs: state.tabs,
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId,
focusedPaneId: state.focusedPaneId,
splitOrientation: state.splitOrientation,
splitSide: state.splitSide,
readerTabId: state.readerTabId,
loadedTabIds: state.loadedTabIds,
};
}
/** Mark a tab active (and focused) in whichever pane it lives in. */
function withTabActivated(state: ProTabCoreState, tab: ProTab): ProTabCoreState {
return {
...state,
...(tab.paneId === 'main'
? { activeTabId: tab.id, focusedPaneId: 'main' as const }
: { activeSplitTabId: tab.id, focusedPaneId: 'split' as const }),
loadedTabIds: state.loadedTabIds.includes(tab.id)
? state.loadedTabIds
: [...state.loadedTabIds, tab.id],
};
}
export const useProTabStore = create<ProTabState>()(
persist(
(set, get) => {
/** Apply a mutation to the core state and commit the normalized result. */
const commit = (mutate: (s: ProTabCoreState) => ProTabCoreState) => {
set(normalizeProTabState(mutate(core(get()))));
};
return {
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
activeSplitTabId: null,
focusedPaneId: 'main',
splitOrientation: null,
splitSide: 'right',
readerTabId: null,
loadedTabIds: [HOME_TAB.id],
openTab: (kind) => {
const state = core(get());
// App tabs are singletons across the whole workspace: opening one
// that already exists focuses it wherever it lives (fixes the
// duplicate-Settings-tab bug), it never creates a second copy.
const existing = state.tabs.find((tab) => tab.kind === kind);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
return existing.id;
}
const blueprint = TAB_BLUEPRINTS[kind];
const newTab: ProTab = {
id: makeId(),
kind,
labelKey: blueprint.labelKey,
closeable: true,
paneId: targetPane,
};
set({
tabs: [...state.tabs, newTab],
...(targetPane === 'main'
? { activeTabId: newTab.id }
: { activeSplitTabId: newTab.id }),
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
openComposeTab: (data) => {
const state = get();
const targetPane = state.focusedPaneId;
const newTab: ProTab = {
id: makeId(),
kind: 'compose',
labelKey: '',
title: data.title,
closeable: true,
composeData: data,
paneId: targetPane,
};
set({
tabs: [...state.tabs, newTab],
...(targetPane === 'main'
? { activeTabId: newTab.id }
: { activeSplitTabId: newTab.id }),
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
openEmailTab: (data) => {
const state = get();
const targetPane = state.focusedPaneId;
const existing = state.tabs.find(
(tab) => tab.kind === 'email'
&& tab.emailData?.emailId === data.emailId
&& tab.emailData?.accountId === data.accountId
);
if (existing) {
// Focus the existing email tab in its current pane.
if (existing.paneId === 'main') {
set({
activeTabId: existing.id,
focusedPaneId: 'main',
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
} else {
set({
activeSplitTabId: existing.id,
focusedPaneId: 'split',
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
}
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind: 'email',
labelKey: '',
title: data.title,
closeable: true,
emailData: data,
paneId: targetPane,
};
set({
tabs: [...state.tabs, newTab],
...(targetPane === 'main'
? { activeTabId: newTab.id }
: { activeSplitTabId: newTab.id }),
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
requestCloseTab: (id) => {
const interceptor = closeInterceptors.get(id);
if (interceptor) {
interceptor();
return;
}
get().closeTab(id);
},
closeTab: (id) => {
const state = get();
const tab = state.tabs.find((t) => t.id === id);
if (!tab || !tab.closeable) return;
// Drop any registered close interceptor for this tab.
closeInterceptors.delete(id);
const removedPane = tab.paneId;
const newTabs = state.tabs.filter((t) => t.id !== id);
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
let activeTabId = state.activeTabId;
let activeSplitTabId = state.activeSplitTabId;
let splitOrientation = state.splitOrientation;
let focusedPaneId = state.focusedPaneId;
if (removedPane === 'main' && state.activeTabId === id) {
activeTabId = neighborInPane(newTabs, id, 'main') ?? HOME_TAB.id;
}
if (removedPane === 'split' && state.activeSplitTabId === id) {
activeSplitTabId = neighborInPane(newTabs, id, 'split');
}
// If the split pane is empty, collapse the split.
const stillSplit = newTabs.some((t) => t.paneId === 'split');
if (!stillSplit) {
activeSplitTabId = null;
splitOrientation = null;
focusedPaneId = 'main';
}
// Guard: never let the tab list be fully empty.
if (newTabs.length === 0) {
set({
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
activeSplitTabId: null,
splitOrientation: null,
focusedPaneId: 'main',
loadedTabIds: [HOME_TAB.id],
});
return;
}
// Make sure the chosen active tab is loaded.
const ensureLoaded = (loaded: string[], id: string | null) =>
id && !loaded.includes(id) ? [...loaded, id] : loaded;
const loaded = ensureLoaded(ensureLoaded(newLoaded, activeTabId), activeSplitTabId);
set({
tabs: newTabs,
activeTabId,
activeSplitTabId,
splitOrientation,
focusedPaneId,
loadedTabIds: loaded,
});
},
setActiveTab: (id) => {
const state = get();
const tab = state.tabs.find((t) => t.id === id);
if (!tab) return;
const loaded = state.loadedTabIds.includes(id)
? state.loadedTabIds
: [...state.loadedTabIds, id];
if (tab.paneId === 'main') {
if (state.activeTabId === id && state.focusedPaneId === 'main') return;
set({ activeTabId: id, focusedPaneId: 'main', loadedTabIds: loaded });
} else {
if (state.activeSplitTabId === id && state.focusedPaneId === 'split') return;
set({ activeSplitTabId: id, focusedPaneId: 'split', loadedTabIds: loaded });
}
},
setFocusedPane: (paneId) => {
const state = get();
if (state.focusedPaneId === paneId) return;
// Switching focus to the split pane is only meaningful when it exists.
if (paneId === 'split' && state.splitOrientation === null) return;
set({ focusedPaneId: paneId });
},
reorderTab: (draggedId, targetTabId, edge) => {
const state = get();
if (draggedId === targetTabId) return;
const dragged = state.tabs.find((t) => t.id === draggedId);
const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return;
// Mirror the moveTabToPane guard: never let a cross-pane reorder
// empty the main pane, which would otherwise leave the layout with
// a blank main pane next to a populated split pane.
if (dragged.paneId === 'main' && target.paneId !== 'main') {
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== draggedId);
if (otherMainTabs.length === 0) return;
}
const next = state.tabs.filter((t) => t.id !== draggedId);
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const reassigned: ProTab = dragged.paneId === target.paneId
? dragged
: { ...dragged, paneId: target.paneId };
next.splice(insertAt, 0, reassigned);
// If the dragged tab was active in its old pane and just moved to a
// different pane, fix up the active ids so the empty side doesn't
// hang on to a stale id.
const patch: Partial<ProTabState> = { tabs: next };
if (dragged.paneId !== target.paneId) {
if (dragged.paneId === 'main' && state.activeTabId === draggedId) {
patch.activeTabId = neighborInPane(next, draggedId, 'main') ?? HOME_TAB.id;
}
if (dragged.paneId === 'split' && state.activeSplitTabId === draggedId) {
patch.activeSplitTabId = neighborInPane(next, draggedId, 'split');
}
// Make the dragged tab active in its new home.
if (target.paneId === 'main') {
patch.activeTabId = draggedId;
patch.focusedPaneId = 'main';
} else {
patch.activeSplitTabId = draggedId;
patch.focusedPaneId = 'split';
}
// Collapse the split if it just emptied.
const stillSplit = next.some((t) => t.paneId === 'split');
if (!stillSplit) {
patch.activeSplitTabId = null;
patch.splitOrientation = null;
patch.focusedPaneId = 'main';
}
}
set(patch);
},
moveTabToPane: (tabId, paneId, orientation) => {
const state = get();
const tab = state.tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.paneId === paneId) return;
// The home tab can move freely (and the unsplit guard below restores
// sanity), but if moving it would leave main empty we prevent it.
const movingFromMain = tab.paneId === 'main';
if (movingFromMain) {
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== tabId);
if (otherMainTabs.length === 0) return; // refuse to empty main
}
const newTabs = state.tabs.map((t) => t.id === tabId ? { ...t, paneId } : t);
const patch: Partial<ProTabState> = { tabs: newTabs };
if (paneId === 'split') {
// Creating or extending a split.
patch.splitOrientation = state.splitOrientation ?? orientation ?? 'vertical';
patch.activeSplitTabId = tabId;
patch.focusedPaneId = 'split';
// If main lost its active tab, pick a neighbor.
if (state.activeTabId === tabId) {
patch.activeTabId = neighborInPane(newTabs, tabId, 'main') ?? HOME_TAB.id;
}
} else {
patch.activeTabId = tabId;
patch.focusedPaneId = 'main';
if (state.activeSplitTabId === tabId) {
patch.activeSplitTabId = neighborInPane(newTabs, tabId, 'split');
}
// Collapse if split just emptied.
const stillSplit = newTabs.some((t) => t.paneId === 'split');
if (!stillSplit) {
patch.activeSplitTabId = null;
patch.splitOrientation = null;
}
}
const loaded = state.loadedTabIds.includes(tabId)
? state.loadedTabIds
: [...state.loadedTabIds, tabId];
patch.loadedTabIds = loaded;
set(patch);
},
collapseSplit: () => {
const state = get();
if (state.splitOrientation === null) return;
const newTabs = state.tabs.map((t) =>
t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t
);
set({
tabs: newTabs,
activeSplitTabId: null,
splitOrientation: null,
focusedPaneId: 'main',
});
},
updateTabTitle: (id, title) => {
const state = get();
const tabs = state.tabs.map((tab) =>
tab.id === id ? { ...tab, title } : tab
);
set({ tabs });
},
updateComposeDraft: (id, draft) => {
const state = get();
const tabs = state.tabs.map((tab) => {
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
return {
...tab,
composeData: { ...tab.composeData, initialData: draft },
const newTab: ProTab = {
id: makeId(),
kind,
labelKey: TAB_BLUEPRINTS[kind].labelKey,
closeable: true,
paneId: state.focusedPaneId,
};
});
set({ tabs });
},
}),
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
openComposeTab: (data) => {
const newTab: ProTab = {
id: makeId(),
kind: 'compose',
labelKey: '',
title: data.title,
closeable: true,
composeData: data,
paneId: get().focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
openEmailTab: (data, opts) => {
const state = core(get());
// Same email already open anywhere: focus it, never open twice.
const existing = state.tabs.find(
(tab) => tab.kind === 'email'
&& tab.emailData?.emailId === data.emailId
&& tab.emailData?.accountId === data.accountId
);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const targetPane: ProPaneId = opts?.pane ?? state.focusedPaneId;
// Reuse the reader tab in the target pane: replace its content
// in place so double-click / drop flows drive a single tab
// instead of accumulating one per message.
if (opts?.reuseReader && state.readerTabId) {
const reader = state.tabs.find(
(t) => t.id === state.readerTabId && t.kind === 'email' && t.paneId === targetPane,
);
if (reader) {
commit((s) => withTabActivated({
...s,
tabs: s.tabs.map((t) => t.id === reader.id
? { ...t, emailData: data, title: data.title }
: t),
}, reader));
return reader.id;
}
}
const newTab: ProTab = {
id: makeId(),
kind: 'email',
labelKey: '',
title: data.title,
closeable: true,
emailData: data,
paneId: targetPane,
};
commit((s) => withTabActivated({
...s,
tabs: [...s.tabs, newTab],
readerTabId: opts?.reuseReader ? newTab.id : s.readerTabId,
}, newTab));
return newTab.id;
},
requestCloseTab: (id) => {
const interceptor = closeInterceptors.get(id);
if (interceptor) {
interceptor();
return;
}
get().closeTab(id);
},
closeTab: (id) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === id);
if (!tab || !tab.closeable) return;
// Drop any registered close interceptor for this tab.
closeInterceptors.delete(id);
commit((s) => ({
...s,
tabs: s.tabs.filter((t) => t.id !== id),
// If the closed tab was active, hand its pane to a neighbour
// (looked up in the pre-close list, so it's the tab next door,
// not just the pane's first tab). normalize() covers the
// emptied-pane cases.
activeTabId: tab.paneId === 'main' && s.activeTabId === id
? (neighborInPane(s.tabs, id, 'main') ?? s.activeTabId)
: s.activeTabId,
activeSplitTabId: tab.paneId === 'split' && s.activeSplitTabId === id
? neighborInPane(s.tabs, id, 'split')
: s.activeSplitTabId,
}));
},
setActiveTab: (id) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === id);
if (!tab) return;
if (tab.paneId === 'main'
&& state.activeTabId === id && state.focusedPaneId === 'main') return;
if (tab.paneId === 'split'
&& state.activeSplitTabId === id && state.focusedPaneId === 'split') return;
commit((s) => withTabActivated(s, tab));
},
setFocusedPane: (paneId) => {
const state = core(get());
if (state.focusedPaneId === paneId) return;
commit((s) => ({ ...s, focusedPaneId: paneId }));
},
setSplitSide: (side) => {
if (get().splitSide === side) return;
commit((s) => ({ ...s, splitSide: side }));
},
reorderTab: (draggedId, targetTabId, edge) => {
const state = core(get());
if (draggedId === targetTabId) return;
const dragged = state.tabs.find((t) => t.id === draggedId);
const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return;
commit((s) => {
const rest = s.tabs.filter((t) => t.id !== draggedId);
const insertAt = rest.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const moved: ProTab = dragged.paneId === target.paneId
? dragged
: { ...dragged, paneId: target.paneId };
const tabs = [...rest];
tabs.splice(insertAt, 0, moved);
let next: ProTabCoreState = { ...s, tabs };
if (dragged.paneId !== target.paneId) {
// The old pane needs a new active tab if the dragged one was
// active there (its neighbour in the pre-move list); the new
// pane activates the dragged tab.
if (dragged.paneId === 'main' && s.activeTabId === draggedId) {
next = { ...next, activeTabId: neighborInPane(s.tabs, draggedId, 'main') ?? s.activeTabId };
}
if (dragged.paneId === 'split' && s.activeSplitTabId === draggedId) {
next = { ...next, activeSplitTabId: neighborInPane(s.tabs, draggedId, 'split') };
}
next = withTabActivated(next, moved);
}
return next;
});
},
moveTabToPane: (tabId, paneId, opts) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.paneId === paneId) return;
const openingSplit = paneId === 'split' && !hasSplitPane(state);
commit((s) => {
const tabs = s.tabs.map((t) => (t.id === tabId ? { ...t, paneId } : t));
const moved = { ...tab, paneId };
let next: ProTabCoreState = {
...s,
tabs,
splitSide: openingSplit ? (opts?.side ?? s.splitSide) : s.splitSide,
};
// Hand the vacated pane to a neighbour (from the pre-move list)
// before activating the moved tab in its new pane. If the
// vacated pane emptied, normalize() collapses/merges as needed.
if (tab.paneId === 'main' && s.activeTabId === tabId) {
next = { ...next, activeTabId: neighborInPane(s.tabs, tabId, 'main') ?? s.activeTabId };
}
if (tab.paneId === 'split' && s.activeSplitTabId === tabId) {
next = { ...next, activeSplitTabId: neighborInPane(s.tabs, tabId, 'split') };
}
return withTabActivated(next, moved);
});
},
collapseSplit: () => {
const state = core(get());
if (!hasSplitPane(state)) return;
commit((s) => ({
...s,
tabs: s.tabs.map((t) => (t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t)),
activeSplitTabId: null,
focusedPaneId: 'main',
}));
},
updateTabTitle: (id, title) => {
set({
tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, title } : tab)),
});
},
updateComposeDraft: (id, draft) => {
set({
tabs: get().tabs.map((tab) => {
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
return { ...tab, composeData: { ...tab.composeData, initialData: draft } };
}),
});
},
};
},
{
name: 'pro-tabs',
version: 3,
version: 4,
// Don't persist transient compose drafts in tab metadata - the composer's
// own draft-store already handles that. Persisted email tabs are fine to
// restore (the tab body refetches the email by id).
partialize: (state) => ({
tabs: state.tabs
.filter((tab) => tab.kind !== 'compose')
.map((tab) => tab.kind === 'compose'
? { ...tab, composeData: undefined }
: tab),
tabs: state.tabs.filter((tab) => tab.kind !== 'compose'),
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId,
splitOrientation: state.splitOrientation,
focusedPaneId: state.focusedPaneId,
splitOrientation: state.splitOrientation,
splitSide: state.splitSide,
readerTabId: state.readerTabId,
loadedTabIds: state.loadedTabIds,
}),
migrate: (persisted, version) => {
// v3 -> v4: `splitSide` and `readerTabId` are new; earlier versions
// carried an un-persisted "which side" that always reset to the
// right. Everything else is repaired by normalize() on rehydrate.
const state = persisted as Partial<ProTabCoreState>;
if (version < 4) {
return {
...state,
splitSide: 'right' as const,
readerTabId: null,
};
}
return state;
},
onRehydrateStorage: () => (state) => {
if (!state) return;
// Backfill paneId in case the user upgrades from version 2.
state.tabs = state.tabs.map((tab) => tab.paneId ? tab : { ...tab, paneId: 'main' as const });
if (state.tabs.length === 0) {
state.tabs = [HOME_TAB];
state.activeTabId = HOME_TAB.id;
state.activeSplitTabId = null;
state.splitOrientation = null;
state.focusedPaneId = 'main';
state.loadedTabIds = [HOME_TAB.id];
return;
}
// Heal broken state where every tab ended up on the split pane:
// collapse the split so the surviving tabs return to main, otherwise
// the layout would render an empty main pane next to the split.
if (!state.tabs.some((t) => t.paneId === 'main')) {
state.tabs = state.tabs.map((t) => ({ ...t, paneId: 'main' as const }));
state.activeSplitTabId = null;
state.splitOrientation = null;
state.focusedPaneId = 'main';
}
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
}
if (state.activeSplitTabId !== null && !state.tabs.some((t) => t.id === state.activeSplitTabId && t.paneId === 'split')) {
state.activeSplitTabId = state.tabs.find((t) => t.paneId === 'split')?.id ?? null;
}
if (state.activeSplitTabId === null) {
state.splitOrientation = null;
state.focusedPaneId = 'main';
}
if (!state.loadedTabIds.includes(state.activeTabId)) {
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
}
if (state.activeSplitTabId && !state.loadedTabIds.includes(state.activeSplitTabId)) {
state.loadedTabIds = [...state.loadedTabIds, state.activeSplitTabId];
}
const normalized = normalizeProTabState({
tabs: Array.isArray(state.tabs) ? state.tabs : [],
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId ?? null,
focusedPaneId: state.focusedPaneId ?? 'main',
splitOrientation: state.splitOrientation ?? null,
splitSide: state.splitSide ?? 'right',
readerTabId: state.readerTabId ?? null,
loadedTabIds: Array.isArray(state.loadedTabIds) ? state.loadedTabIds : [],
});
state.tabs = normalized.tabs;
state.activeTabId = normalized.activeTabId;
state.activeSplitTabId = normalized.activeSplitTabId;
state.focusedPaneId = normalized.focusedPaneId;
state.splitOrientation = normalized.splitOrientation;
state.splitSide = normalized.splitSide;
state.readerTabId = normalized.readerTabId;
state.loadedTabIds = normalized.loadedTabIds;
},
},
),