import { Component, createContext, lazy, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { documentPath, researchWorkspacePath } from "@chopin/protocol/document-url"; import * as Api from "./api"; import { forgetChannel } from "./channel-recovery"; import { newestDocument, updateDocumentMetadata } from "./document-actions"; import type { DocumentAction } from "./document-actions-menu"; import { motionContract } from "./motion-contract"; import { NavigationFocusScope } from "./navigation-focus"; import { motionImmediately } from "./motion-input"; import { activeProject, beginProjectCreation, canManageProject, documentDestination, finishProjectCreation, landingDocument, NAVIGATION_MEDIA, navigationMode, } from "./navigation-model"; import { ProjectSidebarExpandButton, SIDEBAR_MAX, SIDEBAR_MIN, SIDEBAR_STORAGE_KEY, } from "./project-sidebar-chrome"; import { clearRepositoryCache } from "./repository-cache"; import { useProjectDocuments } from "./use-project-documents"; import { useProjectResearch } from "./use-project-research"; import type { Research } from "@chopin/protocol"; import type { TransitionPresence } from "@chopin/editor/transition-presence"; import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from "react"; import type { DocumentMetadata } from "./document-actions"; import type { NavigationMode } from "./navigation-model"; export type Navigate = (destination: string, options?: { replace?: boolean }) => void; class LazyDialogBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { override state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } override render() { if (!this.state.failed) return this.props.children; return (
Could not load this dialog.
); } } let NewResearchDialog = lazy(() => import("./research-actions").then(module => ({ default: module.NewResearchDialog })) ); let ProjectSidebar = lazy(() => import("./project-sidebar").then(module => ({ default: module.ProjectSidebar })) ); let AddProjectDialog = lazy(() => import("./add-project-dialog").then(module => ({ default: module.AddProjectDialog })) ); let DocumentSearchDialog = lazy(() => import("./document-search-dialog").then(module => ({ default: module.DocumentSearchDialog })) ); let RenameDocumentDialog = lazy(() => import("./rename-document-dialog").then(module => ({ default: module.RenameDocumentDialog })) ); let DeleteDocumentDialog = lazy(() => import("./delete-document-dialog").then(module => ({ default: module.DeleteDocumentDialog })) ); type Route = | { page: "repositories" } | { page: "repository"; owner: string; repository: string } | { page: "document"; owner: string; repository: string; slug: string } | { page: "research"; owner: string; repository: string; slug: string; workspaceId: string; } | { page: "channel"; id: string }; type NavigationFailure = { reason: unknown; retry?: "refresh" | "visit" }; let NavigationDocument = createContext<{ channel?: Api.Channel; onDocumentChanged: ( documentId: string, update: DocumentMetadata, ) => void; onDocumentAction: (action: DocumentAction) => void; onDocumentDeleted: (documentId: string) => void; onDocumentLoaded: (channel: Api.Channel, routeKey: string) => Promise; onRepositoryAccessChanged: () => void; onResearchWorkspaceChanged: ( channel: Api.ResearchParentChannel, workspaceId: string, revision: number, ) => void; onResearchWorkspaceLoaded: ( channel: Api.ResearchParentChannel, workspace: Research.WorkspaceSummary, ) => void; onResearchWorkspacesRefresh: (channel: Api.ResearchParentChannel) => void; }>({ onDocumentChanged() {}, onDocumentAction() {}, onDocumentDeleted() {}, async onDocumentLoaded() {}, onRepositoryAccessChanged() {}, onResearchWorkspaceChanged() {}, onResearchWorkspaceLoaded() {}, onResearchWorkspacesRefresh() {}, }); export function useNavigationDocument() { return useContext(NavigationDocument); } function clamp(width: number): number { return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, width)); } function currentMode(): NavigationMode { return navigationMode(matchMedia); } function subscribeMode(notify: () => void): () => void { let query = matchMedia(NAVIGATION_MEDIA); query.addEventListener("change", notify); return () => query.removeEventListener("change", notify); } function useNavigationMode(): NavigationMode { return useSyncExternalStore(subscribeMode, currentMode, () => "inline"); } function useSidebarWidth() { let [width, setWidth] = useState(() => { let stored = Number(localStorage.getItem(SIDEBAR_STORAGE_KEY)); return Number.isFinite(stored) ? clamp(stored) : SIDEBAR_MIN; }); useEffect(() => localStorage.setItem(SIDEBAR_STORAGE_KEY, String(width)), [width]); return [width, (delta: number) => setWidth(current => clamp(current + delta))] as const; } function SidebarResizeHandle( { onResize, width }: { onResize: (delta: number) => void; width: number }, ) { let origin = useRef(0); return (
{ let step = event.shiftKey ? 64 : 16; if (event.key === "ArrowRight") onResize(step); else if (event.key === "ArrowLeft") onResize(-step); else if (event.key === "Home") onResize(SIDEBAR_MIN - width); else if (event.key === "End") onResize(SIDEBAR_MAX - width); else return; event.preventDefault(); }} onPointerDown={event => { origin.current = event.clientX; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={event => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; let delta = event.clientX - origin.current; origin.current = event.clientX; onResize(delta); }} role="separator" tabIndex={0} /> ); } function NavigationDrawer( { children, motion, onDismiss, }: { children: ReactNode; motion: Exclude, { phase: "closed" }>; onDismiss: () => void; }, ) { let active = motion.phase !== "closing"; return (
); } export function NavigationShell( { children, navigate, route, user, }: { children?: ReactNode; navigate: Navigate; route: Route; user: Api.User; }, ) { let [navigation, setNavigation] = useState(); let navigationRef = useRef(undefined); let navigationRequest = useRef | undefined>(undefined); let navigationRefreshQueued = useRef(false); let pendingVisitedRepository = useRef(undefined); let visitTail = useRef>(Promise.resolve()); let visitRevision = useRef(0); let latestVisitedDocument = useRef(undefined); let catalogueRefreshes = useRef(new Map([["navigation", Date.now()]])); let [error, setError] = useState(); let [resolvedDocument, setResolvedDocument] = useState<{ channel: Api.Channel; routeKey: string; }>(); let resolvedDocumentRef = useRef(resolvedDocument); resolvedDocumentRef.current = resolvedDocument; let [collapsed, setCollapsed] = useState(() => localStorage.getItem(`${SIDEBAR_STORAGE_KEY}:collapsed`) === "true" ); let [drawerOpen, setDrawerOpen] = useState(false); let drawerOpener = useRef(null); let [catalogueMode, setCatalogueMode] = useState<"active" | "archived">("active"); let [dialog, setDialog] = useState< | "add" | "search" | { channel: Api.Channel; type: "delete" | "rename" | "research" } >(); let [accountOpen, setAccountOpen] = useState(false); let creatingProjectIds = useRef>(new Set()); let [creatingProjectIdsForView, setCreatingProjectIdsForView] = useState>( () => new Set(), ); let [focusProjectId, setFocusProjectId] = useState(); let [width, resize] = useSidebarWidth(); let mode = useNavigationMode(); let immediateMotion = motionImmediately(); let previousMode = useRef(mode); let modeChanged = previousMode.current !== mode; useLayoutEffect(() => { previousMode.current = mode; }, [mode]); let sidebarMotion = motionContract("sidebar"); let sidebarVisible = mode === "inline" && !collapsed; let sidebarPresence = useTransitionPresence( sidebarVisible ? true : undefined, sidebarMotion.closeDuration, immediateMotion || modeChanged, ); let drawerPresence = useTransitionPresence( mode === "drawer" && drawerOpen ? true : undefined, 180, immediateMotion, ); let dialogPresence = useTransitionPresence(dialog, 150, immediateMotion); let accountPresence = useTransitionPresence( accountOpen ? true : undefined, 150, immediateMotion, ); let dialogMotion = dialogPresence.phase === "closed" ? undefined : dialogPresence; let presentedDialog = dialogMotion?.value; let triggerVisible = !sidebarVisible && !drawerOpen; let { loadMore, projects, refreshProject, removeDocument, updateDocument, upsertDocument, } = useProjectDocuments(navigation, catalogueMode === "archived"); let { groups: research, refreshResearchChannel, refreshResearchWorkspace, removeResearchChannel, upsertResearchWorkspace, } = useProjectResearch(navigation, projects, catalogueMode === "archived"); let routeKey = route.page === "channel" ? `${route.page}:${route.id}` : route.page === "research" ? `${route.page}:${route.owner}/${route.repository}/${route.slug}/${route.workspaceId}` : route.page === "document" ? `${route.page}:${route.owner}/${route.repository}/${route.slug}` : route.page; let currentRouteKey = useRef(routeKey); currentRouteKey.current = routeKey; let resolvedChannel = resolvedDocument?.routeKey === routeKey ? resolvedDocument.channel : undefined; let routedChannel = route.page === "document" || route.page === "research" ? projects.flatMap(project => project.documents.channels).find(channel => channel.repositoryOwner.toLocaleLowerCase() === route.owner.toLocaleLowerCase() && channel.repositoryName.toLocaleLowerCase() === route.repository.toLocaleLowerCase() && channel.slug === route.slug ) : undefined; let currentDocumentId = route.page === "channel" ? route.id : routedChannel?.id ?? resolvedChannel?.id; let currentResearchWorkspaceId = route.page === "research" ? route.workspaceId : undefined; let refresh = useCallback((queue = true): Promise => { let active = navigationRequest.current; if (active) { if (queue) navigationRefreshQueued.current = true; return active; } let request = (async () => { do { navigationRefreshQueued.current = false; setError(current => current?.retry === "visit" ? current : undefined); try { let visitsBeforeRequest = visitRevision.current; let next = await Api.navigation(); let firstNavigation = navigationRef.current === undefined; if (visitRevision.current !== visitsBeforeRequest && latestVisitedDocument.current) { next = { ...next, lastDocumentId: latestVisitedDocument.current }; } let refreshedAt = Date.now(); catalogueRefreshes.current.set("navigation", refreshedAt); if (firstNavigation) { for (let project of next.projects) { catalogueRefreshes.current.set(project.repositoryId, refreshedAt); } } navigationRef.current = next; setNavigation(next); let visited = pendingVisitedRepository.current; if (visited) { pendingVisitedRepository.current = undefined; if (!next.projects.some(project => project.repositoryId === visited)) { navigationRefreshQueued.current = true; } } } catch (reason) { setError({ reason, retry: "refresh" }); } } while (navigationRefreshQueued.current); })(); navigationRequest.current = request; void request.finally(() => { if (navigationRequest.current === request) navigationRequest.current = undefined; }); return request; }, []); let clearLastDocument = useCallback((documentId: string) => { if (latestVisitedDocument.current === documentId) latestVisitedDocument.current = undefined; let current = navigationRef.current; if (current?.lastDocumentId !== documentId) return; let next = { ...current }; delete next.lastDocumentId; visitRevision.current++; navigationRef.current = next; setNavigation(next); }, []); let documentLoaded = useCallback(async (channel: Api.Channel, loadedRouteKey: string) => { let resolved = resolvedDocumentRef.current; let loaded = { channel: resolved?.routeKey === loadedRouteKey && resolved.channel.id === channel.id ? newestDocument(resolved.channel, channel) : channel, routeKey: loadedRouteKey, }; resolvedDocumentRef.current = loaded; setResolvedDocument(loaded); upsertDocument(loaded.channel); if (loaded.channel.archivedAt) { clearLastDocument(channel.id); return; } let visited = visitTail.current.then(() => Api.visitDocument(channel.id)); visitTail.current = visited.catch(() => {}); try { await visited; } catch (reason) { if (currentRouteKey.current === loadedRouteKey) setError({ reason, retry: "visit" }); return; } setError(current => current?.retry === "visit" ? undefined : current); if ( resolvedDocumentRef.current?.channel.id === channel.id && resolvedDocumentRef.current.channel.archivedAt ) return; visitRevision.current++; latestVisitedDocument.current = channel.id; let current = navigationRef.current; if (!current) { pendingVisitedRepository.current = channel.repositoryId; return; } let next = { ...current, lastDocumentId: channel.id }; navigationRef.current = next; setNavigation(next); if (!current.projects.some(project => project.repositoryId === channel.repositoryId)) { pendingVisitedRepository.current = channel.repositoryId; await refresh(); } }, [clearLastDocument, refresh, upsertDocument]); let documentChanged = useCallback(( documentId: string, update: DocumentMetadata, ) => { let current = resolvedDocumentRef.current; let accepted: Api.Channel | undefined; if (current?.channel.id === documentId) { accepted = updateDocumentMetadata(current.channel, update); let next = accepted === current.channel ? current : { ...current, channel: accepted }; resolvedDocumentRef.current = next; setResolvedDocument(next); upsertDocument(next.channel); } else updateDocument(documentId, update); if (Object.hasOwn(update, "archivedAt")) { let archived = accepted ? accepted.archivedAt : update.archivedAt; if (archived) { clearLastDocument(documentId); setDialog(current => typeof current === "object" && current.type === "research" && current.channel.id === documentId ? undefined : current ); } } }, [clearLastDocument, updateDocument, upsertDocument]); useEffect(() => { void refresh(false); }, [refresh]); useEffect(() => { if (route.page !== "repositories" || !navigation) return; let destination = landingDocument(projects, navigation.lastDocumentId); if (destination) navigate(documentDestination(projects, destination), { replace: true }); }, [navigate, navigation, projects, route.page]); useEffect(() => { localStorage.setItem(`${SIDEBAR_STORAGE_KEY}:collapsed`, String(collapsed)); }, [collapsed]); useEffect(() => { if (mode === "inline") setDrawerOpen(false); }, [mode]); useEffect(() => { if ( route.page !== "channel" && route.page !== "document" && route.page !== "research" && navigation?.projects.length === 0 ) { showDialog("add"); } }, [navigation?.projects.length, route.page]); useEffect(() => { if (!focusProjectId) return; let frame = requestAnimationFrame(() => { let project = document.querySelector( `[data-project-id="${CSS.escape(focusProjectId)}"]`, ); if (!project) return; project.focus({ preventScroll: true }); setFocusProjectId(undefined); }); return () => cancelAnimationFrame(frame); }, [focusProjectId, projects]); let startProjectCreation = (projectId: string): boolean => { if (creatingProjectIds.current.has(projectId)) return false; let next = beginProjectCreation(creatingProjectIds.current, projectId); creatingProjectIds.current = next; setCreatingProjectIdsForView(next); return true; }; let completeProjectCreation = (projectId: string) => { let next = finishProjectCreation(creatingProjectIds.current, projectId); creatingProjectIds.current = next; setCreatingProjectIdsForView(next); }; let navigateToDocument = (documentId: string, path?: string) => { setError(undefined); setDialog(undefined); setDrawerOpen(false); navigate(documentDestination(projects, documentId, path)); }; let createDocument = async (project: Api.NavigationProject) => { if (!canManageProject(project) || !startProjectCreation(project.repositoryId)) return; try { let created = await Api.createChannel(project.repositoryOwner, project.repositoryName); upsertDocument(created.channel); navigateToDocument( created.channel.id, documentPath( created.repository.owner, created.repository.name, created.channel.slug, ), ); } catch (reason) { setError({ reason, retry: "refresh" }); } finally { completeProjectCreation(project.repositoryId); } }; let active = activeProject(projects, currentDocumentId, resolvedChannel?.repositoryId); let currentChannel = projects.flatMap(project => project.documents.channels) .find(channel => channel.id === currentDocumentId) ?? resolvedChannel; let currentChannelRef = useRef(undefined); currentChannelRef.current = currentChannel; let knownChannelsRef = useRef(new Map()); knownChannelsRef.current = new Map( [ ...projects.flatMap(project => project.documents.channels), ...(resolvedChannel ? [resolvedChannel] : []), ].map(channel => [channel.id, channel]), ); let currentDocumentIdRef = useRef(currentDocumentId); currentDocumentIdRef.current = currentDocumentId; let revalidateCatalogues = useCallback(() => { let now = Date.now(); let navigationRefreshedAt = catalogueRefreshes.current.get("navigation") ?? 0; if (now - navigationRefreshedAt >= 30_000) { catalogueRefreshes.current.set("navigation", now); void refresh(); } if (!active?.available) return; let projectRefreshedAt = catalogueRefreshes.current.get(active.repositoryId) ?? navigationRefreshedAt; if (now - projectRefreshedAt < 30_000) { catalogueRefreshes.current.set(active.repositoryId, projectRefreshedAt); return; } catalogueRefreshes.current.set(active.repositoryId, now); refreshProject(active); if (currentChannel) refreshResearchChannel(currentChannel); }, [active, currentChannel, refresh, refreshProject, refreshResearchChannel]); useEffect(() => { revalidateCatalogues(); }, [revalidateCatalogues, routeKey]); useEffect(() => { let visible = () => { if (document.visibilityState === "visible") revalidateCatalogues(); }; window.addEventListener("focus", revalidateCatalogues); document.addEventListener("visibilitychange", visible); return () => { window.removeEventListener("focus", revalidateCatalogues); document.removeEventListener("visibilitychange", visible); }; }, [revalidateCatalogues]); let newDocument = () => { if (active && canManageProject(active)) void createDocument(active); else showDialog("add"); }; let showDialog = useCallback((next: NonNullable) => { setDrawerOpen(false); setAccountOpen(false); setDialog(next); }, []); let acceptChannel = useCallback((channel: Api.Channel) => { if (channel.archivedAt) { clearLastDocument(channel.id); } upsertDocument(channel); let current = resolvedDocumentRef.current; if (current?.channel.id !== channel.id) return; let next = { ...current, channel: newestDocument(current.channel, channel) }; resolvedDocumentRef.current = next; setResolvedDocument(next); }, [clearLastDocument, upsertDocument]); let documentDeleted = useCallback((documentId: string) => { let channel = knownChannelsRef.current.get(documentId); if (channel) forgetChannel(user.id, channel); removeDocument(documentId); removeResearchChannel(documentId); clearLastDocument(documentId); let current = resolvedDocumentRef.current; if (current?.channel.id === documentId) { resolvedDocumentRef.current = undefined; setResolvedDocument(undefined); } setDialog(currentDialog => typeof currentDialog === "object" && currentDialog.channel.id === documentId ? undefined : currentDialog ); if (currentDocumentIdRef.current === documentId) navigate("/", { replace: true }); }, [clearLastDocument, navigate, removeDocument, removeResearchChannel, user.id]); let documentAction = useCallback((channel: Api.Channel, action: DocumentAction) => { setDrawerOpen(false); setAccountOpen(false); if (action === "rename") { showDialog({ type: "rename", channel }); return; } if (action === "delete") { showDialog({ type: "delete", channel }); return; } setError(undefined); let mutation = action === "archive" ? Api.archiveChannel(channel.id) : Api.restoreChannel(channel.id); void mutation.then(detail => { acceptChannel(detail.channel); if (action === "restore") setCatalogueMode("active"); }, reason => { setError({ reason }); }); }, [acceptChannel, showDialog]); let currentDocumentAction = useCallback((action: DocumentAction) => { let channel = currentChannelRef.current; if (channel) documentAction(channel, action); }, [documentAction]); let researchWorkspaceChanged = useCallback(( channel: Api.ResearchParentChannel, workspaceId: string, revision: number, ) => { refreshResearchWorkspace(channel, workspaceId, revision); }, [refreshResearchWorkspace]); let researchWorkspaceLoaded = useCallback(( channel: Api.ResearchParentChannel, workspace: Research.WorkspaceSummary, ) => { upsertResearchWorkspace(channel, workspace); }, [upsertResearchWorkspace]); let repositoryAccessChanged = useCallback(() => { void refresh(); }, [refresh]); let navigationDocument = useMemo(() => ({ channel: currentChannel, onDocumentAction: currentDocumentAction, onDocumentChanged: documentChanged, onDocumentDeleted: documentDeleted, onDocumentLoaded: documentLoaded, onRepositoryAccessChanged: repositoryAccessChanged, onResearchWorkspaceChanged: researchWorkspaceChanged, onResearchWorkspaceLoaded: researchWorkspaceLoaded, onResearchWorkspacesRefresh: refreshResearchChannel, }), [ currentChannel, currentDocumentAction, documentChanged, documentDeleted, documentLoaded, repositoryAccessChanged, researchWorkspaceChanged, researchWorkspaceLoaded, refreshResearchChannel, ]); let signOut = async () => { try { await Api.logout(); clearRepositoryCache(user.id); location.assign("/"); } catch (reason) { setError({ reason, retry: "refresh" }); } }; let retryError = () => { if (!error) return; if (error.retry === "visit" && currentChannelRef.current) { void documentLoaded(currentChannelRef.current, currentRouteKey.current); } else void refresh(); }; let dismissDrawer = () => { setDrawerOpen(false); requestAnimationFrame(() => drawerOpener.current?.focus({ preventScroll: true })); }; let navigateLink = (event: ReactMouseEvent) => { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) return; let target = event.target; if (!(target instanceof Element)) return; let link = target.closest("a[href]"); if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return; let destination = new URL(link.href, location.href); if (destination.origin !== location.origin) return; if ( destination.pathname !== "/" && !/^\/(?:channels|documents|repositories)(?:\/|$)/.test(destination.pathname) ) return; event.preventDefault(); setDrawerOpen(false); navigate(`${destination.pathname}${destination.search}${destination.hash}`); }; let sidebar = (
)} accountMenuOpen={accountOpen} canCreateDocument={!active || canManageProject(active)} creatingProjectIds={creatingProjectIdsForView} creatingNewDocument={!!active && creatingProjectIdsForView.has(active.repositoryId)} currentDocumentId={currentDocumentId} currentResearchWorkspaceId={currentResearchWorkspaceId} onAccount={() => setAccountOpen(open => !open)} onAddProject={() => showDialog("add")} onCollapse={() => { setCollapsed(true); if (drawerOpen) dismissDrawer(); else requestAnimationFrame(() => drawerOpener.current?.focus({ preventScroll: true })); }} onCreateDocument={project => void createDocument(project)} onDocumentAction={documentAction} onLoadMore={loadMore} onNewResearch={channel => { if (!channel.archivedAt) showDialog({ type: "research", channel }); }} onNewDocument={newDocument} onSearch={() => showDialog("search")} onCatalogueModeChange={setCatalogueMode} projects={projects} research={research} catalogueMode={catalogueMode} user={user} /> ); let content = ( <> {error !== undefined && (
{error.reason instanceof Error ? error.reason.message : "Could not update navigation."} {error.retry && ( )}
)} {children ?? (
{navigation?.projects.length === 0 ? "Add a Project to start your first document." : "Choose or create a document."}
)} ); return (
{sidebarPresence.phase !== "closed" && (
{sidebar}
)} {triggerVisible && ( mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false)} /> )} {drawerPresence.phase !== "closed" && ( {sidebar} )} {children === undefined ? (
{content}
) : (
{content}
)} {dialogMotion && presentedDialog === "add" && ( { catalogueRefreshes.current.set(project.repositoryId, Date.now()); setFocusProjectId(project.repositoryId); void refresh(); }} onDismiss={() => setDialog(undefined)} userId={user.id} /> )} {dialogMotion && presentedDialog === "search" && ( setDialog(undefined)} onSelect={navigateToDocument} projects={navigation?.projects ?? []} /> )} {dialogMotion && typeof presentedDialog === "object" && presentedDialog.type === "rename" && ( setDialog(undefined)} onRenamed={acceptChannel} /> )} {dialogMotion && typeof presentedDialog === "object" && presentedDialog.type === "delete" && ( documentDeleted(presentedDialog.channel.id)} onDismiss={() => setDialog(undefined)} /> )} {dialogMotion && typeof presentedDialog === "object" && presentedDialog.type === "research" && ( { upsertResearchWorkspace(presentedDialog.channel, workspace); setDialog(undefined); navigate(researchWorkspacePath( presentedDialog.channel.repositoryOwner, presentedDialog.channel.repositoryName, presentedDialog.channel.slug, workspace.id, )); }} onDismiss={() => setDialog(undefined)} /> )}
); }