mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: rework pro split-screen shell, per-pane tab strips, drag & drop, pane-scoped overlays
This commit is contained in:
+179
-120
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user