feat: Add document move and tag mutations, refine document upload logic

This commit is contained in:
2025-12-08 16:27:13 +01:00
parent 5632d2252a
commit 3a0ef7867e
8 changed files with 709 additions and 695 deletions
@@ -0,0 +1,177 @@
import { useCallback } from 'react';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import useNotifyApiError from '../../hooks/useNotifyApiError';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import { getEntryId, isDocumentEntry } from '../../app/entryKey';
import {
moveDocumentsBulk,
moveDocumentToFolder,
listFolderContents,
} from '../../lib/api/apiClient';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import type {
DocumentsState,
FolderState,
SelectionState,
} from '../types/workspaceTypes';
type FolderId = FolderIdentifier | 'root';
type NullableFolderId = FolderId | null;
interface UseDocumentMoveMutationsArgs {
documentsState: DocumentsState;
folderState: FolderState;
selectionState: SelectionState;
}
export const useDocumentMoveMutations = ({
documentsState,
folderState,
selectionState,
}: UseDocumentMoveMutationsArgs) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const normalizeDocumentId = (value: unknown): DocumentId | null => {
if (!value) return null;
if (value && typeof value === 'object' && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value as DocumentId;
};
const moveDocumentsToFolder = useCallback(
async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => {
const uniqueIds = Array.from(
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
);
if (!uniqueIds.length) return;
const uniqueIdSet = new Set(uniqueIds);
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel =
target === null ? DEFAULT_FOLDER_NAME : folderState.folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
const movedDocs = uniqueIds
.map((id) => {
const doc = documentsState.documentLookup.get(id) || null;
if (!doc) {
return null;
}
return {
id,
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
document: doc,
};
})
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>;
const updatedDocsMap = new Map<DocumentId, Document>();
const resolveTargetName = () => {
if (!targetLabel) {
return null;
}
const segments = String(targetLabel).split('/');
return segments[segments.length - 1] || targetLabel;
};
const targetName = resolveTargetName();
movedDocs.forEach(({ id, document }) => {
if (!document) {
return;
}
const updated: Document = {
...document,
folder_id: target,
};
if (targetLabel) {
updated.folder_path = targetLabel;
if (targetName) {
updated.folder_name = targetName;
}
} else if (target === null) {
updated.folder_path = DEFAULT_FOLDER_NAME;
updated.folder_name = DEFAULT_FOLDER_NAME;
}
updatedDocsMap.set(id, updated);
});
try {
if (uniqueIds.length === 1) {
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await moveDocumentsBulk(uniqueIds, target);
}
const count = uniqueIds.length;
const suffix = count === 1 ? '' : 's';
showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
if (updatedDocsMap.size) {
documentsState.mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) {
return updated;
}
return doc;
});
}
if (uniqueIdSet.size) {
const pruneRow = (rows: string[]) => rows.filter(id => !uniqueIdSet.has(getEntryId(id) as DocumentId));
const { selectionOrderRef, selectionAnchorRef, setSelectionOrder, setFocusedDocumentId, setFocusedEntryKey, setSelectedEntries } = selectionState;
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentEntry(selectionAnchorRef.current) &&
uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
if (
selectionState.focusedDocumentId &&
uniqueIdSet.has(selectionState.focusedDocumentId)
) {
setFocusedDocumentId(null);
}
if (
selectionState.focusedEntryKey &&
isDocumentEntry(selectionState.focusedEntryKey) &&
uniqueIdSet.has(getEntryId(selectionState.focusedEntryKey) as DocumentId)
) {
setFocusedEntryKey(null);
}
}
if (targetFolderId && targetFolderId !== folderState.selectedFolder) {
await listFolderContents(targetFolderId as FolderId);
}
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
}
},
[
documentsState,
folderState.folderLabelMap,
folderState.selectedFolder,
selectionState,
notifyApiError,
showToast,
],
);
return { moveDocumentsToFolder };
};
@@ -1,71 +1,27 @@
import { useCallback } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import { getEntryId, isDocumentEntry } from '../../app/entryKey';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
deleteFolder,
moveDocumentsBulk,
moveDocumentToFolder,
queueDocumentReanalysis,
trashDocument,
updateDocument,
} from '../../lib/api/apiClient';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
import type { Document, MessageOptions } from '../../types/documents';
import { useDocumentTagMutations } from './useDocumentTagMutations';
import { useDocumentMoveMutations } from './useDocumentMoveMutations';
import type {
DocumentsState,
FolderState,
SelectionState,
TagsState,
ActionsState,
Tag,
} from '../types/workspaceTypes';
type FolderId = FolderIdentifier | 'root';
type NullableFolderId = FolderId | null;
type DocumentCacheMapper = (
doc: Document | null,
) => Document | null;
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
type UpdateDocumentCaches = (
documentId: DocumentId,
updater: DocumentCacheMapper,
) => void;
type EnsureFolderData = (
folderId: FolderId,
options?: { includeDocuments?: boolean },
) => Promise<FolderContents>;
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void;
interface Tag {
id: DocumentId;
label: string;
color?: string | null;
[key: string]: unknown;
}
interface FolderContents {
documents?: Document[];
subfolders?: Array<{ id?: FolderId;[key: string]: unknown }>;
[key: string]: unknown;
}
interface FolderNode {
id: FolderId;
parentId?: FolderId;
children: FolderId[];
hasChildren?: boolean;
[key: string]: unknown;
}
interface TagManager {
normalizeLabel: (label: string) => string;
buildPayload: (args: { label: string }) => Record<string, unknown>;
}
interface DocumentTagExtras {
option?: Tag | null;
@@ -75,35 +31,12 @@ interface DocumentTagExtras {
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseDocumentMutationsArgs {
documentLookup: Map<DocumentId, Document>;
folderLabelMap: Map<FolderId, string>;
ensureFolderData: EnsureFolderData;
selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
focusedDocumentId: DocumentId | null;
setFocusedEntryKey: Dispatch<SetStateAction<string | null>>;
focusedEntryKey: string | null;
mapDocumentCaches: MapDocumentCaches;
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
closeDocumentPreview: CloseDocumentPreview;
documentsState: DocumentsState;
folderState: FolderState;
selectionState: SelectionState;
tagsState: TagsState;
actions: ActionsState;
previewDocumentId?: DocumentId | null;
refreshCurrentFolder: () => Promise<void>;
updateDocumentCaches: UpdateDocumentCaches;
tagLookupById: Map<DocumentId, Tag>;
tags: Tag[];
refreshTags: () => Promise<void>;
tagManager: TagManager;
extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
}
interface UseDocumentMutationsResult {
@@ -131,242 +64,69 @@ interface UseDocumentMutationsResult {
documentId?: DocumentId,
tagId?: DocumentId,
) => Promise<boolean>;
handleFolderDelete: (folderId?: FolderId, options?: MessageOptions) => Promise<boolean>;
}
const normalizeDocumentId = (value: unknown): DocumentId | null => {
if (!value) return null;
if (value && typeof value === 'object' && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value as DocumentId;
};
const useDocumentMutations = ({
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSelectedFolder,
setDocuments,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
mapDocumentCaches,
folderNodes,
setFolderNodes,
removeDocumentsFromCaches,
closeDocumentPreview,
documentsState,
folderState,
selectionState,
tagsState,
actions,
previewDocumentId,
refreshCurrentFolder,
updateDocumentCaches,
tagLookupById,
tags,
refreshTags,
tagManager,
extractDocumentFromResponse,
ingestDocuments,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const moveDocumentsToFolder = useCallback(
async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => {
const uniqueIds = Array.from(
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
);
if (!uniqueIds.length) return;
const { moveDocumentsToFolder } = useDocumentMoveMutations({
documentsState,
folderState,
selectionState,
});
const uniqueIdSet = new Set(uniqueIds);
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel =
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
const movedDocs = uniqueIds
.map((id) => {
const doc = documentLookup.get(id) || null;
if (!doc) {
return null;
}
return {
id,
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
document: doc,
};
})
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>;
const updatedDocsMap = new Map<DocumentId, Document>();
const resolveTargetName = () => {
if (!targetLabel) {
return null;
}
const segments = String(targetLabel).split('/');
return segments[segments.length - 1] || targetLabel;
};
const targetName = resolveTargetName();
movedDocs.forEach(({ id, document }) => {
if (!document) {
return;
}
const updated: Document = {
...document,
folder_id: target,
};
if (targetLabel) {
updated.folder_path = targetLabel;
if (targetName) {
updated.folder_name = targetName;
}
} else if (target === null) {
updated.folder_path = DEFAULT_FOLDER_NAME;
updated.folder_name = DEFAULT_FOLDER_NAME;
}
updatedDocsMap.set(id, updated);
});
const pruneRow = (collection: string[]): string[] =>
collection.filter((key) => {
if (!isDocumentEntry(key)) {
return true;
}
const id = getEntryId(key);
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
try {
if (uniqueIds.length === 1) {
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await moveDocumentsBulk(uniqueIds, target);
}
const count = uniqueIds.length;
const suffix = count === 1 ? '' : 's';
showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
if (updatedDocsMap.size) {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) {
return updated;
}
return { ...doc, folder_id: target };
});
} else {
mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc;
}
return { ...doc, folder_id: target };
});
}
if (uniqueIdSet.size) {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
// setFolderContents removed as we don't hold full cache anymore
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentEntry(selectionAnchorRef.current) &&
uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
setFocusedDocumentId(null);
}
if (
focusedEntryKey &&
isDocumentEntry(focusedEntryKey) &&
uniqueIdSet.has(getEntryId(focusedEntryKey) as DocumentId)
) {
setFocusedEntryKey(null);
}
}
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId as FolderId);
}
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
}
},
[
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResultIds,
setDocuments,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
showToast,
mapDocumentCaches,
],
);
const {
handleDocumentTagAdd,
handleDocumentTagAttach,
handleDocumentTagDetach,
} = useDocumentTagMutations({
tagsState,
documentsState: { updateDocumentCaches: documentsState.updateDocumentCaches },
});
const handleThumbnailRegeneration = useCallback(
async (documentId: DocumentId) => {
try {
await queueDocumentReanalysis(documentId, { force: true });
showToast('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
await queueDocumentReanalysis(documentId);
showToast('Analysis queued.', 'info');
// Close preview if it's the current one to allow refresh?
if (previewDocumentId === documentId) {
actions.closeDocumentPreview();
}
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
notifyApiError(error, message);
notifyApiError(error, 'Failed to queue analysis.');
}
},
[refreshCurrentFolder, notifyApiError, showToast],
[actions, notifyApiError, previewDocumentId, showToast],
);
const handleDocumentsDelete = useCallback(
async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
if (!documentIds?.length) return false;
// Optimistic update could happen here but usually we wait for standardized confirmation
// However workspace expects mutation here.
try {
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
// We use trashDocument for soft delete
// If we want hard delete we need deleteDocument
// Assuming trash for now as it makes sense for "Delete" action in UI unless specified
await Promise.all(documentIds.map((id) => trashDocument(id)));
removeDocumentsFromCaches(documentIds);
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview();
}
// Remove from local state
documentsState.removeDocumentsFromCaches(documentIds);
if (showMessage) {
const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
showToast(message, 'success');
const count = documentIds.length;
const suffix = count === 1 ? '' : 's';
showToast(`${count} document${suffix} deleted.`, 'success');
}
return true;
} catch (error) {
@@ -376,9 +136,7 @@ const useDocumentMutations = ({
}
},
[
removeDocumentsFromCaches,
previewDocumentId,
closeDocumentPreview,
documentsState,
notifyApiError,
showToast,
],
@@ -393,12 +151,12 @@ const useDocumentMutations = ({
}
try {
const data = await updateDocument(documentId, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
const updatedDocument = documentsState.extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
if (updatedDocument && documentsState.ingestDocuments) {
documentsState.ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
documentsState.updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
@@ -415,11 +173,9 @@ const useDocumentMutations = ({
}
},
[
extractDocumentFromResponse,
ingestDocuments,
documentsState,
notifyApiError,
showToast,
updateDocumentCaches,
],
);
@@ -428,12 +184,12 @@ const useDocumentMutations = ({
const payload = { issued_at: nextIssuedDate || null };
try {
const data = await updateDocument(documentId, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
const updatedDocument = documentsState.extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
if (updatedDocument && documentsState.ingestDocuments) {
documentsState.ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
documentsState.updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
@@ -451,242 +207,14 @@ const useDocumentMutations = ({
}
},
[
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
showToast,
updateDocumentCaches,
],
);
const attachTagToDocument = useCallback(
async ({
documentId,
tag,
}: {
documentId?: DocumentId;
tag?: Tag | null;
}) => {
if (!documentId || !tag?.id) {
return false;
}
const cachedTag: Tag = {
id: tag.id,
label: tag.label,
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
};
try {
await addDocumentTags(documentId, [cachedTag.id]);
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
return doc;
}
return { ...doc, tags: [...currentTags, cachedTag] };
});
showToast('Tag assigned.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
return false;
}
},
[notifyApiError, showToast, updateDocumentCaches],
);
const handleDocumentTagAdd = useCallback(
async (document: Document, label: string, extras: DocumentTagExtras | null = null) => {
const normalizedLabel = tagManager.normalizeLabel(label);
const optionCandidate = extras?.option ?? null;
const input = extras?.input ?? null;
let tag: Tag | null = null;
if (optionCandidate && optionCandidate.id) {
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
}
if (!tag) {
tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
}
try {
if (!tag) {
const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await refreshTags();
}
await attachTagToDocument({
documentId: document.id as DocumentId,
tag,
});
if (input && Object(input) === input && 'value' in (input as Record<string, unknown>)) {
(input as { value?: string }).value = '';
}
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
},
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
);
const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId) => {
if (!documentId || !tagId) {
return false;
}
const resolveTagForCache = (): Tag | null => {
const lookupTag = tagLookupById.get(tagId);
if (!lookupTag || lookupTag.id == null) {
return null;
}
const labelText = `${lookupTag.label ?? ''} `.trim();
if (!labelText) {
return null;
}
return {
id: lookupTag.id,
label: labelText,
color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null,
};
};
const resolvedTag = resolveTagForCache();
return attachTagToDocument({
documentId,
tag: resolvedTag,
});
},
[
attachTagToDocument,
tagLookupById,
],
);
const applyTagRemovalToCaches = useCallback(
(documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) {
return;
}
updateDocumentCaches(documentId, (doc) => {
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
return { ...doc, tags: nextTags };
});
},
[updateDocumentCaches],
);
const handleDocumentTagDetach = useCallback(
async (
documentId?: DocumentId,
tagId?: DocumentId,
) => {
if (!documentId || !tagId) {
return false;
}
try {
await deleteDocumentTag(documentId, tagId);
applyTagRemovalToCaches(documentId, tagId);
showToast('Tag removed.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
return false;
}
},
[applyTagRemovalToCaches, notifyApiError, showToast],
);
const handleFolderDelete = useCallback(
async (folderId?: FolderId, { showMessage = true }: MessageOptions = {}) => {
if (!folderId || folderId === 'root') {
if (showMessage) {
showToast('The root folder cannot be removed.', 'error');
}
return false;
}
try {
const contents = await ensureFolderData(folderId);
const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) {
if (showMessage) {
showToast('Folder must be empty before it can be deleted.', 'error');
}
return false;
}
await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(prev);
const node = next.get(folderId);
next.delete(folderId);
if (node) {
const parentId = node.parentId || 'root';
const parentNode = next.get(parentId);
if (parentNode) {
const remaining = parentNode.children.filter((id) => id !== folderId);
next.set(parentId, {
...parentNode,
children: remaining,
hasChildren: remaining.length > 0,
});
}
}
});
// setFolderContents removed
if (selectedFolder === folderId) {
const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root';
setSelectedFolder(parentId);
// ensureFolderData(parentId) will be called by useDocumentsWorkspace effect when selectedFolder changes
} else if (selectedFolder !== 'root') {
// If deleted folder was not selected, just check if we need to refresh (maybe redundant)
await ensureFolderData(selectedFolder);
}
if (showMessage) {
showToast('Folder deleted.', 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
showToast(message, 'error');
}
return false;
}
},
[
ensureFolderData,
selectedFolder,
folderNodes,
setSelectedFolder,
setFolderNodes,
documentsState,
notifyApiError,
showToast,
],
);
// handleFolderDelete is removed from here
return {
moveDocumentsToFolder,
handleThumbnailRegeneration,
@@ -696,7 +224,6 @@ const useDocumentMutations = ({
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleDocumentTagDetach,
handleFolderDelete,
};
};
@@ -0,0 +1,178 @@
import { useCallback } from 'react';
import type { DocumentId } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
} from '../../lib/api/apiClient';
import useNotifyApiError from '../../hooks/useNotifyApiError';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import type {
TagsState,
DocumentsState,
Tag,
} from '../types/workspaceTypes';
interface DocumentTagExtras {
option?: Tag | null;
input?: { value?: string } | null;
}
interface UseDocumentTagMutationsArgs {
tagsState: TagsState;
documentsState: Pick<DocumentsState, 'updateDocumentCaches'>;
}
export const useDocumentTagMutations = ({
tagsState,
documentsState,
}: UseDocumentTagMutationsArgs) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const attachTagToDocument = useCallback(
async ({
documentId,
tag,
}: {
documentId?: DocumentId;
tag?: Tag | null;
}) => {
if (!documentId || !tag?.id) {
return false;
}
const cachedTag: Tag = {
id: tag.id,
label: tag.label,
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
};
try {
await addDocumentTags(documentId, [cachedTag.id]);
documentsState.updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
return doc;
}
return { ...doc, tags: [...currentTags, cachedTag] };
});
showToast('Tag assigned.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
return false;
}
},
[notifyApiError, showToast, documentsState],
);
const handleDocumentTagAdd = useCallback(
async (document: Document, label: string, extras?: DocumentTagExtras | null) => {
const normalizedLabel = tagsState.tagManager.normalizeLabel(label);
const optionCandidate = extras?.option ?? null;
const input = extras?.input ?? null;
let tag: Tag | null = null;
if (optionCandidate && optionCandidate.id) {
tag = tagsState.tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
}
if (!tag) {
tag = tagsState.tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
}
try {
if (!tag) {
const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await tagsState.refreshTags();
}
await attachTagToDocument({
documentId: document.id as DocumentId,
tag,
});
if (input && typeof input === 'object' && 'value' in input) {
(input as { value?: string }).value = '';
}
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
},
[tagsState, attachTagToDocument, notifyApiError],
);
const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId) => {
if (!documentId || !tagId) {
return false;
}
const resolveTagForCache = (): Tag | null => {
const lookupTag = tagsState.tagLookupById.get(tagId);
if (!lookupTag || lookupTag.id == null) {
return null;
}
const labelText = `${lookupTag.label ?? ''} `.trim();
if (!labelText) {
return null;
}
return {
id: lookupTag.id,
label: labelText,
color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null,
};
};
const resolvedTag = resolveTagForCache();
return attachTagToDocument({
documentId,
tag: resolvedTag,
});
},
[
attachTagToDocument,
tagsState,
],
);
const handleDocumentTagDetach = useCallback(
async (documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) {
return false;
}
try {
await deleteDocumentTag(documentId, tagId);
// Inlined applyTagRemovalToCaches logic
documentsState.updateDocumentCaches(documentId, (doc) => {
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
return { ...doc, tags: nextTags };
});
showToast('Tag removed.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
return false;
}
},
[documentsState, notifyApiError, showToast],
);
return {
handleDocumentTagAdd,
handleDocumentTagAttach,
handleDocumentTagDetach,
};
};
@@ -214,7 +214,7 @@ const useDocumentsWorkspace = ({
}
const tagManager = tagManagerRef.current;
const selection = useWorkspaceSelection();
const selectionState = useWorkspaceSelection();
const {
selectedEntries,
@@ -234,7 +234,7 @@ const useDocumentsWorkspace = ({
clearSelection,
promoteSelectionOrder: promoteSelectionOrderRaw,
configureSelectionEnvironment,
} = selection;
} = selectionState;
const {
documents,
@@ -247,18 +247,22 @@ const useDocumentsWorkspace = ({
fetchDocumentById,
});
const foldersManagerRef = useRef<FoldersManager | null>(null);
if (!foldersManagerRef.current) {
foldersManagerRef.current = new FoldersManager();
}
const foldersManager = foldersManagerRef.current;
const documentLookup = useSyncExternalStore(
(onStoreChange) => documentsManager.subscribe(onStoreChange),
() => documentsManager.getSnapshot(),
() => documentsManager.getSnapshot(),
);
const foldersManagerRef = useRef<FoldersManager | null>(null);
if (!foldersManagerRef.current) {
foldersManagerRef.current = new FoldersManager();
}
const foldersManager = foldersManagerRef.current;
const folderStateRaw = useFolderTree({
initialSelectedFolder: routeFolderId || 'root',
foldersManager,
});
const {
folderNodes,
setFolderNodes,
@@ -266,21 +270,75 @@ const useDocumentsWorkspace = ({
setSelectedFolder,
currentFolderName,
folderOptions,
folderLabelMap,
isInvalidFolderDrop,
} = useFolderTree({
initialSelectedFolder: routeFolderId || 'root',
foldersManager,
});
} = folderStateRaw;
const folderState = {
...folderStateRaw,
setCreatingFolder,
};
const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
const ensureFolderData = useCallback(
const reconcileSelectionWithFolderData = useCallback(
(currentSelection: string[], docs: Document[], subfolders: any[]) => {
const availableDocKeys = docs
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
.filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set(
subfolders
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
.filter(Boolean),
);
const previousFolderKeys = currentSelection
.filter(isFolderEntry)
.filter((key) => availableFolderKeys.has(key));
const previousDocKeys = currentSelection.filter(isDocumentEntry);
const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
return [...previousFolderKeys, ...nextDocKeys];
},
[],
);
const selectedFolderRef = useRef<FolderNodeId>(selectedFolder);
useEffect(() => {
selectedFolderRef.current = selectedFolder;
}, [selectedFolder]);
const updateViewState = useCallback(
(folderId: FolderNodeId, data: any, includeDocuments: boolean) => {
// Guard against race conditions: only update if the folder is still selected
if (folderId === selectedFolderRef.current) {
if (includeDocuments) {
setDocuments((data.documents || []) as Document[]);
}
setCurrentSubfolders((data.subfolders || []) as any[]);
if (includeDocuments) {
setSelectedEntries((prev) => reconcileSelectionWithFolderData(
prev,
(data.documents || []) as Document[],
(data.subfolders || []) as any[]
));
}
}
},
[
setDocuments,
setSelectedEntries,
setCurrentSubfolders,
reconcileSelectionWithFolderData,
selectedFolderRef,
]
);
const fetchFolderData = useCallback(
async (
folderId: FolderNodeId,
options: { includeDocuments?: boolean } = {}
) => {
const path = folderId === 'root' ? 'root' : folderId;
const includeDocuments = options.includeDocuments ?? true;
const params: Record<string, unknown> = {
@@ -290,61 +348,25 @@ const useDocumentsWorkspace = ({
};
const data = await listFolderContents(path, params);
// Only update UI state if we are fetching for the currently selected folder
if (folderId === selectedFolder) {
// Update documents state if included
if (includeDocuments) {
setDocuments((data.documents || []) as Document[]);
}
setCurrentSubfolders((data.subfolders || []) as any[]);
// Update selection state based on new documents
if (includeDocuments) {
const docs = (data.documents || []) as Document[];
const subfolders = (data.subfolders || []) as any[];
const availableDocKeys = docs
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
.filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set(
subfolders
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
.filter(Boolean),
);
setSelectedEntries((previous) => {
const previousFolderKeys = previous
.filter(isFolderEntry)
.filter((key) => availableFolderKeys.has(key));
const previousDocKeys = previous.filter(isDocumentEntry);
const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
const mergedSelection = [...previousFolderKeys, ...nextDocKeys];
return mergedSelection;
});
}
}
return data; // Return data for consumers (e.g. useDocumentMutations)
return { data, includeDocuments };
},
[
activeSortFieldRef,
activeSortDirectionRef,
setDocuments,
setSelectedEntries,
setCurrentSubfolders,
selectedFolder,
]
);
useEffect(() => {
if (selectedFolder) {
ensureFolderData(selectedFolder).catch((error) => {
notifyApiError(error, 'Failed to fetch folder contents');
});
fetchFolderData(selectedFolder)
.then(({ data, includeDocuments }) => {
updateViewState(selectedFolder, data, includeDocuments);
})
.catch((error) => {
notifyApiError(error, 'Failed to fetch folder contents');
});
}
}, [selectedFolder, documentsSortField, documentsSortDirection, ensureFolderData, notifyApiError]);
}, [selectedFolder, documentsSortField, documentsSortDirection, fetchFolderData, updateViewState, notifyApiError]);
const {
searchQuery,
@@ -472,6 +494,12 @@ const useDocumentsWorkspace = ({
selectionInitializedRef,
});
const tagsStateRaw = useTags({
tenantIdRef,
tagManager,
setActiveTagFilters,
mapDocumentCaches,
});
const {
tags,
refreshTags,
@@ -479,13 +507,9 @@ const useDocumentsWorkspace = ({
handleTagUpdate,
handleTagDelete,
setTags,
} = useTags({
tenantIdRef,
tagManager,
setActiveTagFilters,
mapDocumentCaches,
});
} = tagsStateRaw;
// tagLookupById is derived locally
useEffect(() => {
tenantIdRef.current = currentTenantId;
}, [currentTenantId, tenantIdRef]);
@@ -500,6 +524,16 @@ const useDocumentsWorkspace = ({
return map;
}, [tags]);
const tagsState = {
...tagsStateRaw,
tagLookupById, // Add derived lookup
tagManager,
};
const correspondentsStateRaw = useCorrespondents({
tenantIdRef,
mapDocumentCaches,
});
const {
correspondents,
refreshCorrespondents,
@@ -507,10 +541,7 @@ const useDocumentsWorkspace = ({
handleCorrespondentUpdate,
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
tenantIdRef,
mapDocumentCaches,
});
} = correspondentsStateRaw;
const {
correspondentLookupByName,
@@ -549,9 +580,10 @@ const useDocumentsWorkspace = ({
const refreshCurrentFolder = useCallback(async () => {
if (selectedFolder) {
await ensureFolderData(selectedFolder);
const { data, includeDocuments } = await fetchFolderData(selectedFolder);
updateViewState(selectedFolder, data, includeDocuments);
}
}, [selectedFolder, ensureFolderData]);
}, [selectedFolder, fetchFolderData, updateViewState]);
const {
handleBulkTagAddFromDetail,
@@ -575,7 +607,6 @@ const useDocumentsWorkspace = ({
} = useDocumentUploads({
selectedFolder,
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
shellRef,
});
@@ -686,6 +717,22 @@ const useDocumentsWorkspace = ({
],
);
const documentsState = {
documentLookup,
setDocuments,
setSearchResultIds,
removeDocumentsFromCaches,
updateDocumentCaches,
mapDocumentCaches,
extractDocumentFromResponse,
ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs),
};
const actionsState = {
refreshCurrentFolder,
closeDocumentPreview,
};
const {
moveDocumentsToFolder,
handleThumbnailRegeneration,
@@ -696,37 +743,21 @@ const useDocumentsWorkspace = ({
handleDocumentIssuedUpdate,
handleDocumentTagDetach,
} = useDocumentMutations({
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSelectedFolder,
setDocuments,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
mapDocumentCaches,
folderNodes,
setFolderNodes,
removeDocumentsFromCaches,
closeDocumentPreview,
documentsState,
folderState,
selectionState,
tagsState,
actions: actionsState,
previewDocumentId,
refreshCurrentFolder,
updateDocumentCaches,
tagLookupById,
tags,
refreshTags,
tagManager,
extractDocumentFromResponse,
ingestDocuments: (docs) => documentsManager.ingest(docs),
});
const dragState = {
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
};
const {
loadFolder,
selectFolder,
@@ -735,18 +766,15 @@ const useDocumentsWorkspace = ({
handleFolderDelete,
folderClickHandlers,
} = useFolderTreeActions({
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
handleFileDrop,
moveDocumentsToFolder,
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
isInvalidFolderDrop,
setCreatingFolder,
folderState,
dragState,
actions: {
handleFileDrop,
moveDocumentsToFolder,
},
utils: {
isInvalidFolderDrop,
},
});
const {
@@ -1017,7 +1045,6 @@ const useDocumentsWorkspace = ({
documents: viewDocuments,
documentLookup,
folderNodes,
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
previewDocumentId,
@@ -1188,7 +1215,7 @@ const useDocumentsWorkspace = ({
handleDeleteSelection,
handleEntryPointerCore,
handleBulkSelectionReanalyze,
selectionValue: selection,
selectionValue: selectionState,
};
const documentMutations = {
@@ -10,7 +10,7 @@ import {
renameFolder as renameFolderRequest,
} from '../../../lib/api/apiClient';
import type { FolderId } from '../../../types/identifiers';
import type { MessageOptions, FolderNode } from '../../../types/documents';
import type { MessageOptions } from '../../../types/documents';
type FolderKey = FolderId | 'root';
@@ -32,35 +32,51 @@ interface FolderClickHandlers {
import useNotifyApiError from '../../../hooks/useNotifyApiError';
import type {
FolderState,
DragState,
FolderNode,
} from '../../types/workspaceTypes';
interface UseFolderTreeActionsOptions {
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
draggedDocumentIds: FolderId[];
draggedFolderId: FolderKey | null;
setDraggedDocumentIds: (ids: FolderId[]) => void;
setDraggedFolderId: (id: FolderKey | null) => void;
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
setCreatingFolder: (value: boolean) => void;
folderState: Pick<FolderState, 'folderNodes' | 'setFolderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'>;
dragState: DragState;
actions: {
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
};
utils: {
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
};
}
const useFolderTreeActions = ({
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
handleFileDrop,
moveDocumentsToFolder,
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
isInvalidFolderDrop,
setCreatingFolder,
folderState,
dragState,
actions,
utils,
}: UseFolderTreeActionsOptions) => {
const {
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
setCreatingFolder,
} = folderState;
const {
draggedDocumentIds,
draggedFolderId,
setDraggedDocumentIds,
setDraggedFolderId,
} = dragState;
const {
handleFileDrop,
moveDocumentsToFolder,
} = actions;
const { isInvalidFolderDrop } = utils;
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const navigate = useNavigate();
@@ -85,7 +101,7 @@ const useFolderTreeActions = ({
try {
await moveFolderRequest(folderId, parent_id);
setFolderNodes((prev) => {
setFolderNodes((prev: Map<FolderKey, FolderNode>) => {
const next = new Map(prev);
const currentNode = next.get(folderId);
if (!currentNode) {
@@ -154,7 +170,6 @@ const useFolderTreeActions = ({
async (folderId: FolderKey | null, { preserveSearch: _preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root';
setSelectedFolder(targetId);
// Data fetching is now reactive in the parent component based on selectedFolder
},
[setSelectedFolder],
);
@@ -187,7 +202,7 @@ const useFolderTreeActions = ({
try {
await renameFolderRequest(folderId, trimmed);
setFolderNodes((prev) => {
setFolderNodes((prev: Map<FolderKey, FolderNode>) => {
const next = new Map(prev);
const node = next.get(folderId);
if (node) {
@@ -235,7 +250,7 @@ const useFolderTreeActions = ({
throw new Error('Folder creation failed.');
}
showToast('Folder created.', 'success');
setFolderNodes((prev) => {
setFolderNodes((prev: Map<FolderKey, FolderNode>) => {
const next = new Map(prev);
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root';
const parentNode = next.get(parentId);
@@ -295,7 +310,7 @@ const useFolderTreeActions = ({
try {
await deleteFolder(folderId);
setFolderNodes((prev) => {
setFolderNodes((prev: Map<FolderKey, FolderNode>) => {
const next = new Map(prev);
const node = next.get(folderId);
next.delete(folderId);
@@ -3,7 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { useStatusToast } from '../../../lib/context/StatusToastContext';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils';
import { fetchDocument, uploadDocument, resolveFolderPath } from '../../../lib/api/apiClient';
import { fetchDocument, uploadDocument, resolveFolderPath, listFolderContents } from '../../../lib/api/apiClient';
import type { Identifier } from '../../../types/identifiers';
type FolderId = Identifier | 'root' | null;
@@ -93,7 +93,6 @@ const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] =
interface UseDocumentUploadsArgs {
selectedFolder?: FolderId;
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { [key: string]: unknown }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
@@ -122,7 +121,6 @@ import useNotifyApiError from '../../../hooks/useNotifyApiError';
const useDocumentUploads = ({
selectedFolder,
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
shellRef,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
@@ -426,7 +424,7 @@ const useDocumentUploads = ({
targetFolderId !== 'root' &&
targetFolderId !== selectedFolder
) {
await ensureFolderData(targetFolderId);
await listFolderContents(targetFolderId);
}
} catch (error: any) {
const message = error.message || 'Failed to upload files.';
@@ -450,7 +448,6 @@ const useDocumentUploads = ({
uploadFile,
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
appendQueueItems,
updateQueueItem,
],
@@ -0,0 +1,95 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
import type { Document } from '../../types/documents';
type FolderId = FolderIdentifier | 'root';
export interface FolderNode {
id: FolderId;
parentId?: FolderId;
children: FolderId[];
hasChildren?: boolean;
name?: string;
expanded?: boolean;
loaded?: boolean;
}
export interface Tag {
id: DocumentId;
label: string;
color?: string | null;
}
interface TagManager {
normalizeLabel: (label: string) => string;
buildPayload: (args: { label: string }) => Record<string, unknown>;
}
type DocumentCacheMapper = (
doc: Document | null,
) => Document | null;
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
type UpdateDocumentCaches = (
documentId: DocumentId,
updater: DocumentCacheMapper,
) => void;
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void;
export interface DocumentsState {
documentLookup: Map<DocumentId, Document>;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
updateDocumentCaches: UpdateDocumentCaches;
mapDocumentCaches: MapDocumentCaches;
extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
}
export interface FolderState {
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
folderLabelMap: Map<FolderId, string>;
setCreatingFolder?: (value: boolean) => void;
}
export interface SelectionState {
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
focusedDocumentId: DocumentId | null;
setFocusedEntryKey: Dispatch<SetStateAction<string | null>>;
focusedEntryKey: string | null;
}
export interface TagsState {
tags: Tag[];
tagLookupById: Map<DocumentId, Tag>;
refreshTags: () => Promise<void>;
tagManager: TagManager;
}
export interface ActionsState {
refreshCurrentFolder: () => Promise<void>;
closeDocumentPreview: CloseDocumentPreview;
handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void> | void;
moveDocumentsToFolder?: (docIds: FolderId[], folderId: FolderId) => Promise<void>;
}
export interface DragState {
draggedDocumentIds: FolderId[];
draggedFolderId: FolderId | null;
setDraggedDocumentIds: (ids: FolderId[]) => void;
setDraggedFolderId: (id: FolderId | null) => void;
}
@@ -18,7 +18,6 @@ interface UseDetailWorkspaceArgs {
documents: Document[];
documentLookup: Map<Identifier, Document>;
folderNodes: Map<Identifier | 'root', FolderNode>;
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
detailPanelControlRef: MutableRefObject<{ open?: (documentId: Identifier) => void; close?: () => void } | null>;
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
previewDocumentId?: Identifier | null;
@@ -51,11 +50,12 @@ interface UseDetailWorkspaceResult {
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
}
import { listFolderContents } from '../../lib/api/apiClient';
const useDetailWorkspace = ({
documents,
documentLookup,
folderNodes,
ensureFolderData,
detailPanelControlRef,
detailFolderFetchRef,
previewDocumentId,
@@ -118,7 +118,7 @@ const useDetailWorkspace = ({
if (!node) {
if (!detailFolderFetchRef.current.has(currentId)) {
detailFolderFetchRef.current.add(currentId);
ensureFolderData(currentId, { force: false, includeDocuments: false })
listFolderContents(currentId, { include_documents: false })
.catch((error) => {
console.warn('Failed to preload folder metadata for detail path', currentId, error);
})
@@ -135,7 +135,7 @@ const useDetailWorkspace = ({
}
currentId = parentId;
}
}, [detailPanelDocument, folderNodes, ensureFolderData, detailFolderFetchRef]);
}, [detailPanelDocument, folderNodes, detailFolderFetchRef]);
const resolveFolderPath = useCallback(
(folderId) => {
@@ -198,7 +198,6 @@ const useDetailWorkspace = ({
onClose: handleDetailPanelClose,
resolveFolderPath,
folderNodes,
ensureFolderData,
}),
[
activePreviewId,
@@ -214,7 +213,6 @@ const useDetailWorkspace = ({
handleDocumentTitleUpdate,
handleDocumentTagDetach,
folderNodes,
ensureFolderData,
openDocumentPreview,
resolveFolderPath,
selectFolder,