import { useCodeMode } from "@agent-native/core/client/agent-chat"; import { appPath } from "@agent-native/core/client/api-path"; import { DevDatabaseLink } from "@agent-native/core/client/db-admin"; import { ExtensionSlot } from "@agent-native/core/client/extensions"; import { setClientAppState, useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { LanguagePicker, useT } from "@agent-native/core/client/i18n"; import { OrgSwitcher } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import type { ContentDatabaseItem, ContentDatabaseResponse, Document, DocumentTreeNode, } from "@shared/api"; import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "@shared/api"; import { IconFolder, IconFolderOpen, IconPlus, IconRestore, IconSearch, IconSettings, IconStar, IconTrashX, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconChevronDown, IconChevronRight, IconDots, IconTrash, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { ContentFilesSidebarView } from "@/components/editor/database/sidebar"; import { QueryErrorState } from "@/components/QueryErrorState"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { applyOptimisticItemToContentDatabase, contentDatabaseByIdQueryKey, removeOptimisticItemFromContentDatabase, useContentDatabaseById, useContentDatabasePersonalView, useUpdateContentDatabasePersonalView, useCreateContentDatabase, useDeleteContentDatabase, useRestoreContentDatabase, useTrashedContentDatabases, } from "@/hooks/use-content-database"; import { useContentSpaces, useEnsureContentSpaces, type ContentSpaceSummary, } from "@/hooks/use-content-spaces"; import { useDocuments, useCreateDocument, useDeleteDocument, usePermanentlyDeleteDocument, useMoveDocument, useRestoreDocument, useTrashedDocuments, useUpdateDocument, buildDocumentTree, filterDocumentTreeDocuments, } from "@/hooks/use-documents"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { markDocumentCreationPending, shouldCreateDocumentOptimistically, } from "@/lib/optimistic-document"; import { cn } from "@/lib/utils"; import { getDocumentSidebarSections, isDirectLocalDocument, } from "./document-sidebar-sections"; import { DocumentSidebarIcon, DocumentTreeItem, FavoriteDocumentItem, } from "./DocumentTreeItem"; import { contentSpaceAvailability, contentSpaceForStoredSelection, createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, ensureWorkspaceExpanded, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, toggleExpandedWorkspaceIds, } from "./select-content-space"; import { WorkspaceSourceMenu, type CreatedWorkspace, } from "./WorkspaceSourceMenu"; function nanoid(size = 12): string { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const bytes = crypto.getRandomValues(new Uint8Array(size)); return Array.from(bytes, (b) => chars[b % chars.length]).join(""); } interface DocumentSidebarProps { activeDocumentId: string | null; collapsed: boolean; onToggleCollapsed: () => void; onNavigate?: () => void; width?: number; onResize?: (width: number) => void; } const LIST_DOCUMENTS_QUERY_KEY = [ "action", "list-documents", undefined, ] as const; function withDocumentsCacheShape(old: unknown, documents: Document[]) { if (Array.isArray(old)) return documents; return { ...(old && typeof old === "object" ? old : {}), documents, }; } function compareDocumentsByPosition(a: Document, b: Document) { return ( a.position - b.position || a.title.localeCompare(b.title) || a.id.localeCompare(b.id) ); } function collectDocumentSubtreeIds(documents: Document[], rootId: string) { const deletedIds = new Set(); const queue = [rootId]; while (queue.length > 0) { const id = queue.shift()!; if (deletedIds.has(id)) continue; deletedIds.add(id); for (const doc of documents) { if (doc.parentId === id) queue.push(doc.id); } } return deletedIds; } type SidebarSectionId = | "favorites" | "local-files" | "shared-copies" | "private" | "organization" | "trash"; type CollapsedSectionsState = Record; const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = "content-sidebar-collapsed-sections"; const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY = "content-sidebar-trash-collapsed-default-v2"; const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; interface ContentSidebarStateSnapshot { version: typeof CONTENT_SIDEBAR_STATE_VERSION; expandedWorkspaceIds: string[]; expandedDocumentIds: string[]; } const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, "local-files": false, "shared-copies": false, private: false, organization: false, trash: true, }; function normalizeCollapsedSections( value: Partial> | null | undefined, ): CollapsedSectionsState { return { favorites: value?.favorites ?? false, "local-files": value?.["local-files"] ?? false, "shared-copies": value?.["shared-copies"] ?? false, private: value?.private ?? false, organization: value?.organization ?? false, trash: value?.trash ?? true, }; } interface RemoveLocalFileSourceResult { success: boolean; deleted: number; } function WorkspaceFilesSection({ space, selected, activeDocumentId, expandedDocumentIds, onDocumentExpandedChange, onActivate, onCreateChildPage, onCreateChildDatabase, onDeleteItem, onToggleFavorite, }: { space: ContentSpaceSummary; selected: boolean; activeDocumentId: string | null; expandedDocumentIds: ReadonlySet; onDocumentExpandedChange: (documentId: string, expanded: boolean) => void; onActivate: (space: ContentSpaceSummary, documentId?: string) => void; onCreateChildPage: ( space: ContentSpaceSummary, item: ContentDatabaseItem, ) => void; onCreateChildDatabase: ( space: ContentSpaceSummary, item: ContentDatabaseItem, ) => void; onDeleteItem: (item: ContentDatabaseItem) => void; onToggleFavorite: (item: ContentDatabaseItem) => void; }) { const t = useT(); const filesDatabase = useContentDatabaseById(space.filesDatabaseId); const filesPersonalView = useContentDatabasePersonalView( space.filesDatabaseId, ); const updateFilesPersonalView = useUpdateContentDatabasePersonalView( space.filesDatabaseId, ); const failed = filesDatabase.isError || filesPersonalView.isError; return (
{failed ? ( { void filesDatabase.refetch(); void filesPersonalView.refetch(); }} retrying={filesDatabase.isFetching || filesPersonalView.isFetching} /> ) : ( { const current = filesPersonalView.data?.overrides; updateFilesPersonalView.mutate({ databaseId: space.filesDatabaseId, overrides: { version: current?.version ?? CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, activeViewId: viewId, views: current?.views ?? [], }, }); }} onOpenItem={(item: ContentDatabaseItem) => { if (selected) return false; onActivate(space, item.document.id); return true; }} onCreateChildPage={(item) => onCreateChildPage(space, item)} onCreateChildDatabase={(item) => onCreateChildDatabase(space, item)} onDeleteItem={onDeleteItem} onToggleFavorite={onToggleFavorite} labels={{ noMatchesLabel: t("database.noRowsMatchThisView"), clearLabel: t("database.clearSearchAndFilters"), navigationLabel: `${space.name} ${t("sidebar.files")}`, untitledLabel: t("sidebar.untitled"), }} /> )}
); } export function DocumentSidebar({ activeDocumentId, collapsed, onToggleCollapsed, onNavigate, width, onResize, }: DocumentSidebarProps) { const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); const t = useT(); const documentsQuery = useDocuments(); const { data: documents = [], isLoading } = documentsQuery; const createDocument = useCreateDocument(); const createDatabase = useCreateContentDatabase(null); const deleteContentDatabase = useDeleteContentDatabase(); const deleteDocument = useDeleteDocument(); const permanentlyDeleteDocument = usePermanentlyDeleteDocument(); const moveDocument = useMoveDocument(); const restoreDocument = useRestoreDocument(); const { data: trashedDocuments } = useTrashedDocuments(); const restoreContentDatabase = useRestoreContentDatabase(); const { data: trashedDatabases } = useTrashedContentDatabases(); const { isCodeMode } = useCodeMode(); const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; const workspaceCatalogDatabaseId = contentSpacesQuery.data?.catalogDatabaseId ?? null; const favoritesDocumentId = contentSpacesQuery.data?.favoritesDocumentId ?? null; const workspaceCatalogDatabase = useContentDatabaseById( workspaceCatalogDatabaseId, ); const workspaceCatalogPersonalView = useContentDatabasePersonalView( workspaceCatalogDatabaseId, ); const updateWorkspaceCatalogPersonalView = useUpdateContentDatabasePersonalView(workspaceCatalogDatabaseId); const spaceProvisionAttemptedRef = useRef(false); useEffect(() => { if ( contentSpacesQuery.isSuccess && !spaceProvisionAttemptedRef.current && !ensureContentSpaces.isPending ) { spaceProvisionAttemptedRef.current = true; ensureContentSpaces.mutate({}); } }, [ contentSpacesQuery.isSuccess, ensureContentSpaces, ensureContentSpaces.isPending, ]); const [storedSpaceId, setStoredSpaceId] = useLocalStorage( SELECTED_CONTENT_SPACE_STORAGE_KEY, null, ); const selectedSpace = contentSpaceForStoredSelection({ spaces: contentSpaces, storedSpaceId, }); const sidebarStateQuery = useActionQuery("get-content-sidebar-state", {}); const updateSidebarState = useActionMutation("update-content-sidebar-state", { skipActionQueryInvalidation: true, onSuccess: (data) => { queryClient.setQueryData( ["action", "get-content-sidebar-state", {}], data, ); }, }); const updateSidebarStateRef = useRef(updateSidebarState); updateSidebarStateRef.current = updateSidebarState; const sidebarStateWriteQueueRef = useRef< ((snapshot: ContentSidebarStateSnapshot) => Promise) | null >(null); if (!sidebarStateWriteQueueRef.current) { sidebarStateWriteQueueRef.current = createContentSidebarStateWriteQueue( (snapshot: ContentSidebarStateSnapshot) => updateSidebarStateRef.current.mutateAsync(snapshot), ); } const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState( [], ); const [expandedDocumentIds, setExpandedDocumentIds] = useState([]); const expandedDocumentIdSet = useMemo( () => new Set(expandedDocumentIds), [expandedDocumentIds], ); const sidebarStateHydratedRef = useRef(false); const expandedWorkspaceIdsRef = useRef([]); const expandedDocumentIdsRef = useRef([]); const contentSpaceState = contentSpaceAvailability({ hasSelectedSpace: Boolean(selectedSpace), contentSpacesLoading: contentSpacesQuery.isLoading, contentSpacesFetching: contentSpacesQuery.isFetching, contentSpacesError: contentSpacesQuery.isError, provisioningAttempted: spaceProvisionAttemptedRef.current, provisioningPending: ensureContentSpaces.isPending, provisioningError: ensureContentSpaces.isError, }); const handleRetryContentSpaces = useCallback(() => { if (contentSpacesQuery.isError) { spaceProvisionAttemptedRef.current = false; void contentSpacesQuery.refetch(); return; } spaceProvisionAttemptedRef.current = true; ensureContentSpaces.mutate({}); }, [contentSpacesQuery, ensureContentSpaces]); useEffect(() => { if (selectedSpace && selectedSpace.id !== storedSpaceId) { setStoredSpaceId(selectedSpace.id); } }, [selectedSpace, setStoredSpaceId, storedSpaceId]); useEffect(() => { if ( sidebarStateHydratedRef.current || !contentSpacesQuery.isSuccess || sidebarStateQuery.isLoading ) { return; } const stored = sidebarStateQuery.data?.state; const workspaceIds = stored?.expandedWorkspaceIds ?? contentSpaces.map((space) => space.id); const documentIds = stored?.expandedDocumentIds ?? []; expandedWorkspaceIdsRef.current = workspaceIds; expandedDocumentIdsRef.current = documentIds; setExpandedWorkspaceIds(workspaceIds); setExpandedDocumentIds(documentIds); sidebarStateHydratedRef.current = true; }, [ contentSpaces, contentSpacesQuery.isSuccess, sidebarStateQuery.data?.state, sidebarStateQuery.isLoading, ]); const queueSidebarStateWrite = useCallback( (workspaceIds: string[], documentIds: string[]) => { if (!sidebarStateHydratedRef.current) return; void sidebarStateWriteQueueRef .current?.({ version: CONTENT_SIDEBAR_STATE_VERSION, expandedWorkspaceIds: workspaceIds, expandedDocumentIds: documentIds, }) .catch((error) => { toast.error(t("sidebar.failedSaveSidebarState"), { description: error instanceof Error ? error.message : String(error), }); }); }, [t], ); const updateExpandedWorkspaceIds = useCallback( (update: (current: string[]) => string[]) => { setExpandedWorkspaceIds((current) => { const next = update(current); if (next === current) return current; expandedWorkspaceIdsRef.current = next; queueSidebarStateWrite(next, expandedDocumentIdsRef.current); return next; }); }, [queueSidebarStateWrite], ); const handleDocumentExpandedChange = useCallback( (documentId: string, expanded: boolean) => { setExpandedDocumentIds((current) => { const nextSet = new Set(current); if (expanded) nextSet.add(documentId); else nextSet.delete(documentId); const next = [...nextSet]; expandedDocumentIdsRef.current = next; queueSidebarStateWrite(expandedWorkspaceIdsRef.current, next); return next; }); }, [queueSidebarStateWrite], ); const handleSelectContentSpace = useCallback( async ( space: (typeof contentSpaces)[number], targetDocumentId?: string | null, ) => { updateExpandedWorkspaceIds((current) => ensureWorkspaceExpanded(current, space.id), ); try { await workspaceSelectionQueueRef.current(() => selectContentSpace({ space, syncApplicationState: (selected) => setClientAppState( "content-space", { spaceId: selected.id, name: selected.name, kind: selected.kind, filesDatabaseId: selected.filesDatabaseId, }, { requestSource: "content-sidebar" }, ), persistSelection: setStoredSpaceId, openFiles: (documentId) => { if (targetDocumentId === null) return; navigate(`/page/${targetDocumentId ?? documentId}`, { flushSync: true, }); }, }), ); return true; } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); return false; } }, [navigate, setStoredSpaceId, updateExpandedWorkspaceIds], ); const handleWorkspaceCreated = useCallback( (created: CreatedWorkspace) => handleSelectContentSpace({ id: created.spaceId, name: created.name, kind: created.kind, filesDatabaseId: created.filesDatabaseId, filesDocumentId: created.filesDocumentId, orgId: null, role: "owner", catalogItemId: created.catalogItemId, catalogDocumentId: created.catalogDocumentId, }), [handleSelectContentSpace], ); useEffect(() => { if (!selectedSpace) return; void setClientAppState( "content-space", { spaceId: selectedSpace.id, name: selectedSpace.name, kind: selectedSpace.kind, filesDatabaseId: selectedSpace.filesDatabaseId, }, { requestSource: "content-sidebar" }, ).catch(() => { // Space selection remains usable when best-effort agent context sync fails. }); }, [selectedSpace]); const removeLocalFileSource = useActionMutation< RemoveLocalFileSourceResult, { sourceRootPath?: string | null } >("remove-local-file-source"); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); // Track user-expanded nodes only; active ancestors are derived below so they // do not stay open after navigation unless the user explicitly expanded them. const expandedIdsRef = useRef(new Set()); const [, forceUpdate] = useState(0); const [isResizing, setIsResizing] = useState(false); const [storedCollapsedSections, setStoredCollapsedSections] = useLocalStorage< Partial> >(SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY, DEFAULT_COLLAPSED_SECTIONS); const collapsedSections = useMemo( () => normalizeCollapsedSections(storedCollapsedSections), [storedCollapsedSections], ); useEffect(() => { try { if ( window.localStorage.getItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY) === "1" ) { return; } setStoredCollapsedSections((current) => ({ ...normalizeCollapsedSections(current), trash: true, })); window.localStorage.setItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY, "1"); } catch {} }, [setStoredCollapsedSections]); const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] = useState(false); const settingsActive = location.pathname.startsWith("/settings"); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!onResize || width === undefined) return; e.preventDefault(); setIsResizing(true); const startX = e.clientX; const startWidth = width; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; const handleMouseMove = (e: MouseEvent) => { onResize(startWidth + e.clientX - startX); }; const handleMouseUp = () => { setIsResizing(false); document.body.style.userSelect = ""; document.body.style.cursor = ""; document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [onResize, width], ); const treeDocuments = filterDocumentTreeDocuments(documents); const { localFileMode, localSourceDocuments, databaseDocuments, favorites, showFavorites, } = getDocumentSidebarSections(documents, treeDocuments); const localFileTree = buildDocumentTree(localSourceDocuments); const databaseTree = buildDocumentTree(databaseDocuments); const importedLocalFileCount = localFileMode ? 0 : localSourceDocuments.filter( (document) => document.source?.kind !== "folder", ).length; const canRemoveLocalFiles = localFileMode || importedLocalFileCount > 0; // Match the tree rows' right-side inset so favorite titles clip inside the // visible sidebar instead of widening the scroll surface. const favoriteRowWidth = width === undefined ? undefined : Math.max(208, width - 24); const activeDocument = activeDocumentId ? documents.find((doc) => doc.id === activeDocumentId) : null; const trashItems = trashedDatabases?.databases ?? []; const trashedPageItems = trashedDocuments?.documents ?? []; const parentByDocumentId = useMemo( () => new Map(documents.map((doc) => [doc.id, doc.parentId])), [documents], ); const activeAncestorIds = useMemo(() => { const ids = new Set(); let parentId = activeDocumentId ? (parentByDocumentId.get(activeDocumentId) ?? null) : null; while (parentId && !ids.has(parentId)) { ids.add(parentId); parentId = parentByDocumentId.get(parentId) ?? null; } return ids; }, [activeDocumentId, parentByDocumentId]); const expandedIds = new Set(expandedIdsRef.current); for (const id of activeAncestorIds) expandedIds.add(id); const handleToggleExpanded = useCallback( (id: string) => { if (activeAncestorIds.has(id)) return; if (expandedIdsRef.current.has(id)) { expandedIdsRef.current.delete(id); } else { expandedIdsRef.current.add(id); } forceUpdate((n) => n + 1); }, [activeAncestorIds], ); const navigateToDocument = useCallback( (id: string) => { navigate(`/page/${id}`, { flushSync: true }); }, [navigate], ); const handleCreatePage = useCallback( async ( parentId?: string, rootSpaceId = selectedSpace?.id, optimisticId?: string, rootFilesDatabaseId?: string, ) => { if ( !shouldCreateDocumentOptimistically({ localFileMode, filesDatabaseId: rootFilesDatabaseId, }) ) { try { const created = await createDocument.mutateAsync({ title: "", parentId: parentId ?? undefined, spaceId: parentId ? undefined : rootSpaceId, }); queryClient.setQueryData( ["action", "get-document", { id: created.id }], created, ); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); navigateToDocument(created.id); onNavigate?.(); } catch (err) { toast.error(t("sidebar.failedCreatePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } return; } const id = optimisticId ?? nanoid(); const now = new Date().toISOString(); const tempDoc = markDocumentCreationPending({ id, parentId: parentId ?? null, title: "", content: "", icon: null, position: 9999, isFavorite: false, hideFromSearch: false, visibility: "private", accessRole: "owner", canEdit: true, canManage: true, createdAt: now, updatedAt: now, }); // Optimistically inject into caches so UI updates immediately queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: any) => { const docs: Document[] = old?.documents ?? (Array.isArray(old) ? old : []); return { documents: [...docs, tempDoc] }; }); queryClient.setQueryData(["action", "get-document", { id }], tempDoc); if (rootFilesDatabaseId) { const optimisticItem: ContentDatabaseItem = { id: `optimistic-${id}`, databaseId: rootFilesDatabaseId, document: tempDoc, position: tempDoc.position, properties: [], }; queryClient.setQueryData( contentDatabaseByIdQueryKey(rootFilesDatabaseId), (current) => applyOptimisticItemToContentDatabase(current, optimisticItem), ); } navigateToDocument(id); onNavigate?.(); try { const created = await createDocument.mutateAsync({ id, title: "", parentId: parentId ?? undefined, spaceId: parentId ? undefined : rootSpaceId, }); const nextId = created?.id || id; queryClient.setQueryData( ["action", "get-document", { id: nextId }], created, ); if (nextId !== id) { queryClient.removeQueries({ queryKey: ["action", "get-document", { id }], }); navigateToDocument(nextId); } // Replace optimistic doc with real server doc + clear any 404 error // state from the in-flight fetch that ran before create completed. queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: nextId }], }); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); if (rootFilesDatabaseId) { queryClient.invalidateQueries({ queryKey: contentDatabaseByIdQueryKey(rootFilesDatabaseId), }); } } catch (err) { // Revert optimistic updates queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); queryClient.removeQueries({ queryKey: ["action", "get-document", { id }], }); if (rootFilesDatabaseId) { queryClient.setQueryData( contentDatabaseByIdQueryKey(rootFilesDatabaseId), (current) => removeOptimisticItemFromContentDatabase(current, id), ); } navigate("/"); toast.error(t("sidebar.failedCreatePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [ createDocument, localFileMode, navigate, navigateToDocument, onNavigate, queryClient, selectedSpace?.id, ], ); const handleCreateDatabase = useCallback( async (parentId?: string | null, rootSpaceId = selectedSpace?.id) => { try { const result = await createDatabase.mutateAsync({ parentId: parentId ?? null, spaceId: parentId ? undefined : rootSpaceId, title: t("editor.untitledDatabase"), }); navigateToDocument(result.database.documentId); onNavigate?.(); } catch (err) { toast.error(t("sidebar.failedCreateDatabase"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [createDatabase, navigateToDocument, onNavigate, selectedSpace?.id, t], ); const handleCreatePageInSpace = useCallback( async (space: ContentSpaceSummary) => { const id = nanoid(); if (selectedSpace?.id !== space.id) { void handleSelectContentSpace(space, null); } await handleCreatePage(undefined, space.id, id, space.filesDatabaseId); }, [handleCreatePage, handleSelectContentSpace, selectedSpace?.id], ); const handleOpenFavorite = useCallback( (document: Document) => { const space = contentSpaces.find( (candidate) => candidate.filesDocumentId === document.databaseMembership?.databaseDocumentId, ); if (space) { void handleSelectContentSpace(space, document.id); return; } navigateToDocument(document.id); }, [contentSpaces, handleSelectContentSpace, navigateToDocument], ); const handleDelete = useCallback( async (id: string) => { const deletedDocument = documents.find((doc) => doc.id === id) ?? null; const deletedIds = collectDocumentSubtreeIds(documents, id); const activeDeleted = activeDocumentId ? deletedIds.has(activeDocumentId) : false; const survivingDocuments = documents.filter( (doc) => !deletedIds.has(doc.id), ); const navigationCandidates = localFileMode ? survivingDocuments.filter((doc) => doc.source?.kind !== "folder") : survivingDocuments; const nextDocument = navigationCandidates.find((doc) => doc.isFavorite) ?? [...navigationCandidates].sort(compareDocumentsByPosition)[0] ?? null; queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { const cachedDocs: Document[] = (old as { documents?: Document[] })?.documents ?? (Array.isArray(old) ? old : documents); return withDocumentsCacheShape( old, cachedDocs.filter((doc) => !deletedIds.has(doc.id)), ); }); for (const deletedId of deletedIds) { queryClient.removeQueries({ queryKey: ["action", "get-document", { id: deletedId }], }); } if (activeDeleted) { navigate(nextDocument ? `/page/${nextDocument.id}` : "/", { replace: true, flushSync: true, }); } try { if (deletedDocument?.database) { await deleteContentDatabase.mutateAsync({ databaseId: deletedDocument.database.id, }); } else { await deleteDocument.mutateAsync({ id }); } queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); } catch (err) { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); if (activeDeleted && activeDocumentId) { navigate(`/page/${activeDocumentId}`, { replace: true, flushSync: true, }); } toast.error(t("sidebar.failedDeletePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [ activeDocumentId, deleteContentDatabase, deleteDocument, documents, localFileMode, navigate, queryClient, ], ); const handleReorderPage = useCallback( async (id: string, overId: string) => { if (id === overId) return; const current = documents.find((doc) => doc.id === id); const target = documents.find((doc) => doc.id === overId); if (!current || !target) return; if (current.parentId !== target.parentId) { return; } const siblings = documents .filter((doc) => doc.parentId === current.parentId) .sort(compareDocumentsByPosition); const currentIndex = siblings.findIndex((doc) => doc.id === id); const nextIndex = siblings.findIndex((doc) => doc.id === overId); if (currentIndex < 0 || nextIndex < 0 || currentIndex === nextIndex) { return; } const reordered = arrayMove(siblings, currentIndex, nextIndex); const nextPositionById = new Map( reordered.map((doc, index) => [doc.id, index]), ); const changed = reordered.filter( (doc) => doc.position !== nextPositionById.get(doc.id), ); if (changed.length === 0) return; if (changed.some((doc) => doc.canEdit === false)) { toast.error(t("sidebar.cannotReorderPages"), { description: t("sidebar.oneAffectedPageReadOnly"), }); return; } queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { const cachedDocs: Document[] = (old as { documents?: Document[] })?.documents ?? (Array.isArray(old) ? old : documents); const nextDocs = cachedDocs.map((doc) => { const nextPosition = nextPositionById.get(doc.id); return nextPosition === undefined ? doc : { ...doc, position: nextPosition }; }); return withDocumentsCacheShape(old, nextDocs); }); try { await Promise.all( changed.map((doc) => moveDocument.mutateAsync({ id: doc.id, position: nextPositionById.get(doc.id)!, }), ), ); } catch (err) { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); toast.error(t("sidebar.failedMovePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [documents, moveDocument, queryClient], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; const activeId = String(active.id); const overId = over ? String(over.id) : null; if (!overId || activeId === overId) return; if (parentByDocumentId.get(activeId) !== parentByDocumentId.get(overId)) { return; } void handleReorderPage(activeId, overId); }, [handleReorderPage, parentByDocumentId], ); const handleToggleFavorite = useCallback( (id: string, isFavorite: boolean) => { updateDocument.mutate( { id, isFavorite }, { onError: (error) => { toast.error(t("sidebar.failedUpdateFavorite"), { description: error instanceof Error ? error.message : t("empty.genericError"), }); }, }, ); }, [t, updateDocument], ); const handleRestoreDatabase = useCallback( async (databaseId: string) => { try { await restoreContentDatabase.mutateAsync({ databaseId }); toast.success(t("sidebar.databaseRestored")); } catch (err) { toast.error(t("sidebar.failedRestoreDatabase"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [restoreContentDatabase, t], ); const handlePermanentDeleteDatabase = useCallback( async (documentId: string) => { try { await permanentlyDeleteDocument.mutateAsync({ id: documentId }); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); toast.success(t("sidebar.databasePermanentlyDeleted")); } catch (err) { toast.error(t("sidebar.failedPermanentDeleteDatabase"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [permanentlyDeleteDocument, queryClient, t], ); const handleRestoreDocument = useCallback( async (documentId: string) => { try { await restoreDocument.mutateAsync({ id: documentId }); toast.success(t("sidebar.pageRestored")); } catch (err) { toast.error(t("sidebar.failedRestorePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [restoreDocument, t], ); const handlePermanentDeleteDocument = useCallback( async (documentId: string) => { try { await permanentlyDeleteDocument.mutateAsync({ id: documentId }); toast.success(t("sidebar.pagePermanentlyDeleted")); } catch (err) { toast.error(t("sidebar.failedPermanentDeletePage"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [permanentlyDeleteDocument, t], ); const handleRemoveLocalFiles = useCallback(async () => { try { const result = await removeLocalFileSource.mutateAsync({}); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); setRemoveLocalFilesDialogOpen(false); toast.success(t("sidebar.localFilesRemoved"), { description: t("sidebar.localFilesRemovedDescription", { count: result.deleted, }), }); } catch (err) { toast.error(t("sidebar.failedRemoveLocalFiles"), { description: err instanceof Error ? err.message : t("empty.genericError"), }); } }, [queryClient, removeLocalFileSource, t]); const filteredDocuments = searchQuery ? documents.filter((d) => d.title.toLowerCase().includes(searchQuery.toLowerCase()), ) : null; const renderDocumentTree = (nodes: DocumentTreeNode[]) => ( node.id)} strategy={verticalListSortingStrategy} > {nodes.map((node) => ( { navigateToDocument(id); onNavigate?.(); }} onCreateChildPage={(parentId) => handleCreatePage(parentId)} onCreateChildDatabase={(parentId) => handleCreateDatabase(parentId)} onDelete={handleDelete} onToggleFavorite={handleToggleFavorite} /> ))} ); const renderNewButton = (space = selectedSpace) => space ? ( ) : null; const renderCollapsedNewButton = () => selectedSpace ? ( {t("sidebar.newPage")} ) : null; const renderSettingsNavButton = () => ( {t("navigation.settings")} ); const collapseButton = ( {collapsed ? t("sidebar.expand") : t("sidebar.collapse")} ); const searchButton = ( {t("sidebar.search")} ); const translateButton = ( ); const feedbackButton = ( ); const toggleSection = (id: SidebarSectionId) => { setStoredCollapsedSections((current) => { const normalized = normalizeCollapsedSections(current); return { ...normalized, [id]: !normalized[id], }; }); }; const renderLocalFilesSectionActions = () => ( {t("sidebar.localFilesActions")} {t("sidebar.manageLocalFolders")} {canRemoveLocalFiles && ( <> { event.preventDefault(); setRemoveLocalFilesDialogOpen(true); }} > {t("sidebar.removeLocalFilesFromSidebar")} )} ); const renderSectionHeader = ( id: SidebarSectionId, label: string, actions?: ReactNode, ) => { const collapsed = collapsedSections[id]; return (
{actions}
); }; const renderTreeSkeleton = () => ( ); const renderTreeSection = ({ id, label, nodes, emptyLabel, className, headerActions, footer, }: { id: SidebarSectionId; label: string; nodes: DocumentTreeNode[]; emptyLabel: string; className?: string; headerActions?: ReactNode; footer?: ReactNode; }) => { const collapsed = collapsedSections[id]; return (
{renderSectionHeader(id, label, headerActions)} {!collapsed && ( <> {isLoading ? ( renderTreeSkeleton() ) : documentsQuery.isError ? ( void documentsQuery.refetch()} retrying={documentsQuery.isFetching} /> ) : nodes.length === 0 ? (
{emptyLabel}
) : ( renderDocumentTree(nodes) )}
{footer} )}
); }; const renderWorkspaceRoot = (space: ContentSpaceSummary) => { const selected = selectedSpace?.id === space.id; const expanded = expandedWorkspaceIds.includes(space.id); return (
{expanded && ( void handleSelectContentSpace(nextSpace, documentId) } onCreateChildPage={(nextSpace, item) => void handleCreatePage( item.document.id, nextSpace.id, undefined, nextSpace.filesDatabaseId, ) } onCreateChildDatabase={(nextSpace, item) => void handleCreateDatabase(item.document.id, nextSpace.id) } onDeleteItem={(item) => void handleDelete(item.document.id)} onToggleFavorite={(item) => handleToggleFavorite(item.document.id, !item.document.isFavorite) } /> )}
); }; const renderWorkspaceNavigation = () => (
{contentSpaceState === "ready" && selectedSpace ? (
{workspaceCatalogDatabase.isError || workspaceCatalogPersonalView.isError ? ( { void workspaceCatalogDatabase.refetch(); void workspaceCatalogPersonalView.refetch(); }} retrying={ workspaceCatalogDatabase.isFetching || workspaceCatalogPersonalView.isFetching } /> ) : ( { if (!workspaceCatalogDatabaseId) return; const current = workspaceCatalogPersonalView.data?.overrides; updateWorkspaceCatalogPersonalView.mutate({ databaseId: workspaceCatalogDatabaseId, overrides: { version: current?.version ?? CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, activeViewId: viewId, views: current?.views ?? [], }, }); }} renderItem={(item) => { const space = contentSpaces.find( (candidate) => candidate.catalogDocumentId === item.document.id, ); return space ? renderWorkspaceRoot({ ...space, name: item.document.title || space.name, }) : null; }} scroll={false} labels={{ noMatchesLabel: t("database.noRowsMatchThisView"), clearLabel: t("database.clearSearchAndFilters"), navigationLabel: "Content navigation", untitledLabel: t("sidebar.untitled"), }} /> )}
) : contentSpaceState === "loading" ? ( renderTreeSkeleton() ) : ( )}
); const renderTrashSection = () => { const collapsed = collapsedSections.trash; return (
{!collapsed && (
{trashedPageItems.map((document) => { const title = document.title || t("sidebar.untitled"); return (
{title} {t("sidebar.restorePage")} {t("sidebar.deletePermanently")} {t("sidebar.deletePagePermanentlyQuestion")} {t("sidebar.deletePagePermanentlyDescription", { title, })} {t("comments.cancel")} void handlePermanentDeleteDocument( document.documentId, ) } > {t("sidebar.deletePermanently")}
); })} {trashedPageItems.length === 0 && trashItems.length === 0 ? (
{t("sidebar.trashEmpty")}
) : null} {trashItems.map((database) => { const title = database.title || t("editor.untitledDatabase"); return (
{title} {t("sidebar.restoreDatabase")} {database.canPermanentlyDelete && ( {t("sidebar.deletePermanently")} {t("sidebar.deleteDatabasePermanentlyQuestion")} {t("sidebar.deleteDatabasePermanentlyDescription", { title, })} {t("comments.cancel")} void handlePermanentDeleteDatabase( database.documentId, ) } > {t("sidebar.deletePermanently")} )}
); })}
)}
); }; if (collapsed) { return (
{renderCollapsedNewButton()} {t("navigation.settings")}
); } return (
{/* Header */}
Content
{/* Search */} {isSearching && (
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setIsSearching(false); setSearchQuery(""); } }} className="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-md outline-none focus:ring-1 focus:ring-ring" />
)}
{/* Search results */} {filteredDocuments ? ( <>
{t("sidebar.results")}
{filteredDocuments.length === 0 ? (
{t("sidebar.noPagesFound")}
) : ( filteredDocuments.map((doc) => ( )) )}
{renderNewButton()} ) : ( <> {/* Favorites */} {showFavorites && (
{t("sidebar.favorites")}
{!collapsedSections.favorites && (isLoading ? renderTreeSkeleton() : favorites.map((doc) => ( { handleOpenFavorite(doc); onNavigate?.(); }} onCreateChildPage={() => void handleCreatePage(doc.id) } onCreateChildDatabase={() => void handleCreateDatabase(doc.id) } onRemoveFavorite={() => handleToggleFavorite(doc.id, false) } onDelete={() => void handleDelete(doc.id)} /> )))}
)} {renderWorkspaceNavigation()} {renderTrashSection()} )}
{renderSettingsNavButton()}
{/* Footer */}
{isCodeMode ? : null}
{/* Resize handle */} {onResize && (
)} {t("sidebar.removeLocalFilesQuestion")} {t("sidebar.removeLocalFilesDescription")} {t("comments.cancel")} { event.preventDefault(); void handleRemoveLocalFiles(); }} > {t("sidebar.removeLocalFilesFromSidebar")}
); }