import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { matchPath, useLocation, useMatch, useNavigate, } from 'react-router-dom'; import AssetManager, { getAssetFromVersion } from '../../asset_manager'; import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { api, useAppDispatch, useAppState } from '../../app/appState'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection'; import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions'; import useDocumentsPanelProps from '../../documents/hooks/useDocumentsPanelProps'; import useDocumentPreview from '../../app/useDocumentPreview'; import useDeskWorkspaceProps from '../../desktop/useDeskWorkspaceProps'; import useSidebarProps from '../../sidebar/useSidebarProps'; import { ASSET_PRESIGN_TTL_MS, DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, createRootNode, getRowId, isDocumentRowKey, isFolderRowKey, mergeAssetIntoDocument, resolveApiPath, resolveDocumentRowKey, resolveFolderRowKey, } from '../../app/appLayoutUtils'; import useDocumentsSearch from '../../app/useDocumentsSearch'; import useDocumentsStore from './store/useDocumentsStore'; import useAuthManager from './useAuthManager'; import useTags from './useTags'; import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; import useFolderTree from './useFolderTree'; import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; import useDocumentUploads from './useDocumentUploads'; import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../detail/useDetailWorkspace'; const EntryType = Object.freeze({ document: 'document', folder: 'folder', }); const noop = () => {}; const useDocumentsWorkspace = ({ documentsViewMode = 'list', documentsSortField = DEFAULT_SORT_FIELD, documentsSortDirection = DEFAULT_SORT_DIRECTION, documentsSortFieldRef, documentsSortDirectionRef, onDocumentsViewModeChange, onDocumentsSortFieldChange, onDocumentsSortDirectionToggle, searchIncludeDescendants = true, onToggleSearchIncludeDescendants, onSetSearchIncludeDescendants, sortRefreshReadyRef, handleDeskExit, } = {}) => { const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop; const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; const toggleSearchIncludeDescendants = onToggleSearchIncludeDescendants || noop; const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop; const handleDeskExitSafe = handleDeskExit || noop; const fallbackSortFieldRef = useRef(documentsSortField); const activeSortFieldRef = documentsSortFieldRef || fallbackSortFieldRef; useEffect(() => { activeSortFieldRef.current = documentsSortField; }, [documentsSortField, activeSortFieldRef]); const fallbackSortDirectionRef = useRef(documentsSortDirection); const activeSortDirectionRef = documentsSortDirectionRef || fallbackSortDirectionRef; useEffect(() => { activeSortDirectionRef.current = documentsSortDirection; }, [documentsSortDirection, activeSortDirectionRef]); const fallbackSortRefreshReadyRef = useRef(false); const activeSortRefreshReadyRef = sortRefreshReadyRef || fallbackSortRefreshReadyRef; const navigate = useNavigate(); const location = useLocation(); const appState = useAppState(); const appDispatch = useAppDispatch(); const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); const docMatch = matchPath('/documents/:documentId', location.pathname); const routeFolderId = folderMatch?.params?.folderId || null; const routeDocumentId = docMatch?.params?.documentId || null; const previewDocumentId = routeDocumentId; const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState; const tenantName = tenant?.name || tenant?.slug || null; const currentTenantId = tenant?.id || null; const { status, setStatusMessage } = useDocumentsStore(); const handleApiReport = useCallback( ({ message, variant }) => setStatusMessage(message, variant), [setStatusMessage], ); const reportApiError = useApiError({ onReport: handleApiReport, }); const notifyApiError = useCallback( (error, fallbackMessage, variant = 'error') => reportApiError(error, { message: fallbackMessage, variant }), [reportApiError], ); const [loading, setLoading] = useState(false); const [creatingFolder, setCreatingFolder] = useState(false); const { tokenRef, handleLogout } = useAuthManager({ apiClient: api, token, appStatus, appDispatch, notifyApiError, setStatusMessage, setLoading, }); const breadcrumbFetchRef = useRef(new Set()); const tagRemovalCursorActiveRef = useRef(false); const tenantIdRef = useRef(currentTenantId); const detailPanelControlRef = useRef({ open: () => {}, close: () => {} }); const setTagRemovalCursor = useCallback((active) => { if (typeof document === 'undefined') { return; } if (tagRemovalCursorActiveRef.current === active) { return; } const body = document.body; if (!body) { return; } tagRemovalCursorActiveRef.current = active; if (active) { body.classList.add('desk-cursor-remove'); } else { body.classList.remove('desk-cursor-remove'); } }, []); const documentsRouteMatch = useMatch('/documents'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); const documentsDetailRouteMatch = useMatch('/documents/:documentId'); const isDocumentsRoute = Boolean( documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, ); const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); const [draggedFolderId, setDraggedFolderId] = useState(null); const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const shellRef = useRef(null); const assetManagerRef = useRef(null); if (!assetManagerRef.current) { assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); } const assetManager = assetManagerRef.current; const extractDocumentFromResponse = useCallback( (payload) => { if (!payload) { return null; } const hydratedDetail = assetManager.hydrateDetail(payload); return hydratedDetail?.document || payload.document || payload; }, [assetManager], ); const tagManagerRef = useRef(null); if (!tagManagerRef.current) { tagManagerRef.current = new TagManager(); } const tagManager = tagManagerRef.current; const selection = useWorkspaceSelection({ resolveDocumentRowKey, resolveFolderRowKey, isDocumentRowKey, isFolderRowKey, getRowId, }); const { selectedEntries, selectedDocumentIds, selectedFolderIds, setSelectedEntries, selectionOrder, setSelectionOrder, selectionOrderRef, selectionAnchorRef, selectionInitializedRef, focusedDocumentId, setFocusedDocumentId, focusedRowKey, setFocusedRowKey, applySelection, clearSelection, handleEntrySelection, promoteSelectionOrder: promoteSelectionOrderRaw, configureSelectionEnvironment, } = selection; const selectionHelpers = useMemo( () => ({ setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, selectionInitializedRef, }), [ setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, selectionInitializedRef, ], ); const [folderContents, setFolderContents] = useState(() => new Map()); const folderContentsRef = useRef(folderContents); useEffect(() => { folderContentsRef.current = folderContents; }, [folderContents]); const setSearchResultsRef = useRef(() => {}); const setSearchResultsProxy = useCallback((value) => { if (typeof setSearchResultsRef.current === 'function') { setSearchResultsRef.current(value); } }, []); const { documents, setDocuments, mapDocumentCaches, updateDocumentCaches, } = useDocuments({ setSearchResults: setSearchResultsProxy, setFolderContents, }); const { folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, currentFolder, setCurrentFolder, currentSubfolders, setCurrentSubfolders, currentFolderName, folderOptions, folderLabelMap, applySelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', assetManager, apiClient: api, tenantIdRef, documentsSortFieldRef: activeSortFieldRef, documentsSortDirectionRef: activeSortDirectionRef, selectionHelpers, setDocuments, setFolderContents, folderContentsRef, }); const { searchQuery, setSearchQuery, searchResults, setSearchResults, searchLoading, activeTagFilters, setActiveTagFilters, activeCorrespondentFilters, setActiveCorrespondentFilters, toggleTagFilter, toggleCorrespondentFilter, isFilterActive, clearFilters, handleSearchChange, handleSearchSubmit, } = useDocumentsSearch({ api, assetManager, token, selectedFolder, navigate, locationPathname: location.pathname, isDocumentsRoute, selectionHelpers, searchIncludeDescendants, documentsSortField, documentsSortDirection, notifyApiError, setLoading, setSearchIncludeDescendants, }); useEffect(() => { setSearchResultsRef.current = setSearchResults; }, [setSearchResults]); const { previewEntries, previewDocuments, ensurePreviewData, openDocumentPreview, closeDocumentPreview, resetPreviewState, removePreviewEntries, } = useDocumentPreview({ routeDocumentId: previewDocumentId, documents, searchResults, selectedFolder, assetManager, api, resolveApiPath, notifyApiError, navigate, locationPathname: location.pathname, locationSearch: location.search, detailPanelControlRef, setActivePreviewId, }); const getDocumentAsset = useCallback((doc, type) => { if (!doc || !type) return null; return getAssetFromVersion(doc.current_version || null, type); }, []); const bootstrapInitializedRef = useRef(false); const detailFolderFetchRef = useRef(new Set()); const showingSearchResults = searchResults !== null; const visibleDocuments = useMemo( () => (showingSearchResults ? searchResults : documents), [showingSearchResults, searchResults, documents], ); const visibleDocumentIds = useMemo( () => visibleDocuments.map((doc) => doc.id), [visibleDocuments], ); const visibleDocumentKeys = useMemo( () => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean), [visibleDocumentIds], ); const visibleFolderKeys = useMemo( () => showingSearchResults ? [] : currentSubfolders .map((folder) => resolveFolderRowKey(folder.id)) .filter(Boolean), [showingSearchResults, currentSubfolders], ); const visibleRowKeys = useMemo( () => [...visibleFolderKeys, ...visibleDocumentKeys], [visibleFolderKeys, visibleDocumentKeys], ); const visibleRowKeySet = useMemo( () => new Set(visibleRowKeys), [visibleRowKeys], ); const documentLookup = useMemo(() => { const map = new Map(); const push = (items) => { (items || []).forEach((doc) => { if (doc?.id) { map.set(doc.id, doc); } }); }; push(documents); if (Array.isArray(searchResults)) { push(searchResults); } previewDocuments.forEach((doc, id) => { if (doc && id && !map.has(id)) { map.set(id, doc); } }); return map; }, [documents, searchResults, previewDocuments]); const { tags, refreshTags, handleTagCreate, handleTagUpdate, handleTagDelete, setTags, } = useTags({ apiClient: api, notifyApiError, setStatusMessage, tagManager, tenantIdRef, setActiveTagFilters, mapDocumentCaches, }); useEffect(() => { tenantIdRef.current = currentTenantId; }, [currentTenantId]); useEffect(() => { if (!selectedDocumentIds.length) { return; } if (!selectedDocumentIds.includes(activePreviewId)) { setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); } selectionInitializedRef.current = true; }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); const tagLookupById = useMemo(() => { const map = new Map(); tags.forEach((tag) => { if (tag?.id) { map.set(tag.id, tag); } }); return map; }, [tags]); const { correspondents, refreshCorrespondents, handleCorrespondentCreate, handleCorrespondentUpdate, handleCorrespondentDelete, setCorrespondents, } = useCorrespondents({ apiClient: api, notifyApiError, setStatusMessage, tenantIdRef, mapDocumentCaches, }); const { passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, } = usePasskeys({ api, notifyApiError, setStatusMessage, token, }); const resolveTargetDocumentIds = useCallback( (candidateIds) => { const normalized = Array.isArray(candidateIds) ? candidateIds.filter(Boolean) : []; if (normalized.length) { return Array.from(new Set(normalized)); } return selectedDocumentIds; }, [selectedDocumentIds], ); const refreshCurrentFolder = useCallback(async () => { setLoading(true); try { const contents = await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1, }); applySelectedFolder(selectedFolder, contents); } catch (error) { notifyApiError(error, 'Failed to refresh folder.'); } finally { setLoading(false); } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); const { handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkSelectionReanalyze, } = useDocumentTagging({ apiClient: api, tags, tagManager, refreshTags, refreshCurrentFolder, resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, }); const { dropOverlayState, handleFileDrop, handleFileSelection, uploadQueue, clearUploadQueue, resetUploadsState, } = useDocumentUploads({ apiClient: api, token, selectedFolder, currentFolderName, ensureFolderData, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading, shellRef, }); const { handleDocumentDragStart, handleDocumentDragEnd, handleFolderDragStart, handleFolderDragEnd, } = useDocumentDragHandlers({ selectedEntries, selectedDocumentIds, selectedFolderIds, applySelection, handleEntrySelection, documentLookup, setDraggedDocumentIds, setDraggedFolderId, resolveDocumentRowKey, resolveFolderRowKey, documentsViewMode, }); const { correspondentLookupByName, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, } = useDocumentCorrespondentActions({ apiClient: api, correspondents, handleCorrespondentCreate, refreshCurrentFolder, notifyApiError, setStatusMessage, }); useEffect(() => { if (!activeSortRefreshReadyRef.current) { activeSortRefreshReadyRef.current = true; return; } if (!isFilterActive && token) { refreshCurrentFolder(); } }, [ documentsSortField, documentsSortDirection, isFilterActive, refreshCurrentFolder, token, activeSortRefreshReadyRef, ]); const resetWorkspaceState = useCallback(() => { const rootNode = createRootNode(); setFolderNodes(new Map([[rootNode.id, rootNode]])); setFolderContents(new Map()); setSelectedFolder('root'); setCurrentFolder(null); setCurrentSubfolders([]); setDocuments([]); setSelectedEntries([]); setSelectionOrder([]); selectionOrderRef.current = []; setFocusedDocumentId(null); selectionAnchorRef.current = null; setDraggedDocumentIds([]); setDraggedFolderId(null); setSearchResults(null); setTags([]); setCorrespondents([]); setSearchQuery(''); setActiveTagFilters([]); setActiveCorrespondentFilters([]); setActivePreviewId(null); detailPanelControlRef.current.close(); assetManager.reset(); resetPreviewState(); resetUploadsState(); clearUploadQueue(); breadcrumbFetchRef.current = new Set(); detailFolderFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; tenantIdRef.current = null; }, [ assetManager, selectionAnchorRef, selectionInitializedRef, selectionOrderRef, setFocusedDocumentId, setSelectedEntries, setSelectionOrder, setFolderNodes, setFolderContents, setSelectedFolder, setCurrentFolder, setCurrentSubfolders, setDocuments, setDraggedDocumentIds, setDraggedFolderId, setSearchResults, setTags, setCorrespondents, setSearchQuery, setActiveTagFilters, setActiveCorrespondentFilters, setActivePreviewId, resetPreviewState, resetUploadsState, clearUploadQueue, ]); useEffect(() => { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { resetWorkspaceState(); } }, [appStatus, resetWorkspaceState]); const removeDocumentsFromCaches = useCallback( (documentIds) => { if (!documentIds || documentIds.length === 0) { return; } const idSet = new Set(documentIds); setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id))); setSearchResults((prev) => { if (!Array.isArray(prev)) { return prev; } const filtered = prev.filter((doc) => !idSet.has(doc.id)); return filtered.length === prev.length ? prev : filtered; }); setFolderContents((prev) => { if (!prev.size) { return prev; } let changed = false; const next = new Map(); prev.forEach((contents, key) => { const docs = Array.isArray(contents?.documents) ? contents.documents : null; if (!docs || docs.length === 0) { next.set(key, contents); return; } const filtered = docs.filter((doc) => !idSet.has(doc.id)); if (filtered.length !== docs.length) { changed = true; next.set(key, { ...contents, documents: filtered }); } else { next.set(key, contents); } }); return changed ? next : prev; }); removePreviewEntries(Array.from(idSet)); }, [setDocuments, setSearchResults, setFolderContents, removePreviewEntries], ); const { moveDocumentsToFolder, handleThumbnailRegeneration, handleDocumentsDelete, handleDocumentTagAdd, handleDocumentTagAttach, handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleTagRemove, } = useDocumentMutations({ api, token, documentLookup, folderLabelMap, ensureFolderData, selectedFolder, setSelectedFolder, setDocuments, setFolderContents, setSearchResults, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, setFocusedRowKey, focusedRowKey, notifyApiError, setStatusMessage, setLoading, mapDocumentCaches, applySelectedFolder, folderNodes, setFolderNodes, removeDocumentsFromCaches, closeDocumentPreview, previewDocumentId, refreshCurrentFolder, documentsViewMode, updateDocumentCaches, tagLookupById, tags, refreshTags, tagManager, extractDocumentFromResponse, }); const { loadFolder, selectFolder, handleFolderRename, handleFolderCreate, handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ api, token, folderNodes, setFolderNodes, selectedFolder, setSelectedFolder, ensureFolderData, ensureFolderAncestorsLoaded, expandFolderAncestors, applySelectedFolder, notifyApiError, setStatusMessage, setLoading, setFolderContents, setCurrentFolder, setSearchResults, isFilterActive, navigate, handleFileDrop, moveDocumentsToFolder, draggedDocumentIds, draggedFolderId, setDraggedDocumentIds, setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, }); const { promoteSelectionOrder, clearDocumentSelection, } = useDocumentsSelection({ showingSearchResults, currentSubfolders, visibleDocuments, resolveFolderRowKey, resolveDocumentRowKey, configureSelectionEnvironment, visibleRowKeySet, selectedEntries, selectionAnchorRef, promoteSelectionOrderRaw, setFocusedDocumentId, setActivePreviewId, clearSelection, focusedDocumentId, setFocusedRowKey, focusedRowKey, isFolderRowKey, }); const initializeAfterLogin = useCallback(async () => { setLoading(true); try { await Promise.all([refreshTags(), refreshCorrespondents()]); const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; await loadFolder(initialFolder, { showLoading: false }); } catch (error) { notifyApiError(error, 'Failed to initialize data.'); throw error; } finally { setLoading(false); } }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]); useEffect(() => { if (!token) { return; } if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { return; } const targetParam = routeFolderId ?? 'root'; if (targetParam === 'root' && routeDocumentId) { return; } const hasData = folderContents.has(targetParam); if (targetParam !== selectedFolder || !hasData) { selectFolder(targetParam, { immediate: true }); } }, [ token, appStatus, routeFolderId, routeDocumentId, selectedFolder, folderContents, isFilterActive, selectFolder, ]); useEffect(() => { if (appStatus !== 'authenticated') { return; } if (bootstrapInitializedRef.current) { return; } let cancelled = false; const bootstrap = async () => { bootstrapInitializedRef.current = true; appDispatch({ type: 'BOOTSTRAP_START' }); try { await initializeAfterLogin(); if (!cancelled) { appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); } } catch (error) { if (!cancelled) { appDispatch({ type: 'BOOTSTRAP_FAILURE', error: error?.message || 'Failed to initialize data.', }); bootstrapInitializedRef.current = false; } } }; bootstrap(); return () => { cancelled = true; }; }, [appStatus, appDispatch, initializeAfterLogin]); const { handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleDeleteSelection, } = useBulkDocumentActions({ api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, refreshCurrentFolder, setStatusMessage, selectedDocumentIds, selectedFolderIds, handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, setLoading, }); const ensureAssetUrl = useCallback( async (documentId, asset, { force = false, start = null, limit = null } = {}) => { if (!documentId || !asset?.id) { return null; } try { const entry = await assetManager.ensureAsset(documentId, asset, { force, start, limit, }); if (!entry) { return null; } setDocuments((prev) => prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)), ); setSearchResults((prev) => Array.isArray(prev) ? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)) : prev, ); return entry; } catch (error) { notifyApiError(error, 'Unable to refresh document asset.'); throw error; } }, [assetManager, setDocuments, setSearchResults, notifyApiError], ); const handleDocumentTagDrop = useCallback( async (documentId, tag) => { if (!documentId || !tag?.id) { return; } if (tag.sourceDocId && tag.sourceDocId === documentId) { return; } const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); if (!attached) { return; } if (tag.sourceDocId && tag.sourceDocId !== documentId) { await handleTagRemove(tag.sourceDocId, tag.id, { refreshTagList: false, showMessage: false, }); } }, [handleDocumentTagAttach, handleTagRemove], ); const handlePromptCreateFolder = useCallback(async () => { if (creatingFolder) { return; } const input = window.prompt('New folder name'); if (!input) { return; } const trimmed = input.trim(); if (!trimmed) { setStatusMessage('Folder name cannot be empty.', 'error'); return; } setCreatingFolder(true); try { const success = await handleFolderCreate(trimmed); if (!success) { setStatusMessage('Unable to create folder. Check the status message for details.', 'error'); } } finally { setCreatingFolder(false); } }, [creatingFolder, handleFolderCreate, setStatusMessage]); const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({ locationPathname: location.pathname, tags, refreshTags, onTagCreate: handleTagCreate, onTagUpdate: handleTagUpdate, onTagDelete: handleTagDelete, correspondents, refreshCorrespondents, onCorrespondentCreate: handleCorrespondentCreate, onCorrespondentUpdate: handleCorrespondentUpdate, onCorrespondentDelete: handleCorrespondentDelete, setStatusMessage, }); const [settingsOpen, setSettingsOpen] = useState(false); const openSettings = useCallback(() => { setSettingsOpen(true); }, []); const closeSettings = useCallback(() => { setSettingsOpen(false); }, []); useEffect(() => { if (!settingsOpen) { return undefined; } const handleKeyDown = (event) => { if (event.key === 'Escape') { event.preventDefault(); setSettingsOpen(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [settingsOpen]); useEffect( () => () => { setTagRemovalCursor(false); }, [setTagRemovalCursor], ); useEffect(() => { const host = shellRef.current; if (!host) { return undefined; } const isTagTransfer = (event) => isTagTransferEvent(event); const isDocumentDropTarget = (target) => target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; const handleTagDragOver = (event) => { if (!isTagTransfer(event)) { return; } if (isDocumentDropTarget(event.target)) { setTagRemovalCursor(false); return; } event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setTagRemovalCursor(true); }; const handleTagDragLeave = (event) => { if (!isTagTransfer(event)) { return; } const related = event.relatedTarget; if (related instanceof Element && host.contains(related)) { if (isDocumentDropTarget(related)) { setTagRemovalCursor(false); } return; } setTagRemovalCursor(false); }; const handleTagDrop = async (event) => { if (!isTagTransfer(event)) { return; } setTagRemovalCursor(false); if (isDocumentDropTarget(event.target) || event.defaultPrevented) { return; } event.preventDefault(); event.stopPropagation(); const raw = event.dataTransfer.getData('application/x-papercrate-tag') || event.dataTransfer.getData('text/papercrate-tag'); if (!raw) { return; } try { const payload = JSON.parse(raw); if (payload?.sourceDocId && payload?.id) { await handleTagRemove(payload.sourceDocId, payload.id, { refreshTagList: false, showMessage: true, }); } } catch (error) { console.warn('Failed to remove tag from drop target', error); } }; const handleTagDragEnd = () => { setTagRemovalCursor(false); }; host.addEventListener('dragover', handleTagDragOver, true); host.addEventListener('dragleave', handleTagDragLeave, true); host.addEventListener('drop', handleTagDrop, true); window.addEventListener('dragend', handleTagDragEnd, true); return () => { host.removeEventListener('dragover', handleTagDragOver, true); host.removeEventListener('dragleave', handleTagDragLeave, true); host.removeEventListener('drop', handleTagDrop, true); window.removeEventListener('dragend', handleTagDragEnd, true); setTagRemovalCursor(false); }; }, [handleTagRemove, setTagRemovalCursor]); const { detailPanelProps, detailPanelOpen, openDetailPanel, handleDetailPanelClose, inspectDocument, previewActive, previewWorkspaceDocument, previewWorkspaceEntry, resolveThumbnailUrlForDoc, resolveFolderPath, } = useDetailWorkspace({ documents, searchResults, previewDocuments, focusedDocumentId, selectionOrder, selectedDocumentIds, documentLookup, folderNodes, ensureFolderData, detailPanelControlRef, detailFolderFetchRef, previewEntries, previewDocumentId, activePreviewId, openDocumentPreview, promoteSelectionOrder, handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleDocumentTagAdd, handleTagRemove, ensureAssetUrl, getDocumentAsset, ensurePreviewData, correspondents, handleCorrespondentAdd, handleCorrespondentRemove, resolveApiPath, selectFolder, tags, tagLookupById, }); const handleEntryPointerCore = useEntryPointerCore({ resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => { const { type, id } = entry; const key = rowKey || (type === EntryType.document ? resolveDocumentRowKey(id) : resolveFolderRowKey(id)); if (key) { handleEntrySelection(key, event); } if (type === EntryType.folder && !modifierClick && primaryClick) { selectFolder(id); } }, }); const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const chain = []; const seen = new Set(); const pending = new Set(); let currentId = selectedFolder || 'root'; let guard = 0; while (currentId && !seen.has(currentId) && guard < 32) { guard += 1; seen.add(currentId); if (currentId === 'root') { chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); currentId = null; break; } const node = folderNodes.get(currentId); if (node) { chain.push({ id: currentId, name: node.name || 'Folder' }); currentId = node.parentId ?? 'root'; continue; } let fallbackName = '…'; let parentId = null; if (currentFolder && currentFolder.id === currentId) { fallbackName = currentFolder.name; parentId = currentFolder.parent_id ?? 'root'; } chain.push({ id: currentId, name: fallbackName }); pending.add(currentId); currentId = parentId; } if (!chain.some((crumb) => crumb.id === 'root')) { chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); } const ordered = []; const seenOrdered = new Set(); chain .slice() .reverse() .forEach((crumb) => { if (!seenOrdered.has(crumb.id)) { seenOrdered.add(crumb.id); ordered.push(crumb); } }); return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) }; }, [selectedFolder, folderNodes, currentFolder]); useEffect(() => { if (!missingBreadcrumbAncestors.length) { return; } missingBreadcrumbAncestors.forEach((folderId) => { if (!folderId || folderId === 'root') { return; } if (breadcrumbFetchRef.current.has(folderId)) { return; } breadcrumbFetchRef.current.add(folderId); ensureFolderData(folderId, { force: false }) .catch((error) => { console.warn('Failed to preload breadcrumb ancestor', folderId, error); }) .finally(() => { breadcrumbFetchRef.current.delete(folderId); }); }); }, [missingBreadcrumbAncestors, ensureFolderData]); const { handleTenantSelect } = useTenantManager({ apiClient: api, appDispatch, currentTenantId, resetWorkspaceState, setStatusMessage, notifyApiError, setLoading, refreshTags, refreshCorrespondents, loadFolder, handleDocumentsViewModeChange, navigate, tokenRef, tenantIdRef, }); const documentsTableProps = useDocumentsPanelProps({ currentFolderName, breadcrumbs, refreshCurrentFolder, currentSubfolders, documents, searchResults, isFilterActive, folderClickHandlers, handleFolderDragStart, handleFolderDragEnd, draggedFolderId, handleFolderRename, openDocumentPreview, handleDocumentTitleUpdate, selectedDocumentIds, selectedFolderIds, focusedRowKey, draggedDocumentIds, handleDocumentDragStart, handleDocumentDragEnd, searchLoading, tagLookupById, activeCorrespondentFilters, selectedEntries, setFocusedRowKey, ensureAssetUrl, getDocumentAsset, toggleTagFilter, toggleCorrespondentFilter, handleDocumentTagDrop, documentsViewMode, documentsSortField, documentsSortDirection, handleDocumentsSortFieldChange, handleDocumentsSortDirectionToggle, searchIncludeDescendants, toggleSearchIncludeDescendants, handleDocumentsViewModeChange, clearDocumentSelection, handleDeleteSelection, handleEntryPointerCore, inspectDocument, handleEntrySelection, tags, correspondents, documentLookup, handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, selectFolder, }); const sidebarProps = useSidebarProps({ folderNodes, folderClickHandlers, handleFolderDelete, handleFolderRename, selectedFolder, handleFolderDragStart, handleFolderDragEnd, draggedFolderId, handlePromptCreateFolder, creatingFolder, tags, activeTagFilters, toggleTagFilter, handleTagCreate, correspondents, activeCorrespondentFilters, toggleCorrespondentFilter, handleCorrespondentCreate, appStatus, loading, previewActive, searchQuery, handleSearchChange, handleSearchSubmit, clearFilters, isFilterActive, handleLogout, status, tenantName, tenantOptions, currentTenantId, handleTenantSelect, openSettings, handleFileSelection, uploadQueue, }); const deskWorkspaceProps = useDeskWorkspaceProps({ documents, searchResults, breadcrumbs, currentFolderName, documentsViewMode, handleDocumentsViewModeChange, handleDeskExit: handleDeskExitSafe, refreshCurrentFolder, inspectDocument, handleEntryPointerCore, promoteSelectionOrder, currentTenantId, selectedDocumentIds, selectedFolderIds, clearDocumentSelection, detailPanelOpen, handleDetailPanelClose, resolveThumbnailUrlForDoc, handleDocumentTagDrop, handleTagRemove, ensureAssetUrl, getDocumentAsset, activeTagFilters, handleDeleteSelection, tags, correspondents, documentLookup, tagLookupById, handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, handleBulkCorrespondentAdd, handleBulkCorrespondentRemove, handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, searchIncludeDescendants, toggleSearchIncludeDescendants, selectedEntries, selectionAnchorRef, applySelection, resolveDocumentRowKey, showingSearchResults, searchQuery, activeCorrespondentFilters, selectedFolder, openDetailPanel, }); const contextValue = useMemo( () => ({ token, appStatus, status, setStatusMessage, dropOverlayState, handleLogout, sidebarProps, tags, refreshTags, handleTagUpdate, handleTagDelete, handleDocumentTagDrop, correspondents, refreshCorrespondents, handleCorrespondentUpdate, handleCorrespondentCreate, handleCorrespondentDelete, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, previewActive, previewWorkspaceDocument, previewWorkspaceEntry, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, documentsTableProps, detailPanelProps, documentsViewMode, deskWorkspaceProps, ensurePreviewData, ensureAssetUrl, resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, openCorrespondentsModal, openSettings, detailPanelOpen, openDetailPanel, uploadQueue, clearUploadQueue, }), [ token, appStatus, status, setStatusMessage, dropOverlayState, handleLogout, sidebarProps, tags, refreshTags, handleTagUpdate, handleTagDelete, handleDocumentTagDrop, correspondents, refreshCorrespondents, handleCorrespondentUpdate, handleCorrespondentCreate, handleCorrespondentDelete, handleDocumentCorrespondentAttach, handleCorrespondentRemove, handleCorrespondentAdd, passkeys, passkeysSupported, passkeysLoading, registeringPasskey, revokingPasskeyId, refreshPasskeys, registerPasskey, revokePasskey, previewActive, previewWorkspaceDocument, previewWorkspaceEntry, previewDocumentId, closeDocumentPreview, handleThumbnailRegeneration, documentsTableProps, detailPanelProps, documentsViewMode, deskWorkspaceProps, ensurePreviewData, ensureAssetUrl, resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, openCorrespondentsModal, openSettings, detailPanelOpen, openDetailPanel, uploadQueue, clearUploadQueue, ], ); // hook callers handle rendering / routing return { appStatus, location, shellRef, dropOverlayState, managementModals, contextValue, settingsOpen, closeSettings, }; }; export default useDocumentsWorkspace;