import chopinIcon from "./assets/figma/navigation/chopin.svg"; import collapseIcon from "./assets/icons/panel-close.svg"; import documentActionsIcon from "./assets/figma/navigation/document-actions.svg"; import { unansweredDecisionsLabel, useDecisionAttention } from "./decision-view-control"; import { DocumentActionsMenu } from "./document-actions-menu"; import { IconButton } from "./icon-button"; import { MenuItem, MenuSeparator } from "./menu-item"; import { ProjectSidebarSkeleton } from "./project-sidebar-chrome"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { canManageProject } from "./navigation-model"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { Count, Face, MotionDisclosure, MotionDisclosureIcon } from "@chopin/editor"; import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; import { useSidebarRowPresence } from "./sidebar-row-presence"; import { useEffect, useId, useRef, useState } from "react"; import { ArchiveIcon, ChevronIcon, DocumentIcon, KeyboardIcon, LockIcon, PlusIcon, SearchIcon, SignInIcon, } from "@chopin/icons"; import type * as Api from "./api"; import type { DocumentAction } from "./document-actions-menu"; import type { ProjectDocuments } from "./document-actions"; import type { DocumentCreationPhase } from "./use-document-creation"; import type { ReactNode, Ref } from "react"; export function NavigationIcon( { alt = "", className, src }: { alt?: string; className?: string; src: string }, ) { return {alt}; } export function toggleCollapsedProjectIds( ids: ReadonlySet, repositoryId: string, ): Set { let next = new Set(ids); if (next.has(repositoryId)) next.delete(repositoryId); else next.add(repositoryId); return next; } export function documentGroups( channels: Api.Channel[], archiveMode: boolean, ): Array<{ parent: Api.Channel; children: Api.Channel[] }> { let parents = channels.filter(channel => channel.parentChannelId === undefined && (archiveMode ? channel.archivedAt !== undefined : channel.archivedAt === undefined) ); let parentIds = new Set(parents.map(channel => channel.id)); let children = new Map(); for (let channel of channels) { if (!channel.parentChannelId || !parentIds.has(channel.parentChannelId)) continue; let nested = children.get(channel.parentChannelId) ?? []; nested.push(channel); children.set(channel.parentChannelId, nested); } return parents.map(parent => ({ parent, children: children.get(parent.id) ?? [] })); } function UnansweredCount({ unanswered }: { unanswered: number }) { let attention = useDecisionAttention(unanswered); if (unanswered <= 0) return null; return ( ); } function archiveFocusTarget(list: HTMLElement | null, documentId: string): HTMLElement | undefined { let row = [...list?.querySelectorAll(":scope > li[data-document-id]") ?? []] .find(item => item.dataset.documentId === documentId); if (!row || !list) return undefined; let links = [...list.querySelectorAll( ":scope > li:not([data-exiting]) .project-sidebar-document-link", )].filter(link => !row.contains(link)); return links.find(link => row.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING) ?? links.at(-1) ?? row.closest(".project-sidebar-project")?.querySelector( ".project-sidebar-project-disclosure", ) ?? undefined; } // The actions menu is portalled and restores focus to its own trigger, which is // inside the row being removed, so focus is reclaimed once the menu has settled. function reclaimFocus(target: HTMLElement) { let reclaim = () => { let active = document.activeElement; if ( target.isConnected && (!active || active === document.body || active.closest("[data-exiting], [inert]")) ) target.focus({ preventScroll: true }); }; window.setTimeout(reclaim, 0); requestAnimationFrame(() => requestAnimationFrame(reclaim)); window.setTimeout(reclaim, 120); } function DocumentRow( { children, documentId, exiting, enter, onExited }: { children: ReactNode; documentId: string; enter: boolean; exiting: boolean; onExited: () => void; }, ) { let item = useRef(null); let id = useId(); let motion = motionContract("collapse"); let [armed, setArmed] = useState(!enter); useEffect(() => { if (armed) return; let frame = requestAnimationFrame(() => setArmed(true)); return () => cancelAnimationFrame(frame); }, [armed]); let finished = useRef(onExited); finished.current = onExited; useEffect(() => { if (!exiting) return; let timer = window.setTimeout(() => finished.current(), motion.closeDuration + 50); return () => window.clearTimeout(timer); }, [exiting, motion.closeDuration]); return (
  • {children}
  • ); } export function menuItemTarget(key: string, current: number, count: number): number { if (key === "Home") return 0; if (key === "End") return count - 1; return (current + (key === "ArrowDown" ? 1 : -1) + count) % count; } function noop() {} function Project( { archiveMode, pendingCreations, currentDocumentId, entry, expanded, focus, onCreateDocument, onDocumentAction, onFocused, onLoadMore, onToggle, revealed, }: { archiveMode: boolean; pendingCreations: ReadonlyMap; currentDocumentId?: string; entry: ProjectDocuments; expanded: boolean; focus: boolean; onCreateDocument: (project: Api.NavigationProject) => void; onDocumentAction: (channel: Api.Channel, action: DocumentAction) => void; onFocused: () => void; onLoadMore: (entry: ProjectDocuments) => void; onToggle: () => void; revealed: boolean; }, ) { let item = useRef(null); let disclosure = useRef(null); let [flash, setFlash] = useState(false); useEffect(() => { if (!focus) return; disclosure.current?.focus({ preventScroll: true }); item.current?.scrollIntoView({ block: "nearest" }); setFlash(true); onFocused(); }, [focus, onFocused]); useEffect(() => { if (!flash) return; let timer = setTimeout(setFlash, 900, false); return () => clearTimeout(timer); }, [flash]); let { documents, project } = entry; let groups = documentGroups(documents.channels, archiveMode); let presence = useSidebarRowPresence(groups, { immediately: motionImmediately(), ready: documents.status === "ready", scope: archiveMode ? "archived" : "active", }); let label = project.repository?.name ?? project.repositoryName; let canManage = canManageProject(project); let documentUnanswered = (channel: Api.Channel) => archiveMode ? 0 : channel.unansweredDecisions ?? 0; let projectUnanswered = archiveMode || documents.status === "unavailable" ? 0 : documents.unansweredDecisions ?? 0; let phase = pendingCreations.get(project.repositoryId); let contentId = useId(); let collapseMotion = motionContract("collapse"); let list = useRef(null); let documentAction = (channel: Api.Channel, action: DocumentAction) => { let target = action === "archive" ? archiveFocusTarget(list.current, channel.id) : undefined; onDocumentAction(channel, action); if (target) reclaimFocus(target); }; let projectContent = ( <> {documents.status === "ready" && groups.length === 0 && !documents.nextCursor && (

    {archiveMode ? "No archived documents" : "No documents"}

    )} {documents.status === "unavailable" && (

    Access unavailable

    )} {documents.status === "error" && (

    {documents.message}

    )} {documents.status === "loading" && groups.length === 0 && (

    Loading documents…

    )} {(groups.length > 0 || presence.rows.length > 0) && ( )} {documents.status === "loading" && groups.length > 0 && (

    Loading more…

    )} {documents.status === "ready" && documents.nextCursor && ( )} ); return (
  • {!archiveMode && project.available && canManage && ( onCreateDocument(project)} title={`New document in ${label}`} > )}
    {phase === "creating" ? "Creating document…" : phase ? "Opening document…" : ""}
    {projectContent}
  • ); } export function ProjectSidebar( { accountMenu, accountMenuOpen, accountMenuId, accountTriggerRef, accountWrapRef, canCreateDocument, newDocumentPhase, pendingCreations, currentDocumentId, onAccount, focusProjectId, onAddProject, onFocusedProject, onCollapse, onCreateDocument, onDocumentAction, onLoadMore, onNewDocument, onRevealed, onSearch, onCatalogueModeChange, projects, catalogueMode, reveal, user, }: { accountMenu?: false | { className: string; closing: boolean; onDismiss: () => void; onShortcuts: () => void; onSignOut: () => void; }; accountMenuOpen?: boolean; accountMenuId?: string; accountTriggerRef?: Ref; accountWrapRef?: Ref; canCreateDocument: boolean; catalogueMode: "active" | "archived"; newDocumentPhase?: DocumentCreationPhase | "loading"; pendingCreations: ReadonlyMap; currentDocumentId?: string; onAccount: () => void; focusProjectId?: string; onAddProject: () => void; onFocusedProject?: () => void; onCollapse: () => void; onCreateDocument: (project: Api.NavigationProject) => void; onDocumentAction: (channel: Api.Channel, action: DocumentAction) => void; onLoadMore: (entry: ProjectDocuments) => void; onNewDocument: () => void; onRevealed?: () => void; onSearch: () => void; onCatalogueModeChange: (mode: "active" | "archived") => void; projects: ProjectDocuments[]; reveal?: { id: string; nonce: number }; user: Api.User; }, ) { let [collapsedProjectIds, setCollapsedProjectIds] = useState>( () => new Set(), ); useEffect(() => { let timer = window.setTimeout(() => { void import("./dialog-prefetch").then(module => module.prefetchDialogs( user.id, projects.map(entry => entry.project), catalogueMode === "archived", projects, ) ); }, 1500); return () => window.clearTimeout(timer); }, [catalogueMode, projects, user.id]); let [revealedId, setRevealedId] = useState(); useEffect(() => { if (!reveal) return; setCollapsedProjectIds(current => { if (!current.has(reveal.id)) return current; let next = new Set(current); next.delete(reveal.id); return next; }); setRevealedId(reveal.id); let frame = requestAnimationFrame(() => { let row = document.querySelector( `[data-project-id="${CSS.escape(reveal.id)}"] .project-sidebar-project-disclosure`, ); row?.scrollIntoView({ block: "nearest" }); row?.focus({ preventScroll: true }); }); let timer = setTimeout(() => { setRevealedId(undefined); onRevealed?.(); }, 1400); return () => { cancelAnimationFrame(frame); clearTimeout(timer); }; }, [reveal]); let archivedButton = useRef(null); let allDocumentsButton = useRef(null); let archiveMode = catalogueMode === "archived"; let platform = currentShortcutPlatform(); let actionsRef = useRef(null); let listRef = useRef(null); let shownMode = useRef(catalogueMode); useEffect(() => { if (shownMode.current === catalogueMode) return; shownMode.current = catalogueMode; if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; let style = getComputedStyle(actionsRef.current ?? document.documentElement); let offset = catalogueMode === "archived" ? "8px" : "-8px"; for (let element of [actionsRef.current, listRef.current]) { element?.animate( [{ opacity: 0, transform: `translateX(${offset})` }, { opacity: 1, transform: "none" }], { duration: parseFloat(style.getPropertyValue("--duration-base")) || 200, easing: style.getPropertyValue("--motion-smooth-out").trim() || "ease-out", }, ); } }, [catalogueMode]); let searchButton = ( ); let primaryActions = (
    {archiveMode ? ( <> {searchButton} ) : ( <> )}
    ); let archiveFooter = !archiveMode ? (
    ) : null; return ( ); }