diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index d663b7d10..6df1960c8 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -13,7 +13,9 @@ 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 { PaneIdContext } from "@/hooks/use-pane-context"; +import { PaneIdContext, ProTabFocusContext } from "@/hooks/use-pane-context"; +import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; +import { appPath, buildMailPath } from "@/lib/deep-links"; import { ProTabBar } from "@/components/pro/pro-tab-bar"; import { PRO_TAB_DRAG_MIME, @@ -163,7 +165,11 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")} aria-hidden={!isActive} > - {renderTabBody(tab)} + {/* Exactly one tab across both panes is "focused" - it owns + shared single-instance concerns like the address bar. */} + + {renderTabBody(tab)} + ); })} @@ -262,6 +268,45 @@ export default function ProHome() { return tabs.find((t) => t.id === id) ?? null; }, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]); + // ---- Address bar: reflect the focused tab's permalink ---- + // Mail-family tabs are reflected here (MailApp's own URL machinery is + // suppressed when embedded); calendar/contacts/files/settings write their + // own, more precise paths via useDeepLinkUrl gated on ProTabFocusContext. + // replaceState only, and ProInterfaceRedirect doesn't observe it - a reload + // of the shown URL goes through the redirect's handoff and restores the view. + const selectedMailbox = useEmailStore((s) => s.selectedMailbox); + const selectedEmailId = useEmailStore((s) => s.selectedEmail?.id ?? null); + const mailboxes = useEmailStore((s) => s.mailboxes); + const accountMailboxes = useEmailStore((s) => s.accountMailboxes); + const reflectedPath = useMemo(() => { + if (!isAuthenticated || !client || !focusedActiveTab) return null; + switch (focusedActiveTab.kind) { + case 'mail': + return appPath(buildMailPath( + { mailboxId: selectedMailbox || null, emailId: selectedEmailId, threadId: null }, + mailboxes, + )); + case 'email': + return focusedActiveTab.emailData + ? appPath(buildMailPath({ mailboxId: null, emailId: focusedActiveTab.emailData.emailId, threadId: null })) + : null; + case 'folder': { + const data = focusedActiveTab.folderData; + if (!data) return null; + const list = data.accountId ? accountMailboxes[data.accountId] ?? [] : mailboxes; + const path = appPath(buildMailPath({ mailboxId: data.mailboxId, emailId: null, threadId: null }, list)); + return data.accountId ? `${path}?account=${encodeURIComponent(data.accountId)}` : path; + } + case 'compose': + // A draft has no permalink - fall back to the shell's own route so + // the bar never shows a message the user is no longer looking at. + return appPath('/pro'); + default: + return null; + } + }, [isAuthenticated, client, focusedActiveTab, selectedMailbox, selectedEmailId, mailboxes, accountMailboxes]); + useDeepLinkUrl(reflectedPath); + const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => { openTab(itemId); return true; diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b089b2004..88bbab1c7 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -48,6 +48,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useIsFocusedProTab } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; @@ -67,8 +68,8 @@ import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-ca import { debug } from "@/lib/debug"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; -import { appPath, buildCalendarPath, parseCalendarPath } from "@/lib/deep-links"; -import { consumePendingDeepLink } from "@/lib/deep-link-handoff"; +import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links"; +import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; @@ -591,22 +592,16 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { // `/calendar//` moves the grid; `/calendar/event/` opens the // event's sidebar and parks the grid on the day it starts. Applied once the // JMAP session is up, then the URL becomes an output of the view (below). - const deepLinkHandledRef = useRef(false); - useEffect(() => { - if (deepLinkHandledRef.current) return; - if (!isAuthenticated || !client) return; - - const segments = linkSegments ?? consumePendingDeepLink('calendar') ?? []; - const link = parseCalendarPath(segments, new URLSearchParams(window.location.search)); - deepLinkHandledRef.current = true; - if (!link) return; - + // Held in a ref so the live-delivery subscription (Pro shell) always calls + // the copy that closes over the current client and handlers. + const applyCalendarDeepLink = (link: CalendarDeepLink) => { if (link.kind === 'view') { setViewMode(link.view); if (link.date) setSelectedDate(link.date); return; } + if (!client) return; void (async () => { try { const event = await client.getCalendarEvent(link.id, link.accountId); @@ -622,21 +617,50 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { toast.error(tDeepLink('event_not_found')); } })(); - }, [isAuthenticated, client, linkSegments, setViewMode, setSelectedDate, openEditModal, tDeepLink]); + }; + const applyCalendarDeepLinkRef = useRef(applyCalendarDeepLink); + applyCalendarDeepLinkRef.current = applyCalendarDeepLink; - // The permalink for what the calendar is currently showing. Suppressed in - // the Pro shell, where /pro owns the address bar. + const deepLinkHandledRef = useRef(false); + useEffect(() => { + if (deepLinkHandledRef.current) return; + if (!isAuthenticated || !client) return; + + const segments = linkSegments ?? consumePendingDeepLink('calendar') ?? []; + const link = parseCalendarPath(segments, new URLSearchParams(window.location.search)); + deepLinkHandledRef.current = true; + if (!link) return; + applyCalendarDeepLinkRef.current(link); + }, [isAuthenticated, client, linkSegments]); + + // Pro shell only: this surface stays mounted for the whole session, so links + // arriving after mount are delivered live instead of being parked forever. + // Embedded-only: during a cold-load redirect the standard instance renders + // briefly and must not steal the link parked for the Pro one. + useEffect(() => { + if (!isEmbedded) return; + return subscribePendingDeepLink('calendar', (segments) => { + const link = parseCalendarPath(segments, new URLSearchParams(window.location.search)); + if (link) applyCalendarDeepLinkRef.current(link); + }); + }, [isEmbedded]); + + // The permalink for what the calendar is currently showing. In the Pro + // shell only the focused tab writes the address bar; the standard instance + // that renders while Pro takes over a route stays silent. const proInterfaceActive = useProInterfaceActive(); + const isFocusedProTab = useIsFocusedProTab(); const eventLinkId = showEventModal && editEvent ? editEvent.id : null; + const calendarLinkPath = appPath(buildCalendarPath({ + view: normalizedViewMode, + date: selectedDate, + eventId: eventLinkId, + accountId: eventLinkId ? editEvent?.accountId : null, + })); useDeepLinkUrl( - isEmbedded || proInterfaceActive - ? null - : appPath(buildCalendarPath({ - view: normalizedViewMode, - date: selectedDate, - eventId: eventLinkId, - accountId: eventLinkId ? editEvent?.accountId : null, - })), + isEmbedded + ? (isFocusedProTab ? calendarLinkPath : null) + : proInterfaceActive ? null : calendarLinkPath, ); const handleEditFromDetail = useCallback(() => { diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 5699aa47d..a9def7097 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -31,14 +31,15 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useIsFocusedProTab } from "@/hooks/use-pane-context"; import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; -import { appPath, buildContactsPath, parseContactsPath } from "@/lib/deep-links"; -import { consumePendingDeepLink } from "@/lib/deep-link-handoff"; +import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links"; +import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; @@ -181,15 +182,9 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) { // plus the older one-shot query form the mobile recipient popover emits // (`?contactId=`, `?addEmail=`). `from=email` flips the mobile back button to // `router.back()`. After this the URL is an output of the view, not an input. - useEffect(() => { - if (intentAppliedRef.current) return; - const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? []; - const link = parseContactsPath( - segments, - new URLSearchParams(searchParams.toString()), - ); - intentAppliedRef.current = true; - if (!link) return; + // The applier is held in a ref so the live-delivery subscription below (Pro + // shell) always calls the copy closing over the current setters. + const applyContactsDeepLink = (link: ContactDeepLink) => { if (link.fromEmail) setReturnToEmail(true); if (link.kind === 'contact') { setSelectedContact(link.id); @@ -199,18 +194,47 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) { setSelectedContact(null); setView('create'); } + }; + const applyContactsDeepLinkRef = useRef(applyContactsDeepLink); + applyContactsDeepLinkRef.current = applyContactsDeepLink; + + useEffect(() => { + if (intentAppliedRef.current) return; + const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? []; + const link = parseContactsPath( + segments, + new URLSearchParams(searchParams.toString()), + ); + intentAppliedRef.current = true; + if (!link) return; + applyContactsDeepLinkRef.current(link); }, [searchParams, linkSegments, setSelectedContact]); - // The permalink for the contact on screen. Suppressed inside the Pro shell, - // where /pro owns the address bar. + // Pro shell only: this surface stays mounted for the whole session, so links + // arriving after mount are delivered live instead of being parked forever. + // Embedded-only: during a cold-load redirect the standard instance renders + // briefly and must not steal the link parked for the Pro one. + useEffect(() => { + if (!isEmbedded) return; + return subscribePendingDeepLink('contacts', (segments) => { + const link = parseContactsPath(segments, new URLSearchParams(window.location.search)); + if (link) applyContactsDeepLinkRef.current(link); + }); + }, [isEmbedded]); + + // The permalink for the contact on screen. In the Pro shell only the + // focused tab writes the address bar; the standard instance that renders + // while Pro takes over a route stays silent. const proInterfaceActive = useProInterfaceActive(); + const isFocusedProTab = useIsFocusedProTab(); + const contactsLinkPath = appPath(buildContactsPath({ + contactId: view === 'detail' || view === 'edit' ? selectedContactId : null, + editing: view === 'edit', + })); useDeepLinkUrl( - isEmbedded || proInterfaceActive - ? null - : appPath(buildContactsPath({ - contactId: view === 'detail' || view === 'edit' ? selectedContactId : null, - editing: view === 'edit', - })), + isEmbedded + ? (isFocusedProTab ? contactsLinkPath : null) + : proInterfaceActive ? null : contactsLinkPath, ); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 49ed5167a..979127dc9 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -18,6 +18,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useIsFocusedProTab } from "@/hooks/use-pane-context"; import { useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { usePolicyStore } from "@/stores/policy-store"; @@ -31,7 +32,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { AlertTriangle, Loader2 } from "lucide-react"; import { isFilePreviewable } from "@/lib/file-preview"; import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links"; -import { consumePendingDeepLink } from "@/lib/deep-link-handoff"; +import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; @@ -212,6 +213,24 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) { return link; }, []); + // Pro shell only: this surface stays mounted for the whole session, so links + // arriving after mount are delivered live instead of being parked forever. + // The bootstrap has already run by then - walk the drive directly and let + // the preview effect below claim `pendingPreviewRef` once the listing lands. + // Embedded-only: during a cold-load redirect the standard instance renders + // briefly and must not steal the link parked for the Pro one. + const navigateByPathRef = useRef(navigateByPath); + navigateByPathRef.current = navigateByPath; + useEffect(() => { + if (!isEmbedded) return; + return subscribePendingDeepLink('files', (segments) => { + const link = parseFilesPath(segments, new URLSearchParams(window.location.search)); + if (!link) return; + if (link.preview) pendingPreviewRef.current = link.preview; + void navigateByPathRef.current(link.path); + }); + }, [isEmbedded]); + // Check support and load the first listing after the client is initialized. // One effect, run once: `checkSupport` publishes `supportsFiles` before it // resolves, so splitting the cold path from the warm one would have them @@ -480,13 +499,16 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) { setShowDetails(true); }, []); - // The permalink for the folder on screen. Suppressed inside the Pro shell, - // where /pro owns the address bar, and at the account picker (no path yet). + // The permalink for the folder on screen. In the Pro shell only the focused + // tab writes the address bar; the standard instance that renders while Pro + // takes over a route stays silent. const proInterfaceActive = useProInterfaceActive(); + const isFocusedProTab = useIsFocusedProTab(); + const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile)); useDeepLinkUrl( - isEmbedded || proInterfaceActive - ? null - : appPath(buildFilesPath(currentPath, previewFile)), + isEmbedded + ? (isFocusedProTab ? filesLinkPath : null) + : proInterfaceActive ? null : filesLinkPath, ); const handleToggleDetails = useCallback(() => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 250f19e15..882fe3586 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -105,7 +105,7 @@ import { resolveFolderRef, type MailDeepLink, } from "@/lib/deep-links"; -import { consumePendingDeepLink } from "@/lib/deep-link-handoff"; +import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import type { QuoteHeader } from "@/lib/plugin-types"; @@ -1277,6 +1277,17 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { } if (link.kind === 'message') { + // In the Pro shell a message permalink opens as its own fullscreen + // email tab - the tab body fetches the message and retitles itself. + if (isEmbedded) { + useProTabStore.getState().openEmailTab({ + accountId: '', + emailId: link.id, + mailboxId: null, + title: t('common.loading'), + }); + return; + } setLoadingEmail(true); try { const email = await fetchEmailContent(activeClient, link.id); @@ -1337,6 +1348,19 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { const applyMailDeepLinkRef = useRef(applyMailDeepLink); applyMailDeepLinkRef.current = applyMailDeepLink; + // Pro shell only: this MailApp stays mounted for the whole session, so the + // mount-time consume below never sees a link that arrives later (an in-app + // navigation ProInterfaceRedirect intercepts). Subscribe for live delivery. + // Embedded-only: during a cold-load redirect the standard instance renders + // briefly and must not steal the link parked for the Pro one. + useEffect(() => { + if (!isEmbedded) return; + return subscribePendingDeepLink('mail', (segments) => { + const link = parseMailPath(segments, new URLSearchParams(window.location.search)); + if (link) void applyMailDeepLinkRef.current(link); + }); + }, [isEmbedded]); + useEffect(() => { if (deepLinkHandledRef.current) return; if (!isAuthenticated || !client) return; diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index 5152656e4..faf802474 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -48,10 +48,13 @@ export function ProInterfaceRedirect() { if (!proInterface || !isDesktop) return; const match = matchSurface(pathname); if (!match) return; + // Focus the tab before handing over the link: when the surface is already + // mounted the handoff delivers live, and its handler may itself open a + // tab (a message permalink opens an email tab) that must keep focus. + useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]); if (match.segments.length > 0) { setPendingDeepLink(match.surface, match.segments); } - useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]); router.replace('/pro'); }, [proInterface, isDesktop, pathname, router]); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 2194a3776..f33b28ca7 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -70,7 +70,7 @@ import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links'; -import { consumePendingDeepLink } from '@/lib/deep-link-handoff'; +import { consumePendingDeepLink, subscribePendingDeepLink } from '@/lib/deep-link-handoff'; import { useDeepLinkUrl } from '@/hooks/use-deep-link-url'; import { useProInterfaceActive } from '@/components/pro/pro-interface-redirect'; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; @@ -85,6 +85,7 @@ import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; +import { useIsFocusedProTab } from '@/hooks/use-pane-context'; import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; import { usePolicyStore } from '@/stores/policy-store'; @@ -434,6 +435,16 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) { const tab = tabFromDeepLink(consumePendingDeepLink('settings')); if (tab) setActiveTab(tab); }, [linkSegments]); + // Pro shell only: this surface stays mounted for the whole session, so links + // arriving later (e.g. the sidebar's folder-settings gear) are delivered + // live instead of being parked for a mount that already happened. + useEffect(() => { + if (!isEmbedded) return; + return subscribePendingDeepLink('settings', (segments) => { + const tab = tabFromDeepLink(segments); + if (tab) setActiveTab(tab); + }); + }, [isEmbedded]); const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null); @@ -652,10 +663,14 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) { // so the tab is only in the URL once the user is looking at its content. // Must sit above the early return below - it is a hook. const proInterfaceActive = useProInterfaceActive(); + const isFocusedProTab = useIsFocusedProTab(); + const settingsLinkPath = appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)); useDeepLinkUrl( - !isAuthenticated || isEmbedded || proInterfaceActive + !isAuthenticated ? null - : appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)), + : isEmbedded + ? (isFocusedProTab ? settingsLinkPath : null) + : proInterfaceActive ? null : settingsLinkPath, ); // Park the content-pane scroll offset when the Pro toggle flips (the flip diff --git a/hooks/use-deep-link-url.ts b/hooks/use-deep-link-url.ts index a5c829149..7cc6a84b3 100644 --- a/hooks/use-deep-link-url.ts +++ b/hooks/use-deep-link-url.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; /** * Keeps the address bar in step with what a surface is showing (#733), so the @@ -11,20 +11,19 @@ import { useEffect, useRef } from "react"; * than not pushing at all - the URL would change while the view sat still. Mail * is the exception and drives its own history through `useBrowserNavigation`. * - * Pass `null` to leave the URL alone (inside the Pro shell, where the route is - * /pro and the tab strip owns the address bar). + * Pass `null` to leave the URL alone (an unfocused Pro tab, whose focused + * sibling owns the address bar). Dedupe is against the *actual* location, not + * a remembered last write: in the Pro shell several surfaces take turns + * writing the URL, so what this hook wrote last says nothing about what the + * bar currently shows. * * The existing history state is preserved so Next's router bookkeeping - which * lives in `window.history.state` - survives the rewrite. */ export function useDeepLinkUrl(path: string | null): void { - const lastRef = useRef(null); - useEffect(() => { if (typeof window === "undefined") return; if (!path) return; - if (lastRef.current === path) return; - lastRef.current = path; if (`${window.location.pathname}${window.location.search}` === path) return; window.history.replaceState(window.history.state, "", path); }, [path]); diff --git a/hooks/use-pane-context.ts b/hooks/use-pane-context.ts index d1838f4ec..cda7550b8 100644 --- a/hooks/use-pane-context.ts +++ b/hooks/use-pane-context.ts @@ -16,6 +16,19 @@ export function usePaneId(): ProPaneId | null { return useContext(PaneIdContext); } +/** + * True when the current subtree is the *focused* Pro tab: the active tab of + * the focused pane. Exactly one tab body sees `true` at any time, which is + * what lets that tab own shared, single-instance concerns - currently the + * address bar, where the focused tab's permalink is reflected. Always false + * outside the Pro shell. + */ +export const ProTabFocusContext = createContext(false); + +export function useIsFocusedProTab(): boolean { + return useContext(ProTabFocusContext); +} + /** * True when the current subtree renders inside a Pro pane. * diff --git a/lib/__tests__/deep-link-handoff.test.ts b/lib/__tests__/deep-link-handoff.test.ts new file mode 100644 index 000000000..836c47847 --- /dev/null +++ b/lib/__tests__/deep-link-handoff.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + setPendingDeepLink, + consumePendingDeepLink, + subscribePendingDeepLink, + clearPendingDeepLinks, +} from '@/lib/deep-link-handoff'; + +describe('deep-link handoff', () => { + beforeEach(() => { + clearPendingDeepLinks(); + }); + + it('parks segments and yields them exactly once', () => { + setPendingDeepLink('mail', ['message', 'abc']); + expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']); + expect(consumePendingDeepLink('mail')).toBeNull(); + }); + + it('keeps surfaces separate', () => { + setPendingDeepLink('mail', ['message', 'abc']); + setPendingDeepLink('settings', ['folders']); + expect(consumePendingDeepLink('settings')).toEqual(['folders']); + expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']); + }); + + it('delivers live to a subscribed surface instead of parking', () => { + const listener = vi.fn(); + const unsubscribe = subscribePendingDeepLink('mail', listener); + + setPendingDeepLink('mail', ['message', 'abc']); + + expect(listener).toHaveBeenCalledWith(['message', 'abc']); + // Delivered, not parked: a later mount finds nothing. + expect(consumePendingDeepLink('mail')).toBeNull(); + unsubscribe(); + }); + + it('a listener only receives its own surface', () => { + const listener = vi.fn(); + const unsubscribe = subscribePendingDeepLink('mail', listener); + + setPendingDeepLink('settings', ['folders']); + + expect(listener).not.toHaveBeenCalled(); + expect(consumePendingDeepLink('settings')).toEqual(['folders']); + unsubscribe(); + }); + + it('unsubscribing restores parking', () => { + const listener = vi.fn(); + const unsubscribe = subscribePendingDeepLink('mail', listener); + unsubscribe(); + + setPendingDeepLink('mail', ['folder', 'inbox']); + + expect(listener).not.toHaveBeenCalled(); + expect(consumePendingDeepLink('mail')).toEqual(['folder', 'inbox']); + }); +}); diff --git a/lib/deep-link-handoff.ts b/lib/deep-link-handoff.ts index e1d1b3e19..630631645 100644 --- a/lib/deep-link-handoff.ts +++ b/lib/deep-link-handoff.ts @@ -1,7 +1,7 @@ import type { AppSurface } from '@/lib/deep-links'; /** - * One-shot handoff for a deep link that has to survive a route change (#733). + * Handoff for a deep link that has to survive a route change (#733). * * The Pro shell owns a single route (`/pro`) and renders each surface inside a * tab, so a permalink like `/mail/message/` can't reach the surface as a @@ -9,12 +9,30 @@ import type { AppSurface } from '@/lib/deep-links'; * parks the segments here, opens the right tab, and the surface picks them up * on mount - exactly like `protocol-handlers/session` does for `mailto:`. * + * Surfaces in the Pro shell stay mounted for the whole session, so a link that + * arrives *while the shell is already running* (an in-app link to + * `/settings/folders`, a `mailto:`-style handoff, ...) would be parked and + * never seen - the mount-time consume has long since run. For that case a + * mounted surface subscribes here: when a listener exists, `setPendingDeepLink` + * delivers the segments to it directly instead of parking them. + * * Module state, deliberately: it must not survive a reload (the URL is gone by * then, so re-applying it would reopen something the user already closed). */ const pending = new Map(); +type PendingDeepLinkListener = (segments: string[]) => void; + +const listeners = new Map>(); + export function setPendingDeepLink(surface: AppSurface, segments: string[]): void { + const live = listeners.get(surface); + if (live && live.size > 0) { + // A mounted surface is listening - deliver instead of parking, so links + // arriving while the Pro shell is already running still land. + for (const listener of [...live]) listener(segments); + return; + } pending.set(surface, segments); } @@ -26,6 +44,28 @@ export function consumePendingDeepLink(surface: AppSurface): string[] | null { return segments; } +/** + * Live delivery for an already-mounted surface (the Pro shell keeps surfaces + * mounted for the whole session). Returns the unsubscribe function. Only the + * embedded (Pro) instance of a surface should subscribe - during a cold-load + * redirect the standard instance briefly renders too and must not steal the + * link parked for the Pro instance that mounts right after. + */ +export function subscribePendingDeepLink( + surface: AppSurface, + listener: PendingDeepLinkListener, +): () => void { + let set = listeners.get(surface); + if (!set) { + set = new Set(); + listeners.set(surface, set); + } + set.add(listener); + return () => { + set.delete(listener); + }; +} + export function clearPendingDeepLinks(): void { pending.clear(); }