From 26449e522ac2d0ddec856518540c64c1e42c2c10 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 28 Mar 2026 11:51:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(resource):=20add=20select=20al?= =?UTF-8?q?l=20hint=20and=20improve=20resource=20explorer=20selection=20(#?= =?UTF-8?q?13134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(resource): add select all hint and improve resource explorer selection Made-with: Cursor * ♻️ refactor(resource): flatten store actions and improve type imports Made-with: Cursor * :recycle: refactor resource explorer list view * refactor: engine Signed-off-by: Innei * ✨ feat: checkpoint current workspace updates * :recycle: refine resource explorer fetch ownership * 🐛 fix: resolve resource manager ci regressions * 🐛 fix(lambda): delete page-backed knowledge items by document id * 🐛 fix(lambda): include knowledge-base files in remove-all * 🐛 fix(resource): preserve cross-page select-all exclusions * 🐛 fix(resource): retain off-screen optimistic resources * 🐛 fix(resource): hide moved root items from current query * 🐛 fix(resource): reset explorer selection on query change * 🐛 fix(resource): fix select-all batchChunking and optimistic replace visibility - batchChunking: pass through server-resolved IDs not in local resourceMap when selectAllState is 'all', letting server filter unsupported types - replaceLocalResource: keep replacement visible if the optimistic item was already in the list, avoiding slug-vs-UUID mismatch in visibility check * 🐛 fix(resource): reset selectAllState after batch operations and preserve off-screen optimistic items - Reset selectAllState to 'none' after delete, removeFromKnowledgeBase, and batchChunking to prevent stale 'all' state causing unintended re-selection of remaining items - Preserve off-screen optimistic resources in clearCurrentQueryResources so background uploads from other folders survive delete-all-by-query * 🐛 fix: satisfy import-x/first in resource action test Made-with: Cursor * 🎨 lint: sort imports in ResourceExplorer Made-with: Cursor * 🐛 fix: widen searchQuery type in useResetSelectionOnQueryChange test Made-with: Cursor --------- Signed-off-by: Innei --- locales/ar/components.json | 6 + locales/ar/file.json | 1 + locales/bg-BG/components.json | 6 + locales/bg-BG/file.json | 1 + locales/de-DE/components.json | 6 + locales/de-DE/file.json | 1 + locales/en-US/components.json | 6 + locales/en-US/file.json | 1 + locales/es-ES/components.json | 6 + locales/es-ES/file.json | 1 + locales/fa-IR/components.json | 6 + locales/fa-IR/file.json | 1 + locales/fr-FR/components.json | 6 + locales/fr-FR/file.json | 1 + locales/it-IT/components.json | 6 + locales/it-IT/file.json | 1 + locales/ja-JP/components.json | 6 + locales/ja-JP/file.json | 1 + locales/ko-KR/components.json | 6 + locales/ko-KR/file.json | 1 + locales/nl-NL/components.json | 6 + locales/nl-NL/file.json | 1 + locales/pl-PL/components.json | 6 + locales/pl-PL/file.json | 1 + locales/pt-BR/components.json | 6 + locales/pt-BR/file.json | 1 + locales/ru-RU/components.json | 6 + locales/ru-RU/file.json | 1 + locales/tr-TR/components.json | 6 + locales/tr-TR/file.json | 1 + locales/vi-VN/components.json | 6 + locales/vi-VN/file.json | 1 + locales/zh-CN/components.json | 6 + locales/zh-CN/file.json | 1 + locales/zh-TW/components.json | 6 + locales/zh-TW/file.json | 1 + .../src/repositories/knowledge/index.ts | 22 + packages/types/src/files/list.ts | 6 +- .../(auth)/_layout/AuthThemeLite.tsx | 4 +- src/components/AnimatedCollapsed/index.tsx | 6 +- .../Messages/components/SearchGrounding.tsx | 6 +- .../AddFilesToKnowledgeBase/SelectForm.tsx | 165 +-- .../AddFilesToKnowledgeBase/index.tsx | 55 +- .../InstallError/ErrorDetails.tsx | 6 +- .../MCP/MCPInstallProgress/MCPConfigForm.tsx | 18 +- .../MCPDependenciesGuide.tsx | 18 +- src/features/MCP/MCPInstallProgress/index.tsx | 18 +- .../NavPanel/components/NavPanelDraggable.tsx | 6 +- src/features/PageEditor/StoreUpdater.tsx | 9 +- .../PageExplorer/PageExplorerPlaceholder.tsx | 10 +- .../components/Explorer/Header/index.tsx | 34 +- .../ItemDropdown/useFileItemDropdown.tsx | 23 +- .../ListView/ListItem/FileListItemActions.tsx | 97 ++ .../ListView/ListItem/FileListItemName.tsx | 85 ++ .../Explorer/ListView/ListItem/constants.ts | 2 + .../Explorer/ListView/ListItem/index.tsx | 759 ++++--------- .../Explorer/ListView/ListItem/styles.ts | 134 +++ .../ListView/ListItem/useFileListItemDrag.ts | 98 ++ .../ListView/ListItem/useFileListItemMeta.ts | 47 + .../ListItem/useFileListItemRename.ts | 101 ++ .../Explorer/ListView/ListViewDropZone.tsx | 45 + .../Explorer/ListView/ListViewHeader.tsx | 136 +++ .../ListView/ListViewSelectAllHint.tsx | 59 + .../components/Explorer/ListView/Skeleton.tsx | 2 +- .../Explorer/ListView/VirtualizedFileList.tsx | 122 ++ .../components/Explorer/ListView/index.tsx | 474 +------- .../components/Explorer/ListView/styles.ts | 43 + .../Explorer/ListView/useExplorerDropZone.ts | 86 ++ .../ListView/useExplorerInfiniteScroll.tsx | 47 + .../Explorer/ListView/useExplorerListData.ts | 75 ++ .../MasonryItem/MasonryItemWrapper.tsx | 18 +- .../MasonryView/MasonryItem/index.tsx | 4 +- .../components/Explorer/MasonryView/index.tsx | 250 ++-- .../MasonryView/useMasonryViewState.ts | 31 + .../Explorer/SearchResultsOverlay.tsx | 11 +- .../Explorer/ToolBar/BatchActionsDropdown.tsx | 25 +- .../hooks/useExplorerSelection.test.tsx | 59 + .../Explorer/hooks/useExplorerSelection.ts | 139 +++ .../Explorer/hooks/useFileSelection.ts | 62 - .../useResetSelectionOnQueryChange.test.tsx | 76 ++ .../hooks/useResetSelectionOnQueryChange.ts | 24 + .../components/Explorer/index.tsx | 65 +- .../components/Explorer/useCheckTaskStatus.ts | 36 +- .../Explorer/useResourceExplorer.ts | 214 ---- .../components/FolderTree/index.tsx | 10 +- .../components/Header/AddButton.tsx | 32 +- .../Header/hooks/useNotionImport.ts | 5 +- .../components/KnowledgeBaseListProvider.tsx | 34 + .../LibraryHierarchy/HierarchyNode.tsx | 10 +- .../components/LibraryHierarchy/index.tsx | 47 +- .../components/UploadDock/index.tsx | 104 +- src/features/ResourceManager/index.tsx | 5 +- src/initialize.ts | 3 +- src/layout/GlobalProvider/AppTheme.tsx | 4 +- src/locales/default/components.ts | 7 + src/locales/default/file.ts | 1 + .../_layout/Sidebar/Topic/List/Item/index.tsx | 8 +- .../(main)/home/features/InputArea/index.tsx | 6 +- .../resource/features/DndContextWrapper.tsx | 183 +-- .../features/hooks/useCurrentFolderId.ts | 9 + .../hooks/useResourceManagerUrlSync.ts | 12 +- .../resource/features/store/action.test.ts | 67 ++ .../(main)/resource/features/store/action.ts | 308 +++-- .../(main)/resource/features/store/index.ts | 45 +- .../resource/features/store/initialState.ts | 34 +- .../resource/features/store/selectors.test.ts | 48 + .../resource/features/store/selectors.ts | 51 +- src/routes/(main)/resource/store/action.ts | 73 +- .../settings/profile/features/EmailRow.tsx | 10 +- .../routers/lambda/__tests__/file.test.ts | 111 ++ src/server/routers/lambda/file.ts | 292 +++-- src/services/file/index.ts | 13 + src/services/resource/index.ts | 56 +- src/store/file/reducers/uploadFileList.ts | 20 + src/store/file/slices/document/action.test.ts | 270 +++++ src/store/file/slices/document/action.ts | 282 ++++- .../file/slices/fileManager/action.test.ts | 168 ++- src/store/file/slices/fileManager/action.ts | 172 ++- src/store/file/slices/resource/action.test.ts | 102 ++ src/store/file/slices/resource/action.ts | 1001 +++++++++++------ src/store/file/slices/resource/hooks.ts | 7 +- .../file/slices/resource/initialState.ts | 5 +- src/store/file/slices/resource/syncEngine.ts | 326 ------ src/store/file/slices/resource/utils.test.ts | 48 + src/store/file/slices/resource/utils.ts | 61 + src/store/file/slices/upload/action.ts | 14 +- src/store/file/store.ts | 56 +- .../library/slices/content/action.test.ts | 190 +--- src/store/library/slices/content/action.ts | 15 +- src/store/utils/optimisticEngine.test.ts | 389 +++++++ src/store/utils/optimisticEngine.ts | 497 ++++++++ src/types/resource.ts | 18 +- tests/setup.ts | 3 +- 133 files changed, 6089 insertions(+), 3101 deletions(-) create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemActions.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemName.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/constants.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/styles.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemDrag.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemMeta.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemRename.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListViewDropZone.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListViewHeader.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/ListViewSelectAllHint.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/VirtualizedFileList.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/styles.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/useExplorerDropZone.ts create mode 100644 src/features/ResourceManager/components/Explorer/ListView/useExplorerInfiniteScroll.tsx create mode 100644 src/features/ResourceManager/components/Explorer/ListView/useExplorerListData.ts create mode 100644 src/features/ResourceManager/components/Explorer/MasonryView/useMasonryViewState.ts create mode 100644 src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.test.tsx create mode 100644 src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.ts delete mode 100644 src/features/ResourceManager/components/Explorer/hooks/useFileSelection.ts create mode 100644 src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.test.tsx create mode 100644 src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.ts delete mode 100644 src/features/ResourceManager/components/Explorer/useResourceExplorer.ts create mode 100644 src/features/ResourceManager/components/KnowledgeBaseListProvider.tsx create mode 100644 src/routes/(main)/resource/features/hooks/useCurrentFolderId.ts create mode 100644 src/routes/(main)/resource/features/store/action.test.ts create mode 100644 src/routes/(main)/resource/features/store/selectors.test.ts create mode 100644 src/store/file/slices/document/action.test.ts create mode 100644 src/store/file/slices/resource/action.test.ts delete mode 100644 src/store/file/slices/resource/syncEngine.ts create mode 100644 src/store/file/slices/resource/utils.test.ts create mode 100644 src/store/file/slices/resource/utils.ts create mode 100644 src/store/utils/optimisticEngine.test.ts create mode 100644 src/store/utils/optimisticEngine.ts diff --git a/locales/ar/components.json b/locales/ar/components.json index af0b266336..c98328ab25 100644 --- a/locales/ar/components.json +++ b/locales/ar/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "تقسيم الملف إلى أجزاء نصية متعددة وتضمينها للبحث الدلالي والحوار مع الملف.", "FileManager.actions.chunkingUnsupported": "هذا الملف لا يدعم التجزئة.", "FileManager.actions.confirmDelete": "أنت على وشك حذف هذا الملف. لا يمكن استعادته بعد الحذف. يرجى تأكيد الإجراء.", + "FileManager.actions.confirmDeleteAllFiles": "أنت على وشك حذف جميع النتائج في العرض الحالي. بمجرد حذفها، لا يمكن استعادتها. يرجى تأكيد الإجراء.", "FileManager.actions.confirmDeleteFolder": "أنت على وشك حذف هذا المجلد وجميع محتوياته. لا يمكن التراجع عن هذا الإجراء. يرجى تأكيد القرار.", "FileManager.actions.confirmDeleteMultiFiles": "أنت على وشك حذف {{count}} ملفًا محددًا. لا يمكن استعادتها بعد الحذف. يرجى تأكيد الإجراء.", "FileManager.actions.confirmRemoveFromLibrary": "أنت على وشك إزالة {{count}} ملف/ملفات محددة من المكتبة. ستظل متاحة في جميع الملفات. أكد للمتابعة.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "تاريخ الإنشاء", "FileManager.title.size": "الحجم", "FileManager.title.title": "الملف", + "FileManager.total.allSelectedCount": "تم تحديد جميع العناصر البالغ عددها {{count}}.", + "FileManager.total.allSelectedFallback": "تم تحديد جميع النتائج.", "FileManager.total.fileCount": "الإجمالي {{count}} عنصر", + "FileManager.total.loadedSelectedCount": "تم تحديد {{count}} من العناصر المحملة.", + "FileManager.total.selectAll": "تحديد جميع العناصر البالغ عددها {{count}}", + "FileManager.total.selectAllFallback": "تحديد جميع العناصر", "FileManager.total.selectedCount": "المحدد {{count}} عنصر", "FileManager.view.list": "عرض القائمة", "FileManager.view.masonry": "عرض الشبكة", diff --git a/locales/ar/file.json b/locales/ar/file.json index 22b775090b..a039d3e780 100644 --- a/locales/ar/file.json +++ b/locales/ar/file.json @@ -129,6 +129,7 @@ "title": "الموارد", "toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية", "uploadDock.body.collapse": "طي", + "uploadDock.header.cancelAll": "إلغاء الكل", "uploadDock.body.item.cancel": "إلغاء", "uploadDock.body.item.cancelled": "تم الإلغاء", "uploadDock.body.item.done": "تم التحميل", diff --git a/locales/bg-BG/components.json b/locales/bg-BG/components.json index 93f32f73cc..44f65a8a4b 100644 --- a/locales/bg-BG/components.json +++ b/locales/bg-BG/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Разделя файла на няколко текстови части и ги вгражда за семантично търсене и диалог с файла.", "FileManager.actions.chunkingUnsupported": "Този файл не поддържа разделяне.", "FileManager.actions.confirmDelete": "Ще изтриете този файл. След изтриване не може да бъде възстановен. Моля, потвърдете действието си.", + "FileManager.actions.confirmDeleteAllFiles": "Предстои да изтриете всички резултати в текущия изглед. След изтриване те не могат да бъдат възстановени. Моля, потвърдете действието.", "FileManager.actions.confirmDeleteFolder": "Ще изтриете тази папка и цялото ѝ съдържание. Това действие не може да бъде отменено. Моля, потвърдете решението си.", "FileManager.actions.confirmDeleteMultiFiles": "Ще изтриете избраните {{count}} файла. След изтриване те не могат да бъдат възстановени. Моля, потвърдете действието си.", "FileManager.actions.confirmRemoveFromLibrary": "Ще премахнете {{count}} избран(и) файл(а) от библиотеката. Те ще останат достъпни във Всички файлове. Потвърдете, за да продължите.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Създаден на", "FileManager.title.size": "Размер", "FileManager.title.title": "Файл", + "FileManager.total.allSelectedCount": "Всички {{count}} елемента са избрани.", + "FileManager.total.allSelectedFallback": "Всички резултати са избрани.", "FileManager.total.fileCount": "Общо {{count}} елемента", + "FileManager.total.loadedSelectedCount": "Избрани са {{count}} заредени елемента.", + "FileManager.total.selectAll": "Избери всички {{count}} елемента", + "FileManager.total.selectAllFallback": "Избери всички елементи", "FileManager.total.selectedCount": "Избрани {{count}} елемента", "FileManager.view.list": "Изглед списък", "FileManager.view.masonry": "Изглед мрежа", diff --git a/locales/bg-BG/file.json b/locales/bg-BG/file.json index 3fcb106411..8de80dfac1 100644 --- a/locales/bg-BG/file.json +++ b/locales/bg-BG/file.json @@ -129,6 +129,7 @@ "title": "Ресурси", "toggleLeftPanel": "Показване/Скриване на ляв панел", "uploadDock.body.collapse": "Свий", + "uploadDock.header.cancelAll": "Отказ на всички", "uploadDock.body.item.cancel": "Отказ", "uploadDock.body.item.cancelled": "Отказано", "uploadDock.body.item.done": "Качено", diff --git a/locales/de-DE/components.json b/locales/de-DE/components.json index 8208c906ec..9d32c7e106 100644 --- a/locales/de-DE/components.json +++ b/locales/de-DE/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Datei in mehrere Textsegmente aufteilen und einbetten für semantische Suche und Dateidialog.", "FileManager.actions.chunkingUnsupported": "Diese Datei unterstützt keine Segmentierung.", "FileManager.actions.confirmDelete": "Sie sind dabei, diese Datei zu löschen. Nach dem Löschen kann sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.", + "FileManager.actions.confirmDeleteAllFiles": "Sie sind dabei, alle Ergebnisse in der aktuellen Ansicht zu löschen. Nach dem Löschen können sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.", "FileManager.actions.confirmDeleteFolder": "Sie sind dabei, diesen Ordner und seinen gesamten Inhalt zu löschen. Diese Aktion kann nicht rückgängig gemacht werden. Bitte bestätigen Sie Ihre Entscheidung.", "FileManager.actions.confirmDeleteMultiFiles": "Sie sind dabei, die ausgewählten {{count}} Dateien zu löschen. Nach dem Löschen können sie nicht wiederhergestellt werden. Bitte bestätigen Sie Ihre Aktion.", "FileManager.actions.confirmRemoveFromLibrary": "Sie sind dabei, {{count}} ausgewählte Datei(en) aus der Bibliothek zu entfernen. Sie bleiben weiterhin unter 'Alle Dateien' verfügbar. Bitte bestätigen Sie, um fortzufahren.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Erstellt am", "FileManager.title.size": "Größe", "FileManager.title.title": "Datei", + "FileManager.total.allSelectedCount": "Alle {{count}} Elemente sind ausgewählt.", + "FileManager.total.allSelectedFallback": "Alle Ergebnisse sind ausgewählt.", "FileManager.total.fileCount": "Insgesamt {{count}} Elemente", + "FileManager.total.loadedSelectedCount": "{{count}} geladene Elemente ausgewählt.", + "FileManager.total.selectAll": "Alle {{count}} Elemente auswählen", + "FileManager.total.selectAllFallback": "Alle Elemente auswählen", "FileManager.total.selectedCount": "{{count}} Elemente ausgewählt", "FileManager.view.list": "Listenansicht", "FileManager.view.masonry": "Rasteransicht", diff --git a/locales/de-DE/file.json b/locales/de-DE/file.json index 5eb468ac17..33918b8af1 100644 --- a/locales/de-DE/file.json +++ b/locales/de-DE/file.json @@ -129,6 +129,7 @@ "title": "Ressourcen", "toggleLeftPanel": "Linkes Panel ein-/ausblenden", "uploadDock.body.collapse": "Einklappen", + "uploadDock.header.cancelAll": "Alle abbrechen", "uploadDock.body.item.cancel": "Abbrechen", "uploadDock.body.item.cancelled": "Abgebrochen", "uploadDock.body.item.done": "Hochgeladen", diff --git a/locales/en-US/components.json b/locales/en-US/components.json index 48ef705aee..1508793f0f 100644 --- a/locales/en-US/components.json +++ b/locales/en-US/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Split the file into multiple text chunks and embedding them for semantic search and file dialogue.", "FileManager.actions.chunkingUnsupported": "This file does not support chunking.", "FileManager.actions.confirmDelete": "You are about to delete this file. Once deleted, it cannot be recovered. Please confirm your action.", + "FileManager.actions.confirmDeleteAllFiles": "You are about to delete all results in the current view. Once deleted, they cannot be recovered. Please confirm your action.", "FileManager.actions.confirmDeleteFolder": "You are about to delete this folder and all of its contents. This action cannot be undone. Please confirm your decision.", "FileManager.actions.confirmDeleteMultiFiles": "You are about to delete the selected {{count}} files. Once deleted, they cannot be recovered. Please confirm your action.", "FileManager.actions.confirmRemoveFromLibrary": "You're about to remove {{count}} selected file(s) from the Library. They'll still be available in All Files. Confirm to continue.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Created At", "FileManager.title.size": "Size", "FileManager.title.title": "File", + "FileManager.total.allSelectedCount": "All {{count}} items are selected.", + "FileManager.total.allSelectedFallback": "All results are selected.", "FileManager.total.fileCount": "Total {{count}} items", + "FileManager.total.loadedSelectedCount": "Selected {{count}} loaded items.", + "FileManager.total.selectAll": "Select all {{count}} items", + "FileManager.total.selectAllFallback": "Select all items", "FileManager.total.selectedCount": "Selected {{count}} items", "FileManager.view.list": "List View", "FileManager.view.masonry": "Grid View", diff --git a/locales/en-US/file.json b/locales/en-US/file.json index 944587d415..96f42b450e 100644 --- a/locales/en-US/file.json +++ b/locales/en-US/file.json @@ -137,6 +137,7 @@ "uploadDock.body.item.processing": "Processing file...", "uploadDock.body.item.restTime": "Remaining {{time}}", "uploadDock.fileQueueInfo": "Uploading the first {{count}} files, {{remaining}} remaining in queue", + "uploadDock.header.cancelAll": "Cancel all", "uploadDock.totalCount": "Total {{count}} items", "uploadDock.uploadStatus.cancelled": "Upload cancelled", "uploadDock.uploadStatus.error": "Upload error", diff --git a/locales/es-ES/components.json b/locales/es-ES/components.json index c409d73445..3e47f68617 100644 --- a/locales/es-ES/components.json +++ b/locales/es-ES/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Divide el archivo en múltiples fragmentos de texto y los incrusta para búsqueda semántica y diálogo de archivos.", "FileManager.actions.chunkingUnsupported": "Este archivo no admite fragmentación.", "FileManager.actions.confirmDelete": "Estás a punto de eliminar este archivo. Una vez eliminado, no se podrá recuperar. Por favor, confirma tu acción.", + "FileManager.actions.confirmDeleteAllFiles": "Está a punto de eliminar todos los resultados de la vista actual. Una vez eliminados, no se podrán recuperar. Confirme la acción.", "FileManager.actions.confirmDeleteFolder": "Estás a punto de eliminar esta carpeta y todo su contenido. Esta acción no se puede deshacer. Por favor, confirma tu decisión.", "FileManager.actions.confirmDeleteMultiFiles": "Estás a punto de eliminar los {{count}} archivos seleccionados. Una vez eliminados, no se podrán recuperar. Por favor, confirma tu acción.", "FileManager.actions.confirmRemoveFromLibrary": "Estás a punto de eliminar {{count}} archivo(s) seleccionado(s) de la biblioteca. Seguirán disponibles en Todos los archivos. Confirma para continuar.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Fecha de creación", "FileManager.title.size": "Tamaño", "FileManager.title.title": "Archivo", + "FileManager.total.allSelectedCount": "Están seleccionados los {{count}} elementos.", + "FileManager.total.allSelectedFallback": "Están seleccionados todos los resultados.", "FileManager.total.fileCount": "Total {{count}} elementos", + "FileManager.total.loadedSelectedCount": "Seleccionados {{count}} elementos cargados.", + "FileManager.total.selectAll": "Seleccionar los {{count}} elementos", + "FileManager.total.selectAllFallback": "Seleccionar todos los elementos", "FileManager.total.selectedCount": "{{count}} elementos seleccionados", "FileManager.view.list": "Vista de lista", "FileManager.view.masonry": "Vista de cuadrícula", diff --git a/locales/es-ES/file.json b/locales/es-ES/file.json index 23c1d4750a..8a7a429d85 100644 --- a/locales/es-ES/file.json +++ b/locales/es-ES/file.json @@ -129,6 +129,7 @@ "title": "Recursos", "toggleLeftPanel": "Mostrar/Ocultar panel izquierdo", "uploadDock.body.collapse": "Colapsar", + "uploadDock.header.cancelAll": "Cancelar todo", "uploadDock.body.item.cancel": "Cancelar", "uploadDock.body.item.cancelled": "Cancelado", "uploadDock.body.item.done": "Subido", diff --git a/locales/fa-IR/components.json b/locales/fa-IR/components.json index 13ddcce9c6..5735f0d322 100644 --- a/locales/fa-IR/components.json +++ b/locales/fa-IR/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "فایل را به چند بخش متنی تقسیم کرده و آن‌ها را برای جستجوی معنایی و گفت‌وگوی فایل جاسازی می‌کند.", "FileManager.actions.chunkingUnsupported": "این فایل از تقسیم‌بندی پشتیبانی نمی‌کند.", "FileManager.actions.confirmDelete": "در حال حذف این فایل هستید. پس از حذف، قابل بازیابی نخواهد بود. لطفاً اقدام خود را تأیید کنید.", + "FileManager.actions.confirmDeleteAllFiles": "در آستانه حذف همه نتایج در نمای فعلی هستید. پس از حذف، قابل بازیابی نیستند. لطفاً اقدام خود را تأیید کنید.", "FileManager.actions.confirmDeleteFolder": "در حال حذف این پوشه و تمام محتوای آن هستید. این عملیات قابل بازگشت نیست. لطفاً تصمیم خود را تأیید کنید.", "FileManager.actions.confirmDeleteMultiFiles": "در حال حذف {{count}} فایل انتخاب‌شده هستید. پس از حذف، قابل بازیابی نخواهند بود. لطفاً اقدام خود را تأیید کنید.", "FileManager.actions.confirmRemoveFromLibrary": "در حال حذف {{count}} فایل انتخاب‌شده از کتابخانه هستید. این فایل‌ها همچنان در بخش همه فایل‌ها در دسترس خواهند بود. برای ادامه تأیید کنید.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "تاریخ ایجاد", "FileManager.title.size": "اندازه", "FileManager.title.title": "فایل", + "FileManager.total.allSelectedCount": "همه {{count}} مورد انتخاب شده‌اند.", + "FileManager.total.allSelectedFallback": "همه نتایج انتخاب شده‌اند.", "FileManager.total.fileCount": "مجموع {{count}} مورد", + "FileManager.total.loadedSelectedCount": "{{count}} مورد بارگذاری‌شده انتخاب شده‌اند.", + "FileManager.total.selectAll": "انتخاب همه {{count}} مورد", + "FileManager.total.selectAllFallback": "انتخاب همه موارد", "FileManager.total.selectedCount": "{{count}} مورد انتخاب شده", "FileManager.view.list": "نمای لیستی", "FileManager.view.masonry": "نمای جدولی", diff --git a/locales/fa-IR/file.json b/locales/fa-IR/file.json index 5b3a188e74..48c88b2cf5 100644 --- a/locales/fa-IR/file.json +++ b/locales/fa-IR/file.json @@ -129,6 +129,7 @@ "title": "منابع", "toggleLeftPanel": "نمایش/مخفی کردن پنل کناری", "uploadDock.body.collapse": "جمع کردن", + "uploadDock.header.cancelAll": "لغو همه", "uploadDock.body.item.cancel": "لغو", "uploadDock.body.item.cancelled": "لغو شد", "uploadDock.body.item.done": "بارگذاری شد", diff --git a/locales/fr-FR/components.json b/locales/fr-FR/components.json index 9c1ca25dd1..7f5a65715d 100644 --- a/locales/fr-FR/components.json +++ b/locales/fr-FR/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Divise le fichier en plusieurs segments de texte et les intègre pour la recherche sémantique et le dialogue de fichier.", "FileManager.actions.chunkingUnsupported": "Ce fichier ne prend pas en charge le découpage.", "FileManager.actions.confirmDelete": "Vous êtes sur le point de supprimer ce fichier. Une fois supprimé, il ne pourra pas être récupéré. Veuillez confirmer votre action.", + "FileManager.actions.confirmDeleteAllFiles": "Vous êtes sur le point de supprimer tous les résultats de la vue actuelle. Une fois supprimés, ils ne pourront pas être récupérés. Veuillez confirmer votre action.", "FileManager.actions.confirmDeleteFolder": "Vous êtes sur le point de supprimer ce dossier et tout son contenu. Cette action est irréversible. Veuillez confirmer votre décision.", "FileManager.actions.confirmDeleteMultiFiles": "Vous êtes sur le point de supprimer les {{count}} fichiers sélectionnés. Une fois supprimés, ils ne pourront pas être récupérés. Veuillez confirmer votre action.", "FileManager.actions.confirmRemoveFromLibrary": "Vous êtes sur le point de retirer {{count}} fichier(s) sélectionné(s) de la bibliothèque. Ils resteront disponibles dans Tous les fichiers. Confirmez pour continuer.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Créé le", "FileManager.title.size": "Taille", "FileManager.title.title": "Fichier", + "FileManager.total.allSelectedCount": "Les {{count}} éléments sont tous sélectionnés.", + "FileManager.total.allSelectedFallback": "Tous les résultats sont sélectionnés.", "FileManager.total.fileCount": "Total de {{count}} éléments", + "FileManager.total.loadedSelectedCount": "{{count}} éléments chargés sélectionnés.", + "FileManager.total.selectAll": "Sélectionner les {{count}} éléments", + "FileManager.total.selectAllFallback": "Sélectionner tous les éléments", "FileManager.total.selectedCount": "{{count}} éléments sélectionnés", "FileManager.view.list": "Vue en liste", "FileManager.view.masonry": "Vue en grille", diff --git a/locales/fr-FR/file.json b/locales/fr-FR/file.json index 615c180028..2b8bb278d5 100644 --- a/locales/fr-FR/file.json +++ b/locales/fr-FR/file.json @@ -129,6 +129,7 @@ "title": "Ressources", "toggleLeftPanel": "Afficher/Masquer le panneau gauche", "uploadDock.body.collapse": "Réduire", + "uploadDock.header.cancelAll": "Tout annuler", "uploadDock.body.item.cancel": "Annuler", "uploadDock.body.item.cancelled": "Annulé", "uploadDock.body.item.done": "Importé", diff --git a/locales/it-IT/components.json b/locales/it-IT/components.json index e7a8e4bd0d..3377566333 100644 --- a/locales/it-IT/components.json +++ b/locales/it-IT/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Dividi il file in più segmenti di testo ed esegui l'embedding per la ricerca semantica e il dialogo sui file.", "FileManager.actions.chunkingUnsupported": "Questo file non supporta la segmentazione.", "FileManager.actions.confirmDelete": "Stai per eliminare questo file. Una volta eliminato, non potrà essere recuperato. Conferma l'azione.", + "FileManager.actions.confirmDeleteAllFiles": "Stai per eliminare tutti i risultati nella vista corrente. Una volta eliminati, non potranno essere recuperati. Conferma l'azione.", "FileManager.actions.confirmDeleteFolder": "Stai per eliminare questa cartella e tutto il suo contenuto. Questa azione è irreversibile. Conferma la tua decisione.", "FileManager.actions.confirmDeleteMultiFiles": "Stai per eliminare i {{count}} file selezionati. Una volta eliminati, non potranno essere recuperati. Conferma l'azione.", "FileManager.actions.confirmRemoveFromLibrary": "Stai per rimuovere {{count}} file selezionato/i dalla Libreria. Saranno comunque disponibili in Tutti i File. Conferma per continuare.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Data di Creazione", "FileManager.title.size": "Dimensione", "FileManager.title.title": "File", + "FileManager.total.allSelectedCount": "Tutti i {{count}} elementi sono selezionati.", + "FileManager.total.allSelectedFallback": "Tutti i risultati sono selezionati.", "FileManager.total.fileCount": "Totale {{count}} elementi", + "FileManager.total.loadedSelectedCount": "Selezionati {{count}} elementi caricati.", + "FileManager.total.selectAll": "Seleziona tutti i {{count}} elementi", + "FileManager.total.selectAllFallback": "Seleziona tutti gli elementi", "FileManager.total.selectedCount": "{{count}} elementi selezionati", "FileManager.view.list": "Vista Elenco", "FileManager.view.masonry": "Vista Griglia", diff --git a/locales/it-IT/file.json b/locales/it-IT/file.json index bae3b94437..649a84c0ec 100644 --- a/locales/it-IT/file.json +++ b/locales/it-IT/file.json @@ -129,6 +129,7 @@ "title": "Risorse", "toggleLeftPanel": "Mostra/Nascondi pannello sinistro", "uploadDock.body.collapse": "Comprimi", + "uploadDock.header.cancelAll": "Annulla tutto", "uploadDock.body.item.cancel": "Annulla", "uploadDock.body.item.cancelled": "Annullato", "uploadDock.body.item.done": "Caricato", diff --git a/locales/ja-JP/components.json b/locales/ja-JP/components.json index 28b696d95a..2e8d4c4714 100644 --- a/locales/ja-JP/components.json +++ b/locales/ja-JP/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "ファイルを複数のテキストブロックに分割し、ベクトル化した後、意味検索やファイル対話に使用できます", "FileManager.actions.chunkingUnsupported": "このファイルはチャンク分割をサポートしていません", "FileManager.actions.confirmDelete": "このファイルを削除しようとしています。削除後は復元できませんので、操作を確認してください", + "FileManager.actions.confirmDeleteAllFiles": "現在の表示内のすべての結果を削除しようとしています。削除すると元に戻せません。操作を確認してください", "FileManager.actions.confirmDeleteFolder": "このフォルダーとそのすべての内容を削除しようとしています。削除後は元に戻せません。本当に実行しますか?", "FileManager.actions.confirmDeleteMultiFiles": "選択した {{count}} 個のファイルを削除しようとしています。削除後は復元できませんので、操作を確認してください", "FileManager.actions.confirmRemoveFromLibrary": "{{count}} 件の選択されたファイルをライブラリから削除しようとしています。ファイルは「すべてのファイル」には引き続き表示されます。続行するには確認してください。", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "作成日時", "FileManager.title.size": "サイズ", "FileManager.title.title": "ファイル", + "FileManager.total.allSelectedCount": "全 {{count}} 件を選択済みです。", + "FileManager.total.allSelectedFallback": "すべての結果を選択済みです。", "FileManager.total.fileCount": "合計 {{count}} 件", + "FileManager.total.loadedSelectedCount": "読み込み済みの {{count}} 件を選択済みです。", + "FileManager.total.selectAll": "全 {{count}} 件を選択", + "FileManager.total.selectAllFallback": "すべての項目を選択", "FileManager.total.selectedCount": "選択済み {{count}} 件", "FileManager.view.list": "リスト表示", "FileManager.view.masonry": "グリッド表示", diff --git a/locales/ja-JP/file.json b/locales/ja-JP/file.json index 87250899cc..054afa5b33 100644 --- a/locales/ja-JP/file.json +++ b/locales/ja-JP/file.json @@ -129,6 +129,7 @@ "title": "リソース", "toggleLeftPanel": "左パネルの表示/非表示を切り替え", "uploadDock.body.collapse": "折りたたむ", + "uploadDock.header.cancelAll": "すべてキャンセル", "uploadDock.body.item.cancel": "キャンセル", "uploadDock.body.item.cancelled": "キャンセルされました", "uploadDock.body.item.done": "アップロード完了", diff --git a/locales/ko-KR/components.json b/locales/ko-KR/components.json index 8228327f5d..221908e3e8 100644 --- a/locales/ko-KR/components.json +++ b/locales/ko-KR/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "파일을 여러 텍스트 블록으로 분할하고 벡터화하여 의미 기반 검색 및 파일 대화에 사용할 수 있습니다.", "FileManager.actions.chunkingUnsupported": "이 파일은 분할을 지원하지 않습니다.", "FileManager.actions.confirmDelete": "이 파일을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?", + "FileManager.actions.confirmDeleteAllFiles": "현재 보기의 모든 결과를 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 작업을 확인해 주세요.", "FileManager.actions.confirmDeleteFolder": "이 폴더와 그 안의 모든 내용을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?", "FileManager.actions.confirmDeleteMultiFiles": "선택한 {{count}}개의 파일을 삭제하려고 합니다. 삭제 후에는 복구할 수 없습니다. 계속하시겠습니까?", "FileManager.actions.confirmRemoveFromLibrary": "선택한 파일 {{count}}개를 라이브러리에서 제거하려고 합니다. 이 파일들은 '모든 파일'에서 계속 확인할 수 있습니다. 계속하려면 확인을 눌러주세요.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "생성일", "FileManager.title.size": "크기", "FileManager.title.title": "파일", + "FileManager.total.allSelectedCount": "총 {{count}}개 항목이 모두 선택되었습니다.", + "FileManager.total.allSelectedFallback": "모든 결과가 선택되었습니다.", "FileManager.total.fileCount": "총 {{count}}개 항목", + "FileManager.total.loadedSelectedCount": "불러온 {{count}}개 항목이 선택되었습니다.", + "FileManager.total.selectAll": "총 {{count}}개 항목 선택", + "FileManager.total.selectAllFallback": "모든 항목 선택", "FileManager.total.selectedCount": "{{count}}개 선택됨", "FileManager.view.list": "목록 보기", "FileManager.view.masonry": "그리드 보기", diff --git a/locales/ko-KR/file.json b/locales/ko-KR/file.json index 40547eb91e..a70e3dabf9 100644 --- a/locales/ko-KR/file.json +++ b/locales/ko-KR/file.json @@ -129,6 +129,7 @@ "title": "리소스", "toggleLeftPanel": "왼쪽 패널 표시/숨기기", "uploadDock.body.collapse": "접기", + "uploadDock.header.cancelAll": "모두 취소", "uploadDock.body.item.cancel": "취소", "uploadDock.body.item.cancelled": "취소됨", "uploadDock.body.item.done": "업로드 완료", diff --git a/locales/nl-NL/components.json b/locales/nl-NL/components.json index 2a51fb669f..659ff85919 100644 --- a/locales/nl-NL/components.json +++ b/locales/nl-NL/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Splits het bestand in meerdere tekstfragmenten en voorzie ze van embedding voor semantisch zoeken en bestandsdialoog.", "FileManager.actions.chunkingUnsupported": "Dit bestand ondersteunt geen verdeling.", "FileManager.actions.confirmDelete": "Je staat op het punt dit bestand te verwijderen. Na verwijdering kan het niet worden hersteld. Bevestig je actie.", + "FileManager.actions.confirmDeleteAllFiles": "U staat op het punt alle resultaten in de huidige weergave te verwijderen. Na verwijdering kunnen ze niet worden hersteld. Bevestig uw actie.", "FileManager.actions.confirmDeleteFolder": "Je staat op het punt deze map en alle inhoud te verwijderen. Deze actie kan niet ongedaan worden gemaakt. Bevestig je beslissing.", "FileManager.actions.confirmDeleteMultiFiles": "Je staat op het punt de geselecteerde {{count}} bestanden te verwijderen. Na verwijdering kunnen ze niet worden hersteld. Bevestig je actie.", "FileManager.actions.confirmRemoveFromLibrary": "Je staat op het punt om {{count}} geselecteerd(e) bestand(en) uit de bibliotheek te verwijderen. Ze blijven beschikbaar in Alle bestanden. Bevestig om door te gaan.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Aangemaakt op", "FileManager.title.size": "Grootte", "FileManager.title.title": "Bestand", + "FileManager.total.allSelectedCount": "Alle {{count}} items zijn geselecteerd.", + "FileManager.total.allSelectedFallback": "Alle resultaten zijn geselecteerd.", "FileManager.total.fileCount": "Totaal {{count}} items", + "FileManager.total.loadedSelectedCount": "{{count}} geladen items geselecteerd.", + "FileManager.total.selectAll": "Alle {{count}} items selecteren", + "FileManager.total.selectAllFallback": "Alle items selecteren", "FileManager.total.selectedCount": "{{count}} items geselecteerd", "FileManager.view.list": "Lijstweergave", "FileManager.view.masonry": "Rasterweergave", diff --git a/locales/nl-NL/file.json b/locales/nl-NL/file.json index 91de3e5f2c..5cba37bcb6 100644 --- a/locales/nl-NL/file.json +++ b/locales/nl-NL/file.json @@ -129,6 +129,7 @@ "title": "Bronnen", "toggleLeftPanel": "Zijpaneel tonen/verbergen", "uploadDock.body.collapse": "Inklappen", + "uploadDock.header.cancelAll": "Alles annuleren", "uploadDock.body.item.cancel": "Annuleren", "uploadDock.body.item.cancelled": "Geannuleerd", "uploadDock.body.item.done": "Geüpload", diff --git a/locales/pl-PL/components.json b/locales/pl-PL/components.json index f60eb00cea..068c091017 100644 --- a/locales/pl-PL/components.json +++ b/locales/pl-PL/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Podziel plik na wiele fragmentów tekstu i osadź je do wyszukiwania semantycznego i dialogu z plikiem.", "FileManager.actions.chunkingUnsupported": "Ten plik nie obsługuje dzielenia na fragmenty.", "FileManager.actions.confirmDelete": "Zamierzasz usunąć ten plik. Po usunięciu nie będzie można go odzyskać. Potwierdź swoją decyzję.", + "FileManager.actions.confirmDeleteAllFiles": "Zamierzasz usunąć wszystkie wyniki w bieżącym widoku. Po usunięciu nie będzie można ich odzyskać. Potwierdź działanie.", "FileManager.actions.confirmDeleteFolder": "Zamierzasz usunąć ten folder wraz z całą jego zawartością. Tej operacji nie można cofnąć. Potwierdź swoją decyzję.", "FileManager.actions.confirmDeleteMultiFiles": "Zamierzasz usunąć wybrane {{count}} pliki. Po usunięciu nie będzie można ich odzyskać. Potwierdź swoją decyzję.", "FileManager.actions.confirmRemoveFromLibrary": "Zamierzasz usunąć {{count}} wybrany(-ch) plik(-ów) z biblioteki. Nadal będą dostępne w sekcji Wszystkie pliki. Potwierdź, aby kontynuować.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Utworzono", "FileManager.title.size": "Rozmiar", "FileManager.title.title": "Plik", + "FileManager.total.allSelectedCount": "Wybrano wszystkie {{count}} elementy.", + "FileManager.total.allSelectedFallback": "Wybrano wszystkie wyniki.", "FileManager.total.fileCount": "Łącznie {{count}} elementów", + "FileManager.total.loadedSelectedCount": "Wybrano {{count}} załadowanych elementów.", + "FileManager.total.selectAll": "Zaznacz wszystkie {{count}} elementy", + "FileManager.total.selectAllFallback": "Zaznacz wszystkie elementy", "FileManager.total.selectedCount": "Wybrano {{count}} elementów", "FileManager.view.list": "Widok listy", "FileManager.view.masonry": "Widok siatki", diff --git a/locales/pl-PL/file.json b/locales/pl-PL/file.json index dfdb02d99c..ee41430a05 100644 --- a/locales/pl-PL/file.json +++ b/locales/pl-PL/file.json @@ -129,6 +129,7 @@ "title": "Zasoby", "toggleLeftPanel": "Pokaż/Ukryj panel boczny", "uploadDock.body.collapse": "Zwiń", + "uploadDock.header.cancelAll": "Anuluj wszystko", "uploadDock.body.item.cancel": "Anuluj", "uploadDock.body.item.cancelled": "Anulowano", "uploadDock.body.item.done": "Przesłano", diff --git a/locales/pt-BR/components.json b/locales/pt-BR/components.json index 0066a0fa8a..2f21c5bf25 100644 --- a/locales/pt-BR/components.json +++ b/locales/pt-BR/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Divide o arquivo em vários trechos de texto e os incorpora para busca semântica e diálogo com arquivos.", "FileManager.actions.chunkingUnsupported": "Este arquivo não suporta divisão.", "FileManager.actions.confirmDelete": "Você está prestes a excluir este arquivo. Uma vez excluído, não poderá ser recuperado. Confirme sua ação.", + "FileManager.actions.confirmDeleteAllFiles": "Você está prestes a excluir todos os resultados da visualização atual. Depois de excluídos, eles não poderão ser recuperados. Confirme sua ação.", "FileManager.actions.confirmDeleteFolder": "Você está prestes a excluir esta pasta e todo o seu conteúdo. Esta ação não pode ser desfeita. Confirme sua decisão.", "FileManager.actions.confirmDeleteMultiFiles": "Você está prestes a excluir os {{count}} arquivos selecionados. Uma vez excluídos, não poderão ser recuperados. Confirme sua ação.", "FileManager.actions.confirmRemoveFromLibrary": "Você está prestes a remover {{count}} arquivo(s) selecionado(s) da Biblioteca. Eles ainda estarão disponíveis em Todos os Arquivos. Confirme para continuar.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Criado em", "FileManager.title.size": "Tamanho", "FileManager.title.title": "Arquivo", + "FileManager.total.allSelectedCount": "Todos os {{count}} itens estão selecionados.", + "FileManager.total.allSelectedFallback": "Todos os resultados estão selecionados.", "FileManager.total.fileCount": "Total de {{count}} itens", + "FileManager.total.loadedSelectedCount": "{{count}} itens carregados selecionados.", + "FileManager.total.selectAll": "Selecionar todos os {{count}} itens", + "FileManager.total.selectAllFallback": "Selecionar todos os itens", "FileManager.total.selectedCount": "{{count}} itens selecionados", "FileManager.view.list": "Visualização em Lista", "FileManager.view.masonry": "Visualização em Grade", diff --git a/locales/pt-BR/file.json b/locales/pt-BR/file.json index 540257f1c7..1b4e10d28b 100644 --- a/locales/pt-BR/file.json +++ b/locales/pt-BR/file.json @@ -129,6 +129,7 @@ "title": "Recursos", "toggleLeftPanel": "Mostrar/Ocultar Painel Esquerdo", "uploadDock.body.collapse": "Recolher", + "uploadDock.header.cancelAll": "Cancelar tudo", "uploadDock.body.item.cancel": "Cancelar", "uploadDock.body.item.cancelled": "Cancelado", "uploadDock.body.item.done": "Enviado", diff --git a/locales/ru-RU/components.json b/locales/ru-RU/components.json index efa464777f..dbbd55b97d 100644 --- a/locales/ru-RU/components.json +++ b/locales/ru-RU/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Разделить файл на несколько текстовых фрагментов и встроить их для семантического поиска и диалога с файлом.", "FileManager.actions.chunkingUnsupported": "Этот файл не поддерживает сегментацию.", "FileManager.actions.confirmDelete": "Вы собираетесь удалить этот файл. После удаления восстановление будет невозможно. Подтвердите действие.", + "FileManager.actions.confirmDeleteAllFiles": "Вы собираетесь удалить все результаты в текущем представлении. После удаления их нельзя будет восстановить. Пожалуйста, подтвердите действие.", "FileManager.actions.confirmDeleteFolder": "Вы собираетесь удалить эту папку и всё её содержимое. Это действие необратимо. Подтвердите решение.", "FileManager.actions.confirmDeleteMultiFiles": "Вы собираетесь удалить выбранные {{count}} файлов. После удаления восстановление будет невозможно. Подтвердите действие.", "FileManager.actions.confirmRemoveFromLibrary": "Вы собираетесь удалить {{count}} выбранный(е) файл(ы) из библиотеки. Они останутся доступны во всех файлах. Подтвердите, чтобы продолжить.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Дата создания", "FileManager.title.size": "Размер", "FileManager.title.title": "Файл", + "FileManager.total.allSelectedCount": "Все {{count}} элементов выбраны.", + "FileManager.total.allSelectedFallback": "Выбраны все результаты.", "FileManager.total.fileCount": "Всего {{count}} элементов", + "FileManager.total.loadedSelectedCount": "Выбрано {{count}} загруженных элементов.", + "FileManager.total.selectAll": "Выбрать все {{count}} элементов", + "FileManager.total.selectAllFallback": "Выбрать все элементы", "FileManager.total.selectedCount": "Выбрано {{count}} элементов", "FileManager.view.list": "Список", "FileManager.view.masonry": "Сетка", diff --git a/locales/ru-RU/file.json b/locales/ru-RU/file.json index 02a2977ccf..9e7c7614ba 100644 --- a/locales/ru-RU/file.json +++ b/locales/ru-RU/file.json @@ -129,6 +129,7 @@ "title": "Ресурсы", "toggleLeftPanel": "Показать/Скрыть левую панель", "uploadDock.body.collapse": "Свернуть", + "uploadDock.header.cancelAll": "Отменить все", "uploadDock.body.item.cancel": "Отменить", "uploadDock.body.item.cancelled": "Отменено", "uploadDock.body.item.done": "Загружено", diff --git a/locales/tr-TR/components.json b/locales/tr-TR/components.json index a31296f47f..730e40af83 100644 --- a/locales/tr-TR/components.json +++ b/locales/tr-TR/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Dosyayı birden fazla metin parçasına bölerek semantik arama ve dosya diyaloğu için gömülü hale getirir.", "FileManager.actions.chunkingUnsupported": "Bu dosya parçalamayı desteklemiyor.", "FileManager.actions.confirmDelete": "Bu dosyayı silmek üzeresiniz. Silindikten sonra geri alınamaz. Lütfen işlemi onaylayın.", + "FileManager.actions.confirmDeleteAllFiles": "Geçerli görünümdeki tüm sonuçları silmek üzeresiniz. Silindikten sonra geri getirilemezler. Lütfen işlemi onaylayın.", "FileManager.actions.confirmDeleteFolder": "Bu klasörü ve içindeki tüm öğeleri silmek üzeresiniz. Bu işlem geri alınamaz. Lütfen kararınızı onaylayın.", "FileManager.actions.confirmDeleteMultiFiles": "Seçilen {{count}} dosyayı silmek üzeresiniz. Silindikten sonra geri alınamazlar. Lütfen işlemi onaylayın.", "FileManager.actions.confirmRemoveFromLibrary": "{{count}} seçili dosyayı Kütüphane'den kaldırmak üzeresiniz. Dosyalar Tüm Dosyalar bölümünde erişilebilir olmaya devam edecek. Devam etmek için onaylayın.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Oluşturulma Tarihi", "FileManager.title.size": "Boyut", "FileManager.title.title": "Dosya", + "FileManager.total.allSelectedCount": "Tüm {{count}} öğe seçildi.", + "FileManager.total.allSelectedFallback": "Tüm sonuçlar seçildi.", "FileManager.total.fileCount": "Toplam {{count}} öğe", + "FileManager.total.loadedSelectedCount": "Yüklenen {{count}} öğe seçildi.", + "FileManager.total.selectAll": "Tüm {{count}} öğeyi seç", + "FileManager.total.selectAllFallback": "Tüm öğeleri seç", "FileManager.total.selectedCount": "Seçilen {{count}} öğe", "FileManager.view.list": "Liste Görünümü", "FileManager.view.masonry": "Izgara Görünümü", diff --git a/locales/tr-TR/file.json b/locales/tr-TR/file.json index 22e33144a4..53ba18c178 100644 --- a/locales/tr-TR/file.json +++ b/locales/tr-TR/file.json @@ -129,6 +129,7 @@ "title": "Kaynaklar", "toggleLeftPanel": "Sol Paneli Göster/Gizle", "uploadDock.body.collapse": "Daralt", + "uploadDock.header.cancelAll": "Tümünü iptal et", "uploadDock.body.item.cancel": "İptal Et", "uploadDock.body.item.cancelled": "İptal Edildi", "uploadDock.body.item.done": "Yüklendi", diff --git a/locales/vi-VN/components.json b/locales/vi-VN/components.json index 0bc01005dd..b6c5217e47 100644 --- a/locales/vi-VN/components.json +++ b/locales/vi-VN/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "Chia tệp thành nhiều đoạn văn bản và nhúng chúng để tìm kiếm ngữ nghĩa và đối thoại với tệp.", "FileManager.actions.chunkingUnsupported": "Tệp này không hỗ trợ phân đoạn.", "FileManager.actions.confirmDelete": "Bạn sắp xóa tệp này. Sau khi xóa, không thể khôi phục. Vui lòng xác nhận hành động của bạn.", + "FileManager.actions.confirmDeleteAllFiles": "Bạn sắp xóa toàn bộ kết quả trong chế độ xem hiện tại. Sau khi xóa, chúng sẽ không thể khôi phục. Vui lòng xác nhận thao tác.", "FileManager.actions.confirmDeleteFolder": "Bạn sắp xóa thư mục này và toàn bộ nội dung bên trong. Hành động này không thể hoàn tác. Vui lòng xác nhận quyết định của bạn.", "FileManager.actions.confirmDeleteMultiFiles": "Bạn sắp xóa {{count}} tệp đã chọn. Sau khi xóa, chúng không thể khôi phục. Vui lòng xác nhận hành động của bạn.", "FileManager.actions.confirmRemoveFromLibrary": "Bạn sắp xóa {{count}} tệp đã chọn khỏi Thư viện. Chúng vẫn sẽ có sẵn trong Tất cả Tệp. Xác nhận để tiếp tục.", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "Ngày tạo", "FileManager.title.size": "Kích thước", "FileManager.title.title": "Tệp", + "FileManager.total.allSelectedCount": "Đã chọn tất cả {{count}} mục.", + "FileManager.total.allSelectedFallback": "Đã chọn tất cả kết quả.", "FileManager.total.fileCount": "Tổng cộng {{count}} mục", + "FileManager.total.loadedSelectedCount": "Đã chọn {{count}} mục đã tải.", + "FileManager.total.selectAll": "Chọn tất cả {{count}} mục", + "FileManager.total.selectAllFallback": "Chọn tất cả mục", "FileManager.total.selectedCount": "Đã chọn {{count}} mục", "FileManager.view.list": "Chế độ danh sách", "FileManager.view.masonry": "Chế độ lưới", diff --git a/locales/vi-VN/file.json b/locales/vi-VN/file.json index a631a9deea..24d0b1694b 100644 --- a/locales/vi-VN/file.json +++ b/locales/vi-VN/file.json @@ -129,6 +129,7 @@ "title": "Tài Nguyên", "toggleLeftPanel": "Hiện/Ẩn Bảng Bên Trái", "uploadDock.body.collapse": "Thu Gọn", + "uploadDock.header.cancelAll": "Hủy tất cả", "uploadDock.body.item.cancel": "Hủy", "uploadDock.body.item.cancelled": "Đã hủy", "uploadDock.body.item.done": "Đã tải lên", diff --git a/locales/zh-CN/components.json b/locales/zh-CN/components.json index 989edc2f00..241a49482b 100644 --- a/locales/zh-CN/components.json +++ b/locales/zh-CN/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "将文件拆分为多个文本块并向量化后,可用于语义检索和文件对话", "FileManager.actions.chunkingUnsupported": "该文件不支持分块", "FileManager.actions.confirmDelete": "将删除该文件,删除后不可恢复。建议确认无误再继续", + "FileManager.actions.confirmDeleteAllFiles": "将删除当前结果中的全部数据,删除后不可恢复。建议确认无误再继续", "FileManager.actions.confirmDeleteFolder": "将删除该文件夹及其内容,删除后不可恢复。建议确认无误再继续", "FileManager.actions.confirmDeleteMultiFiles": "将删除选中的 {{count}} 个文件,删除后不可恢复。建议确认无误再继续", "FileManager.actions.confirmRemoveFromLibrary": "您即将从资料库中移除 {{count}} 个已选文件。它们仍可在“所有文件”中访问。请确认以继续。", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "创建时间", "FileManager.title.size": "大小", "FileManager.title.title": "文件", + "FileManager.total.allSelectedCount": "已选中全部 {{count}} 项。", + "FileManager.total.allSelectedFallback": "已选中全部结果。", "FileManager.total.fileCount": "共 {{count}} 项", + "FileManager.total.loadedSelectedCount": "已选中当前已加载的 {{count}} 项。", + "FileManager.total.selectAll": "选择全部 {{count}} 项", + "FileManager.total.selectAllFallback": "选择全部项目", "FileManager.total.selectedCount": "已选 {{count}} 项", "FileManager.view.list": "列表视图", "FileManager.view.masonry": "网格视图", diff --git a/locales/zh-CN/file.json b/locales/zh-CN/file.json index 9739e95ee4..10672c481e 100644 --- a/locales/zh-CN/file.json +++ b/locales/zh-CN/file.json @@ -137,6 +137,7 @@ "uploadDock.body.item.processing": "文件处理中…", "uploadDock.body.item.restTime": "剩余 {{time}}", "uploadDock.fileQueueInfo": "正在上传前 {{count}} 个文件,剩余 {{remaining}} 个文件将排队上传", + "uploadDock.header.cancelAll": "全部取消", "uploadDock.totalCount": "共 {{count}} 项", "uploadDock.uploadStatus.cancelled": "上传已取消", "uploadDock.uploadStatus.error": "上传出错", diff --git a/locales/zh-TW/components.json b/locales/zh-TW/components.json index a4b8beb145..b0b2d1e8e3 100644 --- a/locales/zh-TW/components.json +++ b/locales/zh-TW/components.json @@ -12,6 +12,7 @@ "FileManager.actions.chunkingTooltip": "將文件拆分為多個文本塊並向量化後,可用於語義檢索和文件對話", "FileManager.actions.chunkingUnsupported": "該檔案不支援分塊", "FileManager.actions.confirmDelete": "即將刪除該文件,刪除後將無法找回,請確認你的操作", + "FileManager.actions.confirmDeleteAllFiles": "將刪除目前結果中的全部資料,刪除後不可復原。建議確認無誤再繼續", "FileManager.actions.confirmDeleteFolder": "即將刪除該資料夾及其所有內容,刪除後將無法恢復,請確認你的操作", "FileManager.actions.confirmDeleteMultiFiles": "即將刪除選中的 {{count}} 個文件,刪除後將無法找回,請確認你的操作", "FileManager.actions.confirmRemoveFromLibrary": "您即將從資料庫中移除 {{count}} 個已選取的檔案。這些檔案仍會保留在「所有檔案」中。請確認以繼續。", @@ -51,7 +52,12 @@ "FileManager.title.createdAt": "創建時間", "FileManager.title.size": "大小", "FileManager.title.title": "文件", + "FileManager.total.allSelectedCount": "已選取全部 {{count}} 項。", + "FileManager.total.allSelectedFallback": "已選取全部結果。", "FileManager.total.fileCount": "共 {{count}} 項", + "FileManager.total.loadedSelectedCount": "已選取目前已載入的 {{count}} 項。", + "FileManager.total.selectAll": "選取全部 {{count}} 項", + "FileManager.total.selectAllFallback": "選取全部項目", "FileManager.total.selectedCount": "已選 {{count}} 項", "FileManager.view.list": "清單檢視", "FileManager.view.masonry": "網格檢視", diff --git a/locales/zh-TW/file.json b/locales/zh-TW/file.json index 61dc66237e..b688c4d855 100644 --- a/locales/zh-TW/file.json +++ b/locales/zh-TW/file.json @@ -129,6 +129,7 @@ "title": "資源", "toggleLeftPanel": "顯示/隱藏左側面板", "uploadDock.body.collapse": "收起", + "uploadDock.header.cancelAll": "全部取消", "uploadDock.body.item.cancel": "取消", "uploadDock.body.item.cancelled": "已取消", "uploadDock.body.item.done": "已上傳", diff --git a/packages/database/src/repositories/knowledge/index.ts b/packages/database/src/repositories/knowledge/index.ts index 0ab28d6f1b..c40cde4428 100644 --- a/packages/database/src/repositories/knowledge/index.ts +++ b/packages/database/src/repositories/knowledge/index.ts @@ -11,8 +11,10 @@ export interface KnowledgeItem { chunkTaskId?: string | null; content?: string | null; createdAt: Date; + documentId?: string | null; editorData?: Record | null; embeddingTaskId?: string | null; + fileId?: string | null; fileType: string; id: string; metadata?: Record | null; @@ -136,8 +138,10 @@ export class KnowledgeRepo { chunkTaskId: row.chunk_task_id, content: row.content, createdAt: new Date(row.created_at), + documentId: row.document_id, editorData, embeddingTaskId: row.embedding_task_id, + fileId: row.file_id, fileType: row.file_type, id: row.id, metadata, @@ -161,6 +165,8 @@ export class KnowledgeRepo { const fileQuery = sql` SELECT COALESCE(d.id, f.id) as id, + f.id as file_id, + d.id as document_id, f.name, f.file_type, f.size, @@ -187,6 +193,8 @@ export class KnowledgeRepo { const documentQuery = sql` SELECT id, + file_id, + id as document_id, COALESCE(title, filename, 'Untitled') as name, file_type, total_char_count as size, @@ -243,8 +251,10 @@ export class KnowledgeRepo { chunkTaskId: row.chunk_task_id, content: row.content, createdAt: new Date(row.created_at), + documentId: row.document_id, editorData, embeddingTaskId: row.embedding_task_id, + fileId: row.file_id, fileType: row.file_type, id: row.id, metadata, @@ -369,6 +379,8 @@ export class KnowledgeRepo { return sql` SELECT COALESCE(d.id, f.id) as id, + f.id as file_id, + d.id as document_id, f.name, f.file_type, f.size, @@ -407,6 +419,8 @@ export class KnowledgeRepo { return sql` SELECT COALESCE(d.id, f.id) as id, + f.id as file_id, + d.id as document_id, f.name, f.file_type, f.size, @@ -475,6 +489,8 @@ export class KnowledgeRepo { return sql` SELECT NULL::varchar(30) as id, + NULL::varchar(30) as file_id, + NULL::varchar(30) as document_id, NULL::text as name, NULL::varchar(255) as file_type, NULL::integer as size, @@ -536,6 +552,8 @@ export class KnowledgeRepo { return sql` SELECT NULL::varchar(30) as id, + NULL::varchar(30) as file_id, + NULL::varchar(30) as document_id, NULL::text as name, NULL::varchar(255) as file_type, NULL::integer as size, @@ -562,6 +580,8 @@ export class KnowledgeRepo { return sql` SELECT d.id, + d.file_id, + d.id as document_id, COALESCE(d.title, d.filename, 'Untitled') as name, d.file_type, d.total_char_count as size, @@ -583,6 +603,8 @@ export class KnowledgeRepo { return sql` SELECT id, + file_id, + id as document_id, COALESCE(title, filename, 'Untitled') as name, file_type, total_char_count as size, diff --git a/packages/types/src/files/list.ts b/packages/types/src/files/list.ts index aa053688d8..7420c823f6 100644 --- a/packages/types/src/files/list.ts +++ b/packages/types/src/files/list.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; -import type { AsyncTaskStatus } from '../asyncTask'; +import type { AsyncTaskStatus, FileParsingTask } from '../asyncTask'; + +export interface KnowledgeItemStatus extends FileParsingTask { + id: string; +} export interface FileListItem { chunkCount: number | null; diff --git a/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx b/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx index ca9911a652..c520279870 100644 --- a/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx +++ b/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx @@ -4,7 +4,7 @@ import 'antd/dist/reset.css'; import { ConfigProvider, ThemeProvider } from '@lobehub/ui'; import { App } from 'antd'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import Link from 'next/link'; import { type PropsWithChildren } from 'react'; import { memo } from 'react'; @@ -35,7 +35,7 @@ const AuthThemeLite = memo(({ children, globalCDN }) => { ( return ( {open && ( - ( }} > {children} - + )} ); diff --git a/src/features/Conversation/Messages/components/SearchGrounding.tsx b/src/features/Conversation/Messages/components/SearchGrounding.tsx index 1a77eb10e9..f5f609e749 100644 --- a/src/features/Conversation/Messages/components/SearchGrounding.tsx +++ b/src/features/Conversation/Messages/components/SearchGrounding.tsx @@ -1,7 +1,7 @@ import { Flexbox, Icon, SearchResultCards, Tag } from '@lobehub/ui'; import { createStaticStyles, cssVar, cx } from 'antd-style'; import { ChevronDown, ChevronRight, Globe, Images } from 'lucide-react'; -import { AnimatePresence, m as motion } from 'motion/react'; +import { AnimatePresence, m } from 'motion/react'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -195,7 +195,7 @@ const SearchGrounding = memo( {showDetail && ( - ( )} - + )} diff --git a/src/features/LibraryModal/AddFilesToKnowledgeBase/SelectForm.tsx b/src/features/LibraryModal/AddFilesToKnowledgeBase/SelectForm.tsx index f1df48399d..e31cf7ea53 100644 --- a/src/features/LibraryModal/AddFilesToKnowledgeBase/SelectForm.tsx +++ b/src/features/LibraryModal/AddFilesToKnowledgeBase/SelectForm.tsx @@ -11,91 +11,96 @@ interface CreateFormProps { fileIds: string[]; knowledgeBaseId?: string; onClose?: () => void; + resolveFileIds?: () => Promise; + selectedCount?: number; } -const SelectForm = memo(({ onClose, knowledgeBaseId, fileIds }) => { - const { t } = useTranslation('knowledgeBase'); - const [loading, setLoading] = useState(false); +const SelectForm = memo( + ({ onClose, knowledgeBaseId, fileIds, resolveFileIds, selectedCount }) => { + const { t } = useTranslation('knowledgeBase'); + const [loading, setLoading] = useState(false); - const { message } = App.useApp(); - const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [ - s.useFetchKnowledgeBaseList, - s.addFilesToKnowledgeBase, - ]); - const { data, isLoading } = useFetchKnowledgeBaseList(); - const onFinish = async (values: { id: string }) => { - setLoading(true); + const { message } = App.useApp(); + const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [ + s.useFetchKnowledgeBaseList, + s.addFilesToKnowledgeBase, + ]); + const { data, isLoading } = useFetchKnowledgeBaseList(); + const onFinish = async (values: { id: string }) => { + setLoading(true); - try { - await addFilesToKnowledgeBase(values.id, fileIds); - setLoading(false); - message.success({ - content: ( - , - , - ]} - /> - ), - }); - - onClose?.(); - } catch (e) { - console.error(e); - setLoading(false); - } - }; - - return ( -
- {t('addToKnowledgeBase.confirm')} - - } - items={[ - { - children: ( - - - {t('addToKnowledgeBase.totalFiles', { count: fileIds.length })} - - ), - noStyle: true, - }, - { - children: ( - item.id !== knowledgeBaseId) + .map((item) => ({ + label: ( + + + {item.name} + + ), + value: item.id, + }))} + /> + ), + label: t('addToKnowledgeBase.id.title'), + name: 'id', + rules: [{ message: t('addToKnowledgeBase.id.required'), required: true }], + }, + ]} + onFinish={onFinish} + /> + ); + }, +); export default SelectForm; diff --git a/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx index b6bdec694d..c1f3114ca2 100644 --- a/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx +++ b/src/features/LibraryModal/AddFilesToKnowledgeBase/index.tsx @@ -9,28 +9,46 @@ interface AddFilesToKnowledgeBaseModalProps { fileIds: string[]; knowledgeBaseId?: string; onClose?: () => void; + resolveFileIds?: () => Promise; + selectedCount?: number; } interface ModalContentProps { fileIds: string[]; knowledgeBaseId?: string; + resolveFileIds?: () => Promise; + selectedCount?: number; } -const ModalContent = memo(({ fileIds, knowledgeBaseId }) => { - const { t } = useTranslation('knowledgeBase'); - const { close } = useModalContext(); - return ( - <> - - - {t('addToKnowledgeBase.title')} - - - - - - ); -}); +const ModalContent = memo( + ({ fileIds, knowledgeBaseId, resolveFileIds, selectedCount }) => { + const { t } = useTranslation('knowledgeBase'); + const { close } = useModalContext(); + return ( + <> + + + {t('addToKnowledgeBase.title')} + + + + + + ); + }, +); ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent'; @@ -40,7 +58,12 @@ export const useAddFilesToKnowledgeBaseModal = () => { afterClose: params?.onClose, children: ( }> - + ), footer: null, diff --git a/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx b/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx index a9db698cf9..f86425e1f8 100644 --- a/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx +++ b/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx @@ -1,6 +1,6 @@ import { Flexbox, Highlighter, Tag } from '@lobehub/ui'; import { cssVar } from 'antd-style'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,7 +14,7 @@ const ErrorDetails = memo<{ return ( - )} - + ); }); diff --git a/src/features/MCP/MCPInstallProgress/MCPConfigForm.tsx b/src/features/MCP/MCPInstallProgress/MCPConfigForm.tsx index cf12462ba8..046913b98a 100644 --- a/src/features/MCP/MCPInstallProgress/MCPConfigForm.tsx +++ b/src/features/MCP/MCPInstallProgress/MCPConfigForm.tsx @@ -1,7 +1,7 @@ import { Button, Flexbox, Form, Markdown } from '@lobehub/ui'; import { Form as AForm } from 'antd'; import { createStaticStyles } from 'antd-style'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -71,13 +71,13 @@ const MCPConfigForm = memo(({ configSchema, identifier, onCa }; return ( - - (({ configSchema, identifier, onCa {t('mcpInstall.configurationDescription')} - + - (({ configSchema, identifier, onCa }))} onFinish={handleSubmit} /> - + - (({ configSchema, identifier, onCa - - + + ); }); diff --git a/src/features/MCP/MCPInstallProgress/MCPDependenciesGuide.tsx b/src/features/MCP/MCPInstallProgress/MCPDependenciesGuide.tsx index bf89d4b3b2..a47ef83a97 100644 --- a/src/features/MCP/MCPInstallProgress/MCPDependenciesGuide.tsx +++ b/src/features/MCP/MCPInstallProgress/MCPDependenciesGuide.tsx @@ -2,7 +2,7 @@ import { Button, Flexbox, Markdown, Snippet, Text } from '@lobehub/ui'; import { Card, Space } from 'antd'; import { createStaticStyles, cssVar } from 'antd-style'; import { AlertTriangle, CheckCircle, ExternalLink, Terminal } from 'lucide-react'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -92,13 +92,13 @@ const MCPDependenciesGuide = memo( }; return ( - - ( {t('mcpInstall.dependenciesDescription')} - + - ( ))} - + - ( - - + + ); }, ); diff --git a/src/features/MCP/MCPInstallProgress/index.tsx b/src/features/MCP/MCPInstallProgress/index.tsx index 7a076c9e52..65c0822167 100644 --- a/src/features/MCP/MCPInstallProgress/index.tsx +++ b/src/features/MCP/MCPInstallProgress/index.tsx @@ -2,7 +2,7 @@ import { Flexbox, Text } from '@lobehub/ui'; import { Progress } from 'antd'; import { cssVar } from 'antd-style'; import isEqual from 'fast-deep-equal'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -30,7 +30,7 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => { return ( <> {installProgress && !hasError && !needsDependencies && ( - (({ identifier }) => { )} - + )} {hasError && errorInfo && ( - (({ identifier }) => { - + )} {needsDependencies && installProgress?.systemDependencies && ( - (({ identifier }) => { systemDependencies={installProgress.systemDependencies} /> - + )} {needsConfig && installProgress && ( - (({ identifier }) => { }} > - + )} ); diff --git a/src/features/NavPanel/components/NavPanelDraggable.tsx b/src/features/NavPanel/components/NavPanelDraggable.tsx index e20dd11ceb..25f277c669 100644 --- a/src/features/NavPanel/components/NavPanelDraggable.tsx +++ b/src/features/NavPanel/components/NavPanelDraggable.tsx @@ -2,7 +2,7 @@ import { DraggablePanel, Freeze } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { AnimatePresence, motion, useIsPresent } from 'motion/react'; +import { AnimatePresence, m, useIsPresent } from 'motion/react'; import { type ReactNode } from 'react'; import { memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react'; @@ -227,7 +227,7 @@ export const NavPanelDraggable = memo(({ activeContent }
{shouldUseMotion ? ( - (({ activeContent } variants={motionVariants} > {activeContent.node} - + ) : (
diff --git a/src/features/PageEditor/StoreUpdater.tsx b/src/features/PageEditor/StoreUpdater.tsx index c5998cc19b..dae2ea81e4 100644 --- a/src/features/PageEditor/StoreUpdater.tsx +++ b/src/features/PageEditor/StoreUpdater.tsx @@ -8,6 +8,8 @@ import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-pag import { type PublicState } from './store'; import { usePageEditorStore, useStoreApi } from './store'; +type PageAgentEditor = NonNullable[0]>; + export interface StoreUpdaterProps extends Partial { pageId?: string; } @@ -37,6 +39,7 @@ const StoreUpdater = memo( const editor = usePageEditorStore((s) => s.editor); const initMeta = usePageEditorStore((s) => s.initMeta); + const pageAgentEditor = editor as unknown as PageAgentEditor | undefined; // Update store with props useStoreUpdater('documentId', pageId); @@ -56,13 +59,13 @@ const StoreUpdater = memo( // Connect editor to page agent runtime useEffect(() => { - if (editor) { - pageAgentRuntime.setEditor(editor); + if (pageAgentEditor) { + pageAgentRuntime.setEditor(pageAgentEditor); } return () => { pageAgentRuntime.setEditor(null); }; - }, [editor]); + }, [pageAgentEditor]); // Connect title handlers and document ID to page agent runtime useEffect(() => { diff --git a/src/features/PageExplorer/PageExplorerPlaceholder.tsx b/src/features/PageExplorer/PageExplorerPlaceholder.tsx index 651bc25df5..9f22ec49f6 100644 --- a/src/features/PageExplorer/PageExplorerPlaceholder.tsx +++ b/src/features/PageExplorer/PageExplorerPlaceholder.tsx @@ -100,11 +100,7 @@ const PageExplorerPlaceholder = memo( createDocument, currentFolderId: null, libraryId: knowledgeBaseId ?? null, - refetchResources: async () => { - const { revalidateResources } = await import('@/store/file/slices/resource/hooks'); - await revalidateResources(); - await fetchDocuments(); - }, + refetchResources: fetchDocuments, t, }); @@ -113,10 +109,6 @@ const PageExplorerPlaceholder = memo( event: React.ChangeEvent, ) => { await notionImport.handleNotionImport(event); - // Fetch documents to update the UI immediately - // The hook calls refreshFileList which invalidates SWR cache, - // but we need to explicitly fetch to update the zustand store - await fetchDocuments(); }; const handleCreateDocument = async (content: string, title: string) => { diff --git a/src/features/ResourceManager/components/Explorer/Header/index.tsx b/src/features/ResourceManager/components/Explorer/Header/index.tsx index 2d4e004eae..c54ecc168b 100644 --- a/src/features/ResourceManager/components/Explorer/Header/index.tsx +++ b/src/features/ResourceManager/components/Explorer/Header/index.tsx @@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next'; import NavHeader from '@/features/NavHeader'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { getExplorerSelectedCount } from '@/routes/(main)/resource/features/store/selectors'; +import { useFileStore } from '@/store/file'; import { FilesTabs } from '@/types/files'; import AddButton from '../../Header/AddButton'; @@ -26,17 +28,24 @@ const Header = memo(() => { const { modal, message } = App.useApp(); // Get state and actions from store - const [libraryId, category, onActionClick, selectFileIds] = useResourceManagerStore((s) => [ - s.libraryId, - s.category, - s.onActionClick, - s.selectedFileIds, - ]); - const selectCount = selectFileIds.length; - const isMultiSelected = selectCount > 1; + const [libraryId, category, onActionClick, selectAllState, selectFileIds] = + useResourceManagerStore((s) => [ + s.libraryId, + s.category, + s.onActionClick, + s.selectAllState, + s.selectedFileIds, + ]); + const total = useFileStore((s) => s.total); + const selectCount = getExplorerSelectedCount({ + selectAllState, + selectedIds: selectFileIds, + total, + }); + const hasSelected = selectAllState === 'all' || selectCount > 0; // If no libraryId, show category name or "Resource" for All - const leftContent = isMultiSelected ? ( + const leftContent = hasSelected ? ( {libraryId ? ( { await onActionClick('delete'); message.success(t('FileManager.actions.deleteSuccess')); }, - title: t('FileManager.actions.confirmDeleteMultiFiles', { count: selectCount }), + title: t( + selectAllState === 'all' + ? 'FileManager.actions.confirmDeleteAllFiles' + : 'FileManager.actions.confirmDeleteMultiFiles', + { count: selectCount }, + ), }); }} /> diff --git a/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx b/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx index b58dd664db..48d19ff5b3 100644 --- a/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx +++ b/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { shallow } from 'zustand/shallow'; import RepoIcon from '@/components/LibIcon'; +import { useKnowledgeBaseListContext } from '@/features/ResourceManager/components/KnowledgeBaseListProvider'; import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; import { useAppOrigin } from '@/hooks/useAppOrigin'; @@ -64,17 +65,11 @@ export const useFileItemDropdown = ({ }), shallow, ); - const [removeFilesFromKnowledgeBase, addFilesToKnowledgeBase, useFetchKnowledgeBaseList] = - useKnowledgeBaseStore((s) => [ - s.removeFilesFromKnowledgeBase, - s.addFilesToKnowledgeBase, - s.useFetchKnowledgeBaseList, - ]); - - // Fetch knowledge bases - SWR caches this across all dropdown instances - // Only the first call fetches from server, subsequent calls use cache - // The expensive menu computation is deferred until dropdown opens (menuItems is a function) - const { data: libraries } = useFetchKnowledgeBaseList(); + const [removeFilesFromKnowledgeBase, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [ + s.removeFilesFromKnowledgeBase, + s.addFilesToKnowledgeBase, + ]); + const libraries = useKnowledgeBaseListContext(); const isInLibrary = !!libraryId; const isFolder = fileType === 'custom/folder'; @@ -94,7 +89,7 @@ export const useFileItemDropdown = ({ const menuItems = useCallback(() => { // Filter out current knowledge base and create submenu items - const availableKnowledgeBases = (libraries || []).filter((kb) => kb.id !== libraryId); + const availableKnowledgeBases = libraries.filter((kb) => kb.id !== libraryId); // Submenu for adding files to a library (used when NOT in a library) const addToKnowledgeBaseSubmenu: ItemType[] = availableKnowledgeBases.map((kb) => ({ @@ -319,7 +314,7 @@ export const useFileItemDropdown = ({ if (libraryId) { await clearTreeFolderCache(libraryId); } - await refreshFileList(); + await refreshFileList({ revalidateResources: false }); message.success(t('FileManager.actions.deleteSuccess')); }, @@ -330,7 +325,7 @@ export const useFileItemDropdown = ({ ).filter(Boolean); }, [ addFilesToKnowledgeBase, - clearTreeFolderCache, + appOrigin, deleteResource, filename, id, diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemActions.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemActions.tsx new file mode 100644 index 0000000000..bd8d4fe8e7 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemActions.tsx @@ -0,0 +1,97 @@ +import type { IAsyncTaskError } from '@lobechat/types'; +import { Button, Flexbox, stopPropagation } from '@lobehub/ui'; +import type { ItemType } from 'antd/es/menu/interface'; +import { isNull } from 'es-toolkit/compat'; +import { FileBoxIcon } from 'lucide-react'; + +import DropdownMenu from '../../ItemDropdown/DropdownMenu'; +import ChunksBadge from './ChunkTag'; +import { styles } from './styles'; + +interface FileListItemActionsProps { + chunkCount?: number | null; + chunkingError?: IAsyncTaskError | null; + chunkingStatus?: unknown; + embeddingError?: IAsyncTaskError | null; + embeddingStatus?: unknown; + finishEmbedding?: boolean; + id: string; + isCreatingFileParseTask: boolean; + isFolder: boolean; + isPage: boolean; + isSupportedForChunking: boolean; + menuItems: ItemType[] | (() => ItemType[]); + parseFiles: (ids: string[]) => void; + t: any; +} + +const FileListItemActions = ({ + chunkCount, + chunkingError, + chunkingStatus, + embeddingError, + embeddingStatus, + finishEmbedding, + id, + isCreatingFileParseTask, + isFolder, + isPage, + isSupportedForChunking, + menuItems, + parseFiles, + t, +}: FileListItemActionsProps) => ( + + {!isFolder && + !isPage && + (isCreatingFileParseTask || isNull(chunkingStatus) || !chunkingStatus ? ( +
+ +
+ ) : ( +
+ +
+ ))} + +
+); + +export default FileListItemActions; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemName.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemName.tsx new file mode 100644 index 0000000000..fa87f2602c --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/FileListItemName.tsx @@ -0,0 +1,85 @@ +import { Center, Flexbox, Icon, stopPropagation } from '@lobehub/ui'; +import { Input } from 'antd'; +import { FileText, FolderIcon } from 'lucide-react'; + +import FileIcon from '@/components/FileIcon'; + +import { styles } from './styles'; +import TruncatedFileName from './TruncatedFileName'; + +interface FileListItemNameProps { + emoji?: string | null; + fallbackName: string; + fileType: string; + inputRef: any; + isFolder: boolean; + isPage: boolean; + isRenaming: boolean; + name: string; + onRenameCancel: () => void; + onRenameConfirm: () => void; + onRenamingValueChange: (value: string) => void; + renamingValue: string; +} + +const FileListItemName = ({ + emoji, + fallbackName, + fileType, + inputRef, + isFolder, + isPage, + isRenaming, + name, + onRenameCancel, + onRenameConfirm, + onRenamingValueChange, + renamingValue, +}: FileListItemNameProps) => ( + + + {isFolder ? ( + + ) : isPage ? ( + emoji ? ( + {emoji} + ) : ( +
+ +
+ ) + ) : ( + + )} +
+ {isRenaming && isFolder ? ( + onRenamingValueChange(e.target.value)} + onClick={stopPropagation} + onPointerDown={stopPropagation} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onRenameConfirm(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onRenameCancel(); + } + }} + /> + ) : ( + + )} +
+); + +export default FileListItemName; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/constants.ts b/src/features/ResourceManager/components/Explorer/ListView/ListItem/constants.ts new file mode 100644 index 0000000000..91ac48a5b6 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/constants.ts @@ -0,0 +1,2 @@ +export const FILE_DATE_WIDTH = 160; +export const FILE_SIZE_WIDTH = 140; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx index aa6ff6e581..06ebb9fb6f 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx @@ -1,127 +1,27 @@ -import { - Button, - Center, - Checkbox, - ContextMenuTrigger, - Flexbox, - Icon, - stopPropagation, -} from '@lobehub/ui'; -import { App, Input } from 'antd'; -import { createStaticStyles, cssVar, cx } from 'antd-style'; -import dayjs from 'dayjs'; -import { isNull } from 'es-toolkit/compat'; -import { FileBoxIcon, FileText, FolderIcon } from 'lucide-react'; -import { type DragEvent } from 'react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Center, Checkbox, ContextMenuTrigger, Flexbox } from '@lobehub/ui'; +import { cssVar, cx } from 'antd-style'; +import { isEqual } from 'es-toolkit'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { shallow } from 'zustand/shallow'; -import FileIcon from '@/components/FileIcon'; -import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; -import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; -import { - getTransparentDragImage, - useDragActive, - useDragState, -} from '@/routes/(main)/resource/features/DndContextWrapper'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors'; import { fileManagerSelectors, useFileStore } from '@/store/file'; -import { type FileListItem as FileListItemType } from '@/types/files'; +import type { FileListItem as FileListItemType } from '@/types/files'; import { formatSize } from '@/utils/format'; -import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported'; import { useFileItemClick } from '../../hooks/useFileItemClick'; -import DropdownMenu from '../../ItemDropdown/DropdownMenu'; import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown'; -import ChunksBadge from './ChunkTag'; -import TruncatedFileName from './TruncatedFileName'; +import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './constants'; +import FileListItemActions from './FileListItemActions'; +import FileListItemName from './FileListItemName'; +import { styles } from './styles'; +import { useFileListItemDrag } from './useFileListItemDrag'; +import { useFileListItemMeta } from './useFileListItemMeta'; +import { useFileListItemRename } from './useFileListItemRename'; -export const FILE_DATE_WIDTH = 160; -export const FILE_SIZE_WIDTH = 140; - -const styles = createStaticStyles(({ css }) => { - return { - container: css` - cursor: pointer; - min-width: 800px; - - &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - - dragOver: css` - outline: 1px dashed ${cssVar.colorPrimaryBorder}; - outline-offset: -2px; - - &, - &:hover { - background: ${cssVar.colorPrimaryBg}; - } - `, - - dragging: css` - will-change: transform; - opacity: 0.5; - `, - - evenRow: css` - background: ${cssVar.colorFillQuaternary}; - - /* Hover effect overrides zebra striping on the hovered row only */ - &:hover { - background: ${cssVar.colorFillTertiary}; - } - - /* Hide zebra striping when any row is hovered */ - .any-row-hovered & { - background: transparent; - } - - /* But keep hover effect on the actual hovered row */ - .any-row-hovered &:hover { - background: ${cssVar.colorFillTertiary}; - } - `, - - hover: css` - opacity: 0; - - &[data-popup-open], - .file-list-item-group:hover & { - opacity: 1; - } - `, - item: css` - padding-block: 0; - padding-inline: 0 24px; - color: ${cssVar.colorTextSecondary}; - `, - name: css` - overflow: hidden; - flex: 1; - - min-width: 0; - margin-inline-start: 12px; - - color: ${cssVar.colorText}; - white-space: nowrap; - `, - nameContainer: css` - overflow: hidden; - flex: 1; - min-width: 0; - `, - selected: css` - background: ${cssVar.colorFillTertiary}; - - &:hover { - background: ${cssVar.colorFillSecondary}; - } - `, - }; -}); +export { FILE_DATE_WIDTH, FILE_SIZE_WIDTH }; interface FileListItemProps extends FileListItemType { columnWidths: { @@ -130,453 +30,226 @@ interface FileListItemProps extends FileListItemType { size: number; }; index: number; - isAnyRowHovered: boolean; - onHoverChange: (isHovered: boolean) => void; onSelectedChange: (id: string, selected: boolean, shiftKey: boolean, index: number) => void; - pendingRenameItemId?: string | null; selected?: boolean; slug?: string | null; } -const FileListItem = memo( - ({ - size, - chunkingError, - columnWidths, - embeddingError, - embeddingStatus, - finishEmbedding, - chunkCount, - url, +const FileListItem = ({ + chunkCount, + chunkingError, + chunkingStatus, + columnWidths, + createdAt, + embeddingError, + embeddingStatus, + fileType, + finishEmbedding, + id, + index, + metadata, + name, + onSelectedChange, + selected, + size, + slug, + sourceType, + url, +}: FileListItemProps) => { + const { t } = useTranslation(['components', 'file']); + const fileStoreState = useFileStore( + (s) => ({ + isCreatingFileParseTask: fileManagerSelectors.isCreatingFileParseTask(id)(s), + parseFiles: s.parseFilesToChunks, + refreshFileList: s.refreshFileList, + updateResource: s.updateResource, + }), + isEqual, + ); + const resourceManagerState = useResourceManagerStore( + (s) => ({ + isPendingRename: s.pendingRenameItemId === id, + libraryId: s.libraryId, + selected: isExplorerItemSelected({ + id, + selectAllState: s.selectAllState, + selectedIds: s.selectedFileIds, + }), + setPendingRenameItemId: s.setPendingRenameItemId, + }), + shallow, + ); + const isSelected = selected ?? resourceManagerState.selected; + + const { displayTime, emoji, isFolder, isPage, isSupportedForChunking } = useFileListItemMeta({ + createdAt, + fileType, + metadata, name, + sourceType, + }); + const { + handleDragEnd, + handleDragLeave, + handleDragOver, + handleDragStart, + handleDrop, + isDragging, + isOver, + } = useFileListItemDrag({ fileType, id, - createdAt, - selected, - chunkingStatus, - onSelectedChange, - index, - metadata, + isFolder, + libraryId: resourceManagerState.libraryId, + name, sourceType, + }); + const { + handleRenameCancel, + handleRenameConfirm, + handleRenameStart, + inputRef, + isRenaming, + renamingValue, + setRenamingValue, + } = useFileListItemRename({ + id, + isPendingRename: resourceManagerState.isPendingRename, + isFolder, + libraryId: resourceManagerState.libraryId, + name, + refreshFileList: fileStoreState.refreshFileList, + setPendingRenameItemId: resourceManagerState.setPendingRenameItemId, + updateResource: fileStoreState.updateResource, + }); + + const handleItemClick = useFileItemClick({ + id, + isFolder, + isPage, + libraryId: resourceManagerState.libraryId, slug, - pendingRenameItemId, - onHoverChange, - }) => { - const { t } = useTranslation(['components', 'file']); - const { message } = App.useApp(); - // Consolidate all FileStore subscriptions with shallow equality - const fileStoreState = useFileStore( - (s) => ({ - isCreatingFileParseTask: fileManagerSelectors.isCreatingFileParseTask(id)(s), - parseFiles: s.parseFilesToChunks, - refreshFileList: s.refreshFileList, - updateResource: s.updateResource, - }), - shallow, - ); + }); + const { menuItems } = useFileItemDropdown({ + fileType, + filename: name, + id, + libraryId: resourceManagerState.libraryId, + onRenameStart: isFolder ? handleRenameStart : undefined, + sourceType, + url, + }); - // Consolidate all ResourceManagerStore subscriptions with shallow equality - const resourceManagerState = useResourceManagerStore( - (s) => ({ - libraryId: s.libraryId, - setPendingRenameItemId: s.setPendingRenameItemId, - }), - shallow, - ); + const handleCheckboxClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onSelectedChange(id, !isSelected, e.shiftKey, index); + }, + [id, index, isSelected, onSelectedChange], + ); - const [isRenaming, setIsRenaming] = useState(false); - const [renamingValue, setRenamingValue] = useState(name); - const inputRef = useRef(null); - const isConfirmingRef = useRef(false); - const isDragActive = useDragActive(); - const { setCurrentDrag } = useDragState(); - const [isDragging, setIsDragging] = useState(false); - const [isOver, setIsOver] = useState(false); + const handleCheckboxPointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + if (e.shiftKey) { + e.preventDefault(); + } + }, []); - const computedValues = useMemo(() => { - const lowerFileType = fileType?.toLowerCase(); - const lowerName = name?.toLowerCase(); - const isPDF = lowerFileType === 'pdf' || lowerName?.endsWith('.pdf'); - // Office files should use the MSDoc viewer, not the page editor - const isOfficeFile = - lowerName?.endsWith('.xls') || - lowerName?.endsWith('.xlsx') || - lowerName?.endsWith('.doc') || - lowerName?.endsWith('.docx') || - lowerName?.endsWith('.ppt') || - lowerName?.endsWith('.pptx') || - lowerName?.endsWith('.odt'); - return { - emoji: sourceType === 'document' || fileType === PAGE_FILE_TYPE ? metadata?.emoji : null, - isFolder: fileType === 'custom/folder', - // PDF and Office files should not be treated as pages, even if they have sourceType='document' - isPage: - !isPDF && !isOfficeFile && (sourceType === 'document' || fileType === PAGE_FILE_TYPE), - isSupportedForChunking: !isChunkingUnsupported(fileType), - }; - }, [fileType, sourceType, metadata?.emoji, name]); - - const { isSupportedForChunking, isPage, isFolder, emoji } = computedValues; - - const dragData = useMemo( - () => ({ - fileType, - isFolder, - name, - sourceType, - }), - [fileType, isFolder, name, sourceType], - ); - - const handleDragStart = useCallback( - (e: DragEvent) => { - if (!resourceManagerState.libraryId) { - e.preventDefault(); - return; - } - - setIsDragging(true); - setCurrentDrag({ - data: dragData, - id, - type: isFolder ? 'folder' : 'file', - }); - - // Set drag image to be transparent (we use custom overlay) - const img = getTransparentDragImage(); - if (img) { - e.dataTransfer.setDragImage(img, 0, 0); - } - e.dataTransfer.effectAllowed = 'move'; - }, - [resourceManagerState.libraryId, dragData, id, isFolder, setCurrentDrag], - ); - - const handleDragEnd = useCallback(() => { - setIsDragging(false); - }, []); - - const handleDragOver = useCallback( - (e: DragEvent) => { - if (!isFolder || !isDragActive) return; - - e.preventDefault(); - e.stopPropagation(); - setIsOver(true); - }, - [isFolder, isDragActive], - ); - - const handleDragLeave = useCallback(() => { - setIsOver(false); - }, []); - - const handleDrop = useCallback(() => { - setIsOver(false); - }, []); - - // Memoize display time calculation - const displayTime = useMemo( - () => - dayjs().diff(dayjs(createdAt), 'd') < 7 - ? dayjs(createdAt).fromNow() - : dayjs(createdAt).format('YYYY-MM-DD'), - [createdAt], - ); - - const handleRenameStart = useCallback(() => { - setIsRenaming(true); - setRenamingValue(name); - // Focus input after render - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - }, [name]); - - const handleRenameConfirm = useCallback(async () => { - // Prevent duplicate calls (e.g., from both Enter key and onBlur) - if (isConfirmingRef.current) return; - isConfirmingRef.current = true; - - if (!renamingValue.trim()) { - message.error(t('FileManager.actions.renameError')); - isConfirmingRef.current = false; - return; - } - - if (renamingValue.trim() === name) { - setIsRenaming(false); - isConfirmingRef.current = false; - return; - } - - try { - // Use optimistic updateResource for instant UI update - await fileStoreState.updateResource(id, { name: renamingValue.trim() }); - if (resourceManagerState.libraryId) { - await clearTreeFolderCache(resourceManagerState.libraryId); - } - await fileStoreState.refreshFileList(); - - message.success(t('FileManager.actions.renameSuccess')); - setIsRenaming(false); - } catch (error) { - console.error('Rename error:', error); - message.error(t('FileManager.actions.renameError')); - } finally { - isConfirmingRef.current = false; - } - }, [ - fileStoreState.refreshFileList, - fileStoreState.updateResource, - id, - message, - name, - renamingValue, - resourceManagerState.libraryId, - t, - ]); - - const handleRenameCancel = useCallback(() => { - // Don't cancel if we're in the middle of confirming - if (isConfirmingRef.current) return; - setIsRenaming(false); - setRenamingValue(name); - }, [name]); - - // Use shared click handler hook - const handleItemClick = useFileItemClick({ - id, - isFolder, - isPage, - libraryId: resourceManagerState.libraryId, - slug, - }); - - // Auto-start renaming if this is the pending rename item - useEffect(() => { - if (pendingRenameItemId === id && isFolder && !isRenaming) { - handleRenameStart(); - resourceManagerState.setPendingRenameItemId(null); - } - }, [pendingRenameItemId, id, isFolder, resourceManagerState]); - - const { menuItems } = useFileItemDropdown({ - fileType, - filename: name, - id, - libraryId: resourceManagerState.libraryId, - onRenameStart: isFolder ? handleRenameStart : undefined, - sourceType, - url, - }); - - return ( - + return ( + + +
+ +
onHoverChange(true)} - onMouseLeave={() => onHoverChange(false)} > -
{ - e.stopPropagation(); - - onSelectedChange(id, !selected, e.shiftKey, index); - }} - onPointerDown={(e) => { - e.stopPropagation(); - // Prevent text selection when shift-clicking for batch selection - if (e.shiftKey) { - e.preventDefault(); - } - }} - > - -
- - - - {isFolder ? ( - - ) : isPage ? ( - emoji ? ( - {emoji} - ) : ( -
- -
- ) - ) : ( - - )} -
- {isRenaming && isFolder ? ( - setRenamingValue(e.target.value)} - onClick={stopPropagation} - onPointerDown={stopPropagation} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleRenameConfirm(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( - - )} -
- - {!isFolder && - !isPage && - (fileStoreState.isCreatingFileParseTask || - isNull(chunkingStatus) || - !chunkingStatus ? ( -
- -
- ) : ( -
- -
- ))} - -
-
- {!isDragging && ( - <> - - {displayTime} - - - {isFolder || isPage ? '-' : formatSize(size)} - - - )} + +
-
- ); - }, - // Custom comparison function to prevent unnecessary re-renders - (prevProps, nextProps) => { - return ( - prevProps.id === nextProps.id && - prevProps.name === nextProps.name && - prevProps.selected === nextProps.selected && - prevProps.chunkingStatus === nextProps.chunkingStatus && - prevProps.embeddingStatus === nextProps.embeddingStatus && - prevProps.chunkCount === nextProps.chunkCount && - prevProps.chunkingError === nextProps.chunkingError && - prevProps.embeddingError === nextProps.embeddingError && - prevProps.finishEmbedding === nextProps.finishEmbedding && - prevProps.pendingRenameItemId === nextProps.pendingRenameItemId && - prevProps.size === nextProps.size && - prevProps.createdAt === nextProps.createdAt && - prevProps.fileType === nextProps.fileType && - prevProps.sourceType === nextProps.sourceType && - prevProps.slug === nextProps.slug && - prevProps.url === nextProps.url && - prevProps.columnWidths.name === nextProps.columnWidths.name && - prevProps.columnWidths.date === nextProps.columnWidths.date && - prevProps.columnWidths.size === nextProps.columnWidths.size && - prevProps.isAnyRowHovered === nextProps.isAnyRowHovered - ); - }, -); + {!isDragging && ( + <> + + {displayTime} + + + {isFolder || isPage ? '-' : formatSize(size)} + + + )} +
+ + ); +}; -FileListItem.displayName = 'FileListItem'; - -export default FileListItem; +export default memo(FileListItem); diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/styles.ts b/src/features/ResourceManager/components/Explorer/ListView/ListItem/styles.ts new file mode 100644 index 0000000000..abdd5bb7f8 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/styles.ts @@ -0,0 +1,134 @@ +import { createStaticStyles, cssVar } from 'antd-style'; + +export const styles = createStaticStyles(({ css }) => ({ + container: css` + position: relative; + cursor: pointer; + min-width: 800px; + + &::after { + content: ''; + + position: absolute; + z-index: 0; + inset: 0; + + background-color: ${cssVar.colorFillTertiary}; + opacity: 0; + pointer-events: none; + transition: + opacity ${cssVar.motionDurationMid} ${cssVar.motionEaseInOut}, + background-color ${cssVar.motionDurationMid} ${cssVar.motionEaseInOut}; + } + + > * { + position: relative; + z-index: 1; + } + + &:hover { + &::after { + opacity: 1; + } + } + `, + + dragOver: css` + outline: 1px dashed ${cssVar.colorPrimaryBorder}; + outline-offset: -2px; + + &, + &:hover { + &::after { + background-color: ${cssVar.colorPrimaryBg}; + opacity: 1; + } + } + + &::before { + opacity: 0; + } + `, + + dragging: css` + will-change: transform; + opacity: 0.5; + `, + + evenRow: css` + &::before { + content: ''; + + position: absolute; + z-index: 0; + inset: 0; + + background-color: ${cssVar.colorFillQuaternary}; + pointer-events: none; + + opacity: 1; + transition: opacity 300ms ${cssVar.motionEaseInOut}; + } + + &:hover { + &::before { + opacity: 0; + } + } + + .list-view-drop-zone:hover & { + &::before { + opacity: 0; + } + } + `, + + hover: css` + opacity: 0; + + &[data-popup-open], + .file-list-item-group:hover & { + opacity: 1; + } + `, + + item: css` + padding-block: 0; + padding-inline: 0 24px; + color: ${cssVar.colorTextSecondary}; + `, + + name: css` + overflow: hidden; + flex: 1; + + min-width: 0; + margin-inline-start: 12px; + + color: ${cssVar.colorText}; + white-space: nowrap; + `, + + nameContainer: css` + overflow: hidden; + flex: 1; + min-width: 0; + `, + + selected: css` + &::before { + opacity: 0; + } + + &::after { + background-color: ${cssVar.colorFillTertiary}; + opacity: 1; + } + + &:hover { + &::after { + background-color: ${cssVar.colorFillSecondary}; + } + } + `, +})); diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemDrag.ts b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemDrag.ts new file mode 100644 index 0000000000..b9cb6c7dc1 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemDrag.ts @@ -0,0 +1,98 @@ +import type { DragEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { + getTransparentDragImage, + useDragActive, + useSetCurrentDrag, +} from '@/routes/(main)/resource/features/DndContextWrapper'; + +interface UseFileListItemDragOptions { + fileType?: string | null; + id: string; + isFolder: boolean; + libraryId?: string; + name?: string | null; + sourceType?: string | null; +} + +export const useFileListItemDrag = ({ + fileType, + id, + isFolder, + libraryId, + name, + sourceType, +}: UseFileListItemDragOptions) => { + const isDragActive = useDragActive(); + const setCurrentDrag = useSetCurrentDrag(); + + const [isDragging, setIsDragging] = useState(false); + const [isOver, setIsOver] = useState(false); + + const dragData = useMemo( + () => ({ + fileType, + isFolder, + name, + sourceType, + }), + [fileType, isFolder, name, sourceType], + ); + + const handleDragStart = useCallback( + (e: DragEvent) => { + if (!libraryId) { + e.preventDefault(); + return; + } + + setIsDragging(true); + setCurrentDrag({ + data: dragData, + id, + type: isFolder ? 'folder' : 'file', + }); + + const img = getTransparentDragImage(); + if (img) { + e.dataTransfer.setDragImage(img, 0, 0); + } + e.dataTransfer.effectAllowed = 'move'; + }, + [dragData, id, isFolder, libraryId, setCurrentDrag], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + const handleDragOver = useCallback( + (e: DragEvent) => { + if (!isFolder || !isDragActive) return; + + e.preventDefault(); + e.stopPropagation(); + setIsOver(true); + }, + [isDragActive, isFolder], + ); + + const handleDragLeave = useCallback(() => { + setIsOver(false); + }, []); + + const handleDrop = useCallback(() => { + setIsOver(false); + }, []); + + return { + handleDragEnd, + handleDragLeave, + handleDragOver, + handleDragStart, + handleDrop, + isDragging, + isOver, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemMeta.ts b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemMeta.ts new file mode 100644 index 0000000000..5393da6b24 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemMeta.ts @@ -0,0 +1,47 @@ +import dayjs from 'dayjs'; +import { useMemo } from 'react'; + +import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; +import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported'; + +interface UseFileListItemMetaOptions { + createdAt: Date; + fileType: string; + metadata?: { + emoji?: string | null; + } | null; + name?: string | null; + sourceType?: string | null; +} + +export const useFileListItemMeta = ({ + createdAt, + fileType, + metadata, + name, + sourceType, +}: UseFileListItemMetaOptions) => + useMemo(() => { + const lowerFileType = fileType?.toLowerCase(); + const lowerName = name?.toLowerCase(); + const isPDF = lowerFileType === 'pdf' || lowerName?.endsWith('.pdf'); + const isOfficeFile = + lowerName?.endsWith('.xls') || + lowerName?.endsWith('.xlsx') || + lowerName?.endsWith('.doc') || + lowerName?.endsWith('.docx') || + lowerName?.endsWith('.ppt') || + lowerName?.endsWith('.pptx') || + lowerName?.endsWith('.odt'); + + return { + displayTime: + dayjs().diff(dayjs(createdAt), 'd') < 7 + ? dayjs(createdAt).fromNow() + : dayjs(createdAt).format('YYYY-MM-DD'), + emoji: sourceType === 'document' || fileType === PAGE_FILE_TYPE ? metadata?.emoji : null, + isFolder: fileType === 'custom/folder', + isPage: !isPDF && !isOfficeFile && (sourceType === 'document' || fileType === PAGE_FILE_TYPE), + isSupportedForChunking: !isChunkingUnsupported(fileType), + }; + }, [createdAt, fileType, metadata?.emoji, name, sourceType]); diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemRename.ts b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemRename.ts new file mode 100644 index 0000000000..df62be8a07 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/useFileListItemRename.ts @@ -0,0 +1,101 @@ +import { App } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; +import { useEventCallback } from '@/hooks/useEventCallback'; + +interface UseFileListItemRenameOptions { + id: string; + isFolder: boolean; + isPendingRename?: boolean; + libraryId?: string; + name?: string | null; + refreshFileList: (options?: { revalidateResources?: boolean }) => Promise; + setPendingRenameItemId: (id: string | null) => void; + updateResource: (id: string, payload: { name: string }) => Promise; +} + +export const useFileListItemRename = ({ + id, + isPendingRename, + isFolder, + libraryId, + name, + refreshFileList, + setPendingRenameItemId, + updateResource, +}: UseFileListItemRenameOptions) => { + const { t } = useTranslation(['components', 'file']); + const { message } = App.useApp(); + const [isRenaming, setIsRenaming] = useState(false); + const [renamingValue, setRenamingValue] = useState(name || ''); + const inputRef = useRef(null); + const isConfirmingRef = useRef(false); + + const handleRenameStart = useCallback(() => { + setIsRenaming(true); + setRenamingValue(name || ''); + + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + }, [name]); + + const handleRenameConfirm = useEventCallback(async () => { + if (isConfirmingRef.current) return; + isConfirmingRef.current = true; + + if (!renamingValue.trim()) { + message.error(t('FileManager.actions.renameError')); + isConfirmingRef.current = false; + return; + } + + if (renamingValue.trim() === name) { + setIsRenaming(false); + isConfirmingRef.current = false; + return; + } + + try { + await updateResource(id, { name: renamingValue.trim() }); + if (libraryId) { + await clearTreeFolderCache(libraryId); + } + await refreshFileList({ revalidateResources: false }); + + message.success(t('FileManager.actions.renameSuccess')); + setIsRenaming(false); + } catch (error) { + console.error('Rename error:', error); + message.error(t('FileManager.actions.renameError')); + } finally { + isConfirmingRef.current = false; + } + }); + + const handleRenameCancel = useCallback(() => { + if (isConfirmingRef.current) return; + setIsRenaming(false); + setRenamingValue(name || ''); + }, [name]); + + useEffect(() => { + if (isPendingRename && isFolder && !isRenaming) { + handleRenameStart(); + setPendingRenameItemId(null); + } + }, [handleRenameStart, isFolder, isPendingRename, isRenaming, setPendingRenameItemId]); + + return { + handleRenameCancel, + handleRenameConfirm, + handleRenameStart, + inputRef, + isRenaming, + renamingValue, + setRenamingValue, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListViewDropZone.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListViewDropZone.tsx new file mode 100644 index 0000000000..7df4dc599d --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListViewDropZone.tsx @@ -0,0 +1,45 @@ +import { createStaticStyles, cx } from 'antd-style'; +import type { ReactNode, RefObject } from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; + +import { styles } from './styles'; +import { useExplorerDropZone } from './useExplorerDropZone'; + +interface ListViewDropZoneProps { + children: ReactNode; + currentFolderId: string | null; + virtuosoRef: RefObject; +} + +const localStyles = createStaticStyles(({ css }) => ({ + container: css` + overflow: hidden; + position: relative; + `, +})); + +const ListViewDropZone = ({ children, currentFolderId, virtuosoRef }: ListViewDropZoneProps) => { + const { containerRef, handleDragLeave, handleDragOver, handleDrop, isDropZoneActive } = + useExplorerDropZone(virtuosoRef); + + return ( +
+ {children} +
+ ); +}; + +export default ListViewDropZone; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListViewHeader.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListViewHeader.tsx new file mode 100644 index 0000000000..f0bcff7d42 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListViewHeader.tsx @@ -0,0 +1,136 @@ +import { Center, Checkbox, Flexbox } from '@lobehub/ui'; +import { cssVar } from 'antd-style'; +import { useTranslation } from 'react-i18next'; + +import { useGlobalStore } from '@/store/global'; +import type { FileListItem } from '@/types/files'; + +import { + useExplorerSelectionActions, + useExplorerSelectionSummary, +} from '../hooks/useExplorerSelection'; +import ColumnResizeHandle from './ColumnResizeHandle'; +import ListViewSelectAllHint from './ListViewSelectAllHint'; +import { styles } from './styles'; + +interface ListViewHeaderProps { + columnWidths: { + date: number; + name: number; + size: number; + }; + data: FileListItem[]; + hasMore: boolean; +} + +const ListViewHeader = ({ columnWidths, data, hasMore }: ListViewHeaderProps) => { + const { t } = useTranslation(['components', 'file']); + const updateColumnWidth = useGlobalStore((s) => s.updateResourceManagerColumnWidth); + const { handleSelectAll, handleSelectAllResources } = useExplorerSelectionActions(data); + const { + allSelected, + indeterminate, + selectAllState, + selectedCount, + showSelectAllHint, + total, + } = useExplorerSelectionSummary({ + data, + hasMore, + }); + const isAllResultsSelected = selectAllState === 'all' && total === selectedCount; + const selectedLabelKey = + selectAllState === 'all' + ? total + ? isAllResultsSelected + ? 'FileManager.total.allSelectedCount' + : 'FileManager.total.selectedCount' + : 'FileManager.total.allSelectedFallback' + : 'FileManager.total.selectedCount'; + + return ( + <> + +
+ +
+ + {selectedCount > 0 || selectAllState === 'all' + ? t(selectedLabelKey, { + count: selectedCount, + ns: 'components', + }) + : t('FileManager.title.title')} + updateColumnWidth('name', width)} + /> + + + {t('FileManager.title.createdAt')} + updateColumnWidth('date', width)} + /> + + + {t('FileManager.title.size')} + updateColumnWidth('size', width)} + /> + +
+ + + ); +}; + +export default ListViewHeader; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListViewSelectAllHint.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListViewSelectAllHint.tsx new file mode 100644 index 0000000000..beee7378db --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListViewSelectAllHint.tsx @@ -0,0 +1,59 @@ +import { Button, Flexbox } from '@lobehub/ui'; +import { useTranslation } from 'react-i18next'; + +import type { SelectAllState } from '@/routes/(main)/resource/features/store/initialState'; + +import { styles } from './styles'; + +interface ListViewSelectAllHintProps { + dataLength: number; + onSelectAllResources: () => void; + selectAllState: SelectAllState; + selectedCount: number; + showSelectAllHint: boolean; + total?: number; +} + +const ListViewSelectAllHint = ({ + dataLength, + onSelectAllResources, + selectedCount, + selectAllState, + showSelectAllHint, + total, +}: ListViewSelectAllHintProps) => { + const { t } = useTranslation('components'); + const isAllResultsSelected = selectAllState === 'all' && total === selectedCount; + + if (!showSelectAllHint) return null; + + return ( + + + {t( + selectAllState === 'all' + ? total + ? isAllResultsSelected + ? 'FileManager.total.allSelectedCount' + : 'FileManager.total.selectedCount' + : 'FileManager.total.allSelectedFallback' + : 'FileManager.total.loadedSelectedCount', + { + count: selectedCount, + }, + )} + + {selectAllState !== 'all' && ( + + )} + + ); +}; + +export default ListViewSelectAllHint; diff --git a/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx b/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx index aa10a8c080..cd88aae7b5 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx @@ -1,7 +1,7 @@ import { Center, Checkbox, Flexbox, Skeleton } from '@lobehub/ui'; import { cssVar } from 'antd-style'; -import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem'; +import { FILE_DATE_WIDTH, FILE_SIZE_WIDTH } from './ListItem/constants'; interface ListViewSkeletonProps { columnWidths?: { diff --git a/src/features/ResourceManager/components/Explorer/ListView/VirtualizedFileList.tsx b/src/features/ResourceManager/components/Explorer/ListView/VirtualizedFileList.tsx new file mode 100644 index 0000000000..5e1c4e7230 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/VirtualizedFileList.tsx @@ -0,0 +1,122 @@ +import type { RefObject } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors'; +import type { FileListItem } from '@/types/files'; + +import { useExplorerSelectionActions } from '../hooks/useExplorerSelection'; +import FileListItemComponent from './ListItem'; +import { useExplorerInfiniteScroll } from './useExplorerInfiniteScroll'; + +interface VirtualizedFileListProps { + columnWidths: { + date: number; + name: number; + size: number; + }; + data: FileListItem[]; + hasMore: boolean; + virtuosoRef: RefObject; +} + +const VirtualizedFileList = ({ + columnWidths, + data, + hasMore, + virtuosoRef, +}: VirtualizedFileListProps) => { + const { + clearSelectAllState, + selectAllState, + selectedFileIds, + setSelectedFileIds, + toggleItemSelection, + } = useExplorerSelectionActions(data); + const { Footer, handleEndReached } = useExplorerInfiniteScroll({ + columnWidths, + dataLength: data.length, + hasMore, + }); + const dataRef = useRef(data); + const lastSelectedIndexRef = useRef(null); + + useEffect(() => { + dataRef.current = data; + }, [data]); + + useEffect(() => { + return useResourceManagerStore.subscribe( + (s) => s.selectedFileIds.length, + (selectedCount) => { + if (selectedCount === 0) { + lastSelectedIndexRef.current = null; + } + }, + ); + }, []); + + const handleSelectionChange = useCallback( + (id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => { + if (shiftKey && lastSelectedIndexRef.current !== null && dataRef.current.length > 0) { + clearSelectAllState(); + + const currentSelected = useResourceManagerStore.getState().selectedFileIds; + const start = Math.min(lastSelectedIndexRef.current, clickedIndex); + const end = Math.max(lastSelectedIndexRef.current, clickedIndex); + const rangeIds = dataRef.current.slice(start, end + 1).map((item) => item.id); + const nextSelected = new Set(currentSelected); + + for (const rangeId of rangeIds) { + nextSelected.add(rangeId); + } + + setSelectedFileIds(Array.from(nextSelected)); + } else { + toggleItemSelection(id, checked); + } + + lastSelectedIndexRef.current = clickedIndex; + }, + [clearSelectAllState, setSelectedFileIds, toggleItemSelection], + ); + + return ( + { + if (!item) return null; + + return ( + + ); + }, + [columnWidths, handleSelectionChange, selectAllState, selectedFileIds], + )} + /> + ); +}; + +export default VirtualizedFileList; diff --git a/src/features/ResourceManager/components/Explorer/ListView/index.tsx b/src/features/ResourceManager/components/Explorer/ListView/index.tsx index 7cbfdff799..fe9e7a5252 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/index.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/index.tsx @@ -1,467 +1,49 @@ 'use client'; -import { Center, Checkbox, Flexbox } from '@lobehub/ui'; -import { createStaticStyles, cssVar, cx } from 'antd-style'; -import debug from 'debug'; -import { type DragEvent } from 'react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { type VirtuosoHandle } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; +import { Flexbox } from '@lobehub/ui'; +import { useRef } from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; -import { useDragActive } from '@/routes/(main)/resource/features/DndContextWrapper'; -import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath'; -import { - useResourceManagerFetchFolderBreadcrumb, - useResourceManagerStore, -} from '@/routes/(main)/resource/features/store'; -import { sortFileList } from '@/routes/(main)/resource/features/store/selectors'; -import { useFileStore } from '@/store/file'; -import { useFetchResources } from '@/store/file/slices/resource/hooks'; -import { useGlobalStore } from '@/store/global'; -import { INITIAL_STATUS } from '@/store/global/initialState'; -import { type AsyncTaskStatus } from '@/types/asyncTask'; -import { type FileListItem as FileListItemType } from '@/types/files'; +import type { ResourceQueryParams } from '@/types/resource'; -import ColumnResizeHandle from './ColumnResizeHandle'; -import FileListItem from './ListItem'; +import ListViewDropZone from './ListViewDropZone'; +import ListViewHeader from './ListViewHeader'; import ListViewSkeleton from './Skeleton'; +import { styles } from './styles'; +import { useExplorerListData } from './useExplorerListData'; +import VirtualizedFileList from './VirtualizedFileList'; -const log = debug('resource-manager:list-view'); +interface ListViewProps { + isLoading?: boolean; + isValidating?: boolean; + queryParams: ResourceQueryParams; +} -const styles = createStaticStyles(({ css }) => ({ - dropZone: css` - position: relative; - height: 100%; - `, - dropZoneActive: css` - background: ${cssVar.colorPrimaryBg}; - outline: 1px dashed ${cssVar.colorPrimaryBorder}; - outline-offset: -4px; - `, - header: css` - min-width: 800px; - height: 40px; - min-height: 40px; - color: ${cssVar.colorTextDescription}; - `, - headerItem: css` - height: 100%; - padding-block: 6px; - padding-inline: 0 24px; - `, - scrollContainer: css` - overflow: auto hidden; - flex: 1; - `, -})); - -const ListView = memo(function ListView() { - const [ - libraryId, - category, - selectFileIds, - setSelectedFileIds, - pendingRenameItemId, - sorter, - sortType, - storeIsTransitioning, - ] = useResourceManagerStore((s) => [ - s.libraryId, - s.category, - s.selectedFileIds, - s.setSelectedFileIds, - s.pendingRenameItemId, - s.sorter, - s.sortType, - s.isTransitioning, - ]); - - // Access column widths from Global store - const columnWidths = useGlobalStore( - (s) => s.status.resourceManagerColumnWidths || INITIAL_STATUS.resourceManagerColumnWidths, - ); - const updateColumnWidth = useGlobalStore((s) => s.updateResourceManagerColumnWidth); - - const { t } = useTranslation(['components', 'file']); +const ListView = ({ isLoading, isValidating, queryParams }: ListViewProps) => { const virtuosoRef = useRef(null); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const isDragActive = useDragActive(); - const [isDropZoneActive, setIsDropZoneActive] = useState(false); - const [isAnyRowHovered, setIsAnyRowHovered] = useState(false); - const scrollTimerRef = useRef | null>(null); - const autoScrollIntervalRef = useRef | null>(null); - const containerRef = useRef(null); - const lastSelectedIndexRef = useRef(null); - - const { currentFolderSlug } = useFolderPath(); - const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug); - - // Get current folder ID - either from breadcrumb or null for root - const currentFolderId = folderBreadcrumb?.at(-1)?.id || null; - - const queryParams = useMemo( - () => ({ - category: libraryId ? undefined : category, - libraryId, - parentId: currentFolderSlug || null, - showFilesInKnowledgeBase: false, - sortType, - sorter, - }), - [category, currentFolderSlug, libraryId, sorter, sortType], - ); - - const { isLoading, isValidating } = useFetchResources(queryParams); - const { queryParams: currentQueryParams, hasMore, loadMoreResources } = useFileStore(); - - const isNavigating = useMemo(() => { - if (!currentQueryParams || !queryParams) return false; - - return ( - currentQueryParams.libraryId !== queryParams.libraryId || - currentQueryParams.parentId !== queryParams.parentId || - currentQueryParams.category !== queryParams.category - ); - }, [currentQueryParams, queryParams]); - - const resourceList = useFileStore((s) => s.resourceList); - - // Map ResourceItem[] to FileListItem[] for compatibility - const rawData = - resourceList?.map((item) => ({ - ...item, - chunkCount: item.chunkCount ?? null, - chunkingError: item.chunkingError ?? null, - chunkingStatus: (item.chunkingStatus ?? null) as AsyncTaskStatus | null, - embeddingError: item.embeddingError ?? null, - embeddingStatus: (item.embeddingStatus ?? null) as AsyncTaskStatus | null, - finishEmbedding: item.finishEmbedding ?? false, - url: item.url ?? '', - })) ?? []; - - // Sort data using current sort settings - const data = sortFileList(rawData, sorter, sortType) || []; - - const dataLength = data.length; - const effectiveIsLoading = isLoading ?? false; - const effectiveIsNavigating = isNavigating ?? false; - const effectiveIsTransitioning = storeIsTransitioning ?? false; - const effectiveIsValidating = isValidating ?? false; - - const showSkeleton = - (effectiveIsLoading && dataLength === 0) || - (effectiveIsNavigating && effectiveIsValidating) || - effectiveIsTransitioning; - - const dataRef = useRef(data); - - useEffect(() => { - dataRef.current = data; - }, [data]); - - // Handle selection change with shift-click support for range selection - const handleSelectionChange = useCallback( - (id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => { - // Always get the latest state from the store to avoid stale closure issues - const currentSelected = useResourceManagerStore.getState().selectedFileIds; - const lastIndex = lastSelectedIndexRef.current; - const list = dataRef.current; - - if (shiftKey && lastIndex !== null && list.length > 0) { - // Shift-click: select range from lastIndex to current index - const start = Math.min(lastIndex, clickedIndex); - const end = Math.max(lastIndex, clickedIndex); - const rangeIds = list - .slice(start, end + 1) - .filter(Boolean) - .map((item) => item.id); - - // Merge with existing selection - const prevSet = new Set(currentSelected); - rangeIds.forEach((rangeId) => prevSet.add(rangeId)); - setSelectedFileIds(Array.from(prevSet)); - } else { - // Regular click: toggle single item - if (checked) { - setSelectedFileIds([...currentSelected, id]); - } else { - setSelectedFileIds(currentSelected.filter((item) => item !== id)); - } - } - lastSelectedIndexRef.current = clickedIndex; - }, - [setSelectedFileIds], - ); - - // Clean up invalid selections when data changes - useEffect(() => { - if (selectFileIds.length > 0) { - const validFileIds = new Set(data.map((item) => item?.id).filter(Boolean)); - const filteredSelection = selectFileIds.filter((id) => validFileIds.has(id)); - if (filteredSelection.length !== selectFileIds.length) { - setSelectedFileIds(filteredSelection); - } - } - }, [data, selectFileIds, setSelectedFileIds]); - - // Reset last selected index when all selections are cleared - useEffect(() => { - if (selectFileIds.length === 0) { - lastSelectedIndexRef.current = null; - } - }, [selectFileIds.length]); - - // Calculate select all checkbox state - const { allSelected, indeterminate } = useMemo(() => { - const fileCount = data.length; - const selectedCount = selectFileIds.length; - return { - allSelected: fileCount > 0 && selectedCount === fileCount, - indeterminate: selectedCount > 0 && selectedCount < fileCount, - }; - }, [data, selectFileIds]); - - // Handle select all checkbox change - const handleSelectAll = () => { - if (allSelected) { - setSelectedFileIds([]); - } else { - setSelectedFileIds(data.map((item) => item.id)); - } - }; - - // Handle automatic load more when reaching the end - const handleEndReached = useCallback(async () => { - log('handleEndReached', hasMore, isLoadingMore); - - if (!hasMore || isLoadingMore) return; - - setIsLoadingMore(true); - try { - await loadMoreResources(); - } finally { - setIsLoadingMore(false); - } - }, [hasMore, loadMoreResources, isLoadingMore]); - - // Clear auto-scroll timers - const clearScrollTimers = useCallback(() => { - if (scrollTimerRef.current) { - clearTimeout(scrollTimerRef.current); - scrollTimerRef.current = null; - } - if (autoScrollIntervalRef.current) { - clearInterval(autoScrollIntervalRef.current); - autoScrollIntervalRef.current = null; - } - }, []); - - // Drop zone handlers for dragging to blank space - const handleDropZoneDragOver = useCallback( - (e: DragEvent) => { - if (!isDragActive) return; - e.preventDefault(); - e.stopPropagation(); - setIsDropZoneActive(true); - }, - [isDragActive], - ); - - const handleDropZoneDragLeave = useCallback(() => { - setIsDropZoneActive(false); - clearScrollTimers(); - }, [clearScrollTimers]); - - const handleDropZoneDrop = useCallback(() => { - setIsDropZoneActive(false); - clearScrollTimers(); - }, [clearScrollTimers]); - - // Handle auto-scroll during drag - const handleDragMove = useCallback( - (e: DragEvent) => { - if (!isDragActive || !containerRef.current) return; - - const container = containerRef.current; - const rect = container.getBoundingClientRect(); - const mouseY = e.clientY; - const bottomThreshold = 200; // pixels from bottom edge - const distanceFromBottom = rect.bottom - mouseY; - - // Check if mouse is near the bottom edge - if (distanceFromBottom > 0 && distanceFromBottom <= bottomThreshold) { - // If not already started, start the 2-second timer - if (!scrollTimerRef.current && !autoScrollIntervalRef.current) { - scrollTimerRef.current = setTimeout(() => { - // After 2 seconds, start auto-scrolling - autoScrollIntervalRef.current = setInterval(() => { - virtuosoRef.current?.scrollBy({ top: 50 }); - }, 100); // Scroll every 100ms for smooth scrolling - scrollTimerRef.current = null; - }, 2000); - } - } else { - // Mouse moved away from bottom edge, clear timers - clearScrollTimers(); - } - }, - [isDragActive, clearScrollTimers], - ); - - // Clean up timers when drag ends or component unmounts - useEffect(() => { - if (!isDragActive) { - clearScrollTimers(); - } - }, [isDragActive, clearScrollTimers]); - - useEffect(() => { - return () => { - clearScrollTimers(); - }; - }, [clearScrollTimers]); - - // Memoize footer component to show skeleton loaders when loading more - // eslint-disable-next-line @eslint-react/no-nested-component-definitions - const Footer = useCallback(() => { - if (isLoadingMore && hasMore) return ; - - // Leave some padding at the end when there are no more pages, - // so users can clearly feel they've reached the end of the list. - if (hasMore === false && dataLength > 0) return
; - - return null; - }, [columnWidths, dataLength, hasMore, isLoadingMore]); + const { columnWidths, currentFolderId, data, hasMore, showSkeleton } = useExplorerListData({ + isLoading, + isValidating, + queryParams, + }); if (showSkeleton) return ; return (
- -
- -
- - {selectFileIds.length > 0 - ? t('FileManager.total.selectedCount', { - count: selectFileIds.length, - ns: 'components', - }) - : t('FileManager.title.title')} - updateColumnWidth('name', width)} - /> - - - {t('FileManager.title.createdAt')} - updateColumnWidth('date', width)} - /> - - - {t('FileManager.title.size')} - updateColumnWidth('size', width)} - /> - -
-
{ - handleDropZoneDragOver(e); - handleDragMove(e); - }} - > - + + { - if (!item) return null; - return ( - - ); - }} + hasMore={hasMore} + virtuosoRef={virtuosoRef} /> -
+
); -}); +}; export default ListView; diff --git a/src/features/ResourceManager/components/Explorer/ListView/styles.ts b/src/features/ResourceManager/components/Explorer/ListView/styles.ts new file mode 100644 index 0000000000..1214aeeeec --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/styles.ts @@ -0,0 +1,43 @@ +import { createStaticStyles, cssVar } from 'antd-style'; + +export const styles = createStaticStyles(({ css }) => ({ + dropZone: css` + position: relative; + height: 100%; + `, + dropZoneActive: css` + background: ${cssVar.colorPrimaryBg}; + outline: 1px dashed ${cssVar.colorPrimaryBorder}; + outline-offset: -4px; + `, + header: css` + min-width: 800px; + height: 40px; + min-height: 40px; + color: ${cssVar.colorTextDescription}; + `, + headerItem: css` + height: 100%; + padding-block: 6px; + padding-inline: 0 24px; + `, + scrollContainer: css` + overflow: auto hidden; + flex: 1; + `, + selectAllHint: css` + position: sticky; + z-index: 1; + inset-block-start: 40px; + + min-width: 800px; + padding-block: 8px; + padding-inline: 16px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + + font-size: 12px; + color: ${cssVar.colorTextDescription}; + + background: ${cssVar.colorFillTertiary}; + `, +})); diff --git a/src/features/ResourceManager/components/Explorer/ListView/useExplorerDropZone.ts b/src/features/ResourceManager/components/Explorer/ListView/useExplorerDropZone.ts new file mode 100644 index 0000000000..50892ebd20 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/useExplorerDropZone.ts @@ -0,0 +1,86 @@ +import type { DragEvent, RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; + +import { useDragActive } from '@/routes/(main)/resource/features/DndContextWrapper'; + +export const useExplorerDropZone = (virtuosoRef: RefObject) => { + const isDragActive = useDragActive(); + const [isDropZoneActive, setIsDropZoneActive] = useState(false); + const scrollTimerRef = useRef | null>(null); + const autoScrollIntervalRef = useRef | null>(null); + const containerRef = useRef(null); + + const clearScrollTimers = useCallback(() => { + if (scrollTimerRef.current) { + clearTimeout(scrollTimerRef.current); + scrollTimerRef.current = null; + } + + if (autoScrollIntervalRef.current) { + clearInterval(autoScrollIntervalRef.current); + autoScrollIntervalRef.current = null; + } + }, []); + + const handleDragLeave = useCallback(() => { + setIsDropZoneActive(false); + clearScrollTimers(); + }, [clearScrollTimers]); + + const handleDrop = useCallback(() => { + setIsDropZoneActive(false); + clearScrollTimers(); + }, [clearScrollTimers]); + + const handleDragOver = useCallback( + (e: DragEvent) => { + if (!isDragActive || !containerRef.current) return; + + e.preventDefault(); + e.stopPropagation(); + setIsDropZoneActive(true); + + const rect = containerRef.current.getBoundingClientRect(); + const distanceFromBottom = rect.bottom - e.clientY; + const bottomThreshold = 200; + + if (distanceFromBottom > 0 && distanceFromBottom <= bottomThreshold) { + if (!scrollTimerRef.current && !autoScrollIntervalRef.current) { + scrollTimerRef.current = setTimeout(() => { + autoScrollIntervalRef.current = setInterval(() => { + virtuosoRef.current?.scrollBy({ top: 50 }); + }, 100); + scrollTimerRef.current = null; + }, 2000); + } + + return; + } + + clearScrollTimers(); + }, + [clearScrollTimers, isDragActive, virtuosoRef], + ); + + useEffect(() => { + if (!isDragActive) { + clearScrollTimers(); + } + }, [clearScrollTimers, isDragActive]); + + useEffect( + () => () => { + clearScrollTimers(); + }, + [clearScrollTimers], + ); + + return { + containerRef, + handleDragLeave, + handleDragOver, + handleDrop, + isDropZoneActive, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/ListView/useExplorerInfiniteScroll.tsx b/src/features/ResourceManager/components/Explorer/ListView/useExplorerInfiniteScroll.tsx new file mode 100644 index 0000000000..b1491ec044 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/useExplorerInfiniteScroll.tsx @@ -0,0 +1,47 @@ +import { useCallback, useState } from 'react'; + +import { useFileStore } from '@/store/file'; + +import ListViewSkeleton from './Skeleton'; + +interface UseExplorerInfiniteScrollOptions { + columnWidths: { + date: number; + name: number; + size: number; + }; + dataLength: number; + hasMore: boolean; +} + +export const useExplorerInfiniteScroll = ({ + columnWidths, + dataLength, + hasMore, +}: UseExplorerInfiniteScrollOptions) => { + const loadMoreResources = useFileStore((s) => s.loadMoreResources); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const handleEndReached = useCallback(async () => { + if (!hasMore || isLoadingMore) return; + + setIsLoadingMore(true); + try { + await loadMoreResources(); + } finally { + setIsLoadingMore(false); + } + }, [hasMore, isLoadingMore, loadMoreResources]); + + const Footer = useCallback(() => { + if (isLoadingMore && hasMore) return ; + if (hasMore === false && dataLength > 0) return
; + + return null; + }, [columnWidths, dataLength, hasMore, isLoadingMore]); + + return { + Footer, + handleEndReached, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/ListView/useExplorerListData.ts b/src/features/ResourceManager/components/Explorer/ListView/useExplorerListData.ts new file mode 100644 index 0000000000..35ab52b9c6 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/useExplorerListData.ts @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; + +import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId'; +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { sortFileList } from '@/routes/(main)/resource/features/store/selectors'; +import { useFileStore } from '@/store/file'; +import { useGlobalStore } from '@/store/global'; +import { INITIAL_STATUS } from '@/store/global/initialState'; +import type { AsyncTaskStatus } from '@/types/asyncTask'; +import type { FileListItem } from '@/types/files'; +import type { ResourceQueryParams } from '@/types/resource'; + +interface UseExplorerListDataParams { + isLoading?: boolean; + isValidating?: boolean; + queryParams: ResourceQueryParams; +} + +export const useExplorerListData = ({ + isLoading, + isValidating, + queryParams, +}: UseExplorerListDataParams) => { + const [sorter, sortType] = useResourceManagerStore((s) => [s.sorter, s.sortType]); + const columnWidths = useGlobalStore( + (s) => s.status.resourceManagerColumnWidths || INITIAL_STATUS.resourceManagerColumnWidths, + ); + const currentFolderId = useCurrentFolderId(); + const { currentQueryParams, hasMore, resourceList } = useFileStore((s) => ({ + currentQueryParams: s.queryParams, + hasMore: s.hasMore, + resourceList: s.resourceList, + })); + + const isNavigating = useMemo(() => { + if (!currentQueryParams) return false; + + return ( + currentQueryParams.libraryId !== queryParams.libraryId || + currentQueryParams.parentId !== queryParams.parentId || + currentQueryParams.category !== queryParams.category + ); + }, [currentQueryParams, queryParams]); + + const rawData = useMemo( + () => + resourceList?.map((item) => ({ + ...item, + chunkCount: item.chunkCount ?? null, + chunkingError: item.chunkingError ?? null, + chunkingStatus: (item.chunkingStatus ?? null) as AsyncTaskStatus | null, + embeddingError: item.embeddingError ?? null, + embeddingStatus: (item.embeddingStatus ?? null) as AsyncTaskStatus | null, + finishEmbedding: item.finishEmbedding ?? false, + url: item.url ?? '', + })) ?? [], + [resourceList], + ); + + const data = useMemo( + () => sortFileList(rawData, sorter, sortType) || [], + [rawData, sorter, sortType], + ); + + const showSkeleton = + ((isLoading ?? false) && data.length === 0) || ((isNavigating ?? false) && !!isValidating); + + return { + columnWidths, + currentFolderId, + data, + hasMore, + showSkeleton, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/MasonryItemWrapper.tsx b/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/MasonryItemWrapper.tsx index 050cb406cc..38df5de28c 100644 --- a/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/MasonryItemWrapper.tsx +++ b/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/MasonryItemWrapper.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; +import { isExplorerItemSelected } from '@/routes/(main)/resource/features/store/selectors'; import { type FileListItem } from '@/types/files'; import MasonryFileItem from '.'; @@ -7,8 +8,9 @@ import MasonryFileItem from '.'; interface MasonryItemWrapperProps { context: { knowledgeBaseId?: string; + onSelectedChange: (id: string, checked: boolean) => void; + selectAllState: 'all' | 'loaded' | 'none'; selectFileIds: string[]; - setSelectedFileIds: (ids: string[]) => void; }; data: FileListItem; index: number; @@ -24,14 +26,12 @@ const MasonryItemWrapper = memo(({ data: item, context
{ - if (checked) { - context.setSelectedFileIds([...context.selectFileIds, id]); - } else { - context.setSelectedFileIds(context.selectFileIds.filter((item) => item !== id)); - } - }} + selected={isExplorerItemSelected({ + id: item.id, + selectAllState: context.selectAllState, + selectedIds: context.selectFileIds, + })} + onSelectedChange={context.onSelectedChange} {...item} />
diff --git a/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/index.tsx b/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/index.tsx index 5fc6431d3f..cf2c0073ed 100644 --- a/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/index.tsx +++ b/src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/index.tsx @@ -5,7 +5,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from ' import { getTransparentDragImage, useDragActive, - useDragState, + useSetCurrentDrag, } from '@/routes/(main)/resource/features/DndContextWrapper'; import { documentService } from '@/services/document'; import { type FileListItem } from '@/types/files'; @@ -204,7 +204,7 @@ const MasonryFileItem = memo( const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false); const isDragActive = useDragActive(); - const { setCurrentDrag } = useDragState(); + const setCurrentDrag = useSetCurrentDrag(); const [isDragging, setIsDragging] = useState(false); const [isOver, setIsOver] = useState(false); diff --git a/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx b/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx index 5e1b8115d0..443e7c73e8 100644 --- a/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx +++ b/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Center } from '@lobehub/ui'; +import { Button, Center, Checkbox, Flexbox } from '@lobehub/ui'; import { VirtuosoMasonry } from '@virtuoso.dev/masonry'; -import { cssVar } from 'antd-style'; +import { createStaticStyles, cssVar } from 'antd-style'; import { type UIEvent } from 'react'; import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,55 +10,73 @@ import { useTranslation } from 'react-i18next'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; import { sortFileList } from '@/routes/(main)/resource/features/store/selectors'; import { useFileStore } from '@/store/file'; -import { useFetchResources } from '@/store/file/slices/resource/hooks'; import { type FileListItem } from '@/types/files'; +import type { ResourceQueryParams } from '@/types/resource'; +import { + useExplorerSelectionActions, + useExplorerSelectionSummary, +} from '../hooks/useExplorerSelection'; import { useMasonryColumnCount } from '../useMasonryColumnCount'; import MasonryItemWrapper from './MasonryItem/MasonryItemWrapper'; import MasonryViewSkeleton from './Skeleton'; +import { useMasonryViewState } from './useMasonryViewState'; -const MasonryView = memo(function MasonryView() { +const styles = createStaticStyles(({ css }) => ({ + selectAllHint: css` + position: sticky; + z-index: 1; + inset-block-start: 53px; + + padding-block: 8px; + padding-inline: 4px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + + font-size: 12px; + color: ${cssVar.colorTextDescription}; + + background: ${cssVar.colorFillTertiary}; + `, + toolbar: css` + position: sticky; + z-index: 1; + inset-block-start: 0; + + padding-block: 12px; + padding-inline: 4px; + border-block-end: 1px solid ${cssVar.colorBorderSecondary}; + + background: ${cssVar.colorBgContainer}; + `, +})); + +interface MasonryViewProps { + isLoading?: boolean; + isValidating?: boolean; + queryParams: ResourceQueryParams; +} + +const MasonryView = memo(function MasonryView({ + isLoading, + isValidating, + queryParams, +}: MasonryViewProps) { // Access all state from Resource Manager store - const [ - libraryId, - category, - selectedFileIds, - setSelectedFileIds, - storeIsMasonryReady, - sorter, - sortType, - storeIsTransitioning, - ] = useResourceManagerStore((s) => [ + const [libraryId, viewMode, sorter, sortType] = useResourceManagerStore((s) => [ s.libraryId, - s.category, - s.selectedFileIds, - s.setSelectedFileIds, - s.isMasonryReady, + s.viewMode, s.sorter, s.sortType, - s.isTransitioning, ]); - const { t } = useTranslation('file'); + const { t } = useTranslation(['components', 'file']); const columnCount = useMasonryColumnCount(); const [isLoadingMore, setIsLoadingMore] = useState(false); // NEW: Read from resource store instead of fetching independently const resourceList = useFileStore((s) => s.resourceList); + const total = useFileStore((s) => s.total); - const queryParams = useMemo( - () => ({ - category: libraryId ? undefined : category, - libraryId, - parentId: null, - showFilesInKnowledgeBase: false, - sortType, - sorter, - }), - [category, libraryId, sorter, sortType], - ); - - const { isLoading, isValidating } = useFetchResources(queryParams); const { queryParams: currentQueryParams, hasMore, loadMoreResources } = useFileStore(); const isNavigating = useMemo(() => { @@ -72,54 +90,58 @@ const MasonryView = memo(function MasonryView() { }, [currentQueryParams, queryParams]); // Map ResourceItem[] to FileListItem[] for compatibility - const rawData = resourceList?.map( - (item): FileListItem => ({ - chunkCount: item.chunkCount ?? null, - chunkingError: item.chunkingError ?? null, - chunkingStatus: (item.chunkingStatus as any) ?? null, - content: item.content, - createdAt: item.createdAt, - editorData: item.editorData, - embeddingError: item.embeddingError ?? null, - embeddingStatus: (item.embeddingStatus as any) ?? null, - fileType: item.fileType, - finishEmbedding: item.finishEmbedding ?? false, - id: item.id, - metadata: item.metadata, - name: item.name, - parentId: item.parentId, - size: item.size, - slug: item.slug, - sourceType: item.sourceType, - updatedAt: item.updatedAt, - url: item.url ?? '', - }), + const rawData = useMemo( + () => + resourceList?.map( + (item): FileListItem => ({ + chunkCount: item.chunkCount ?? null, + chunkingError: item.chunkingError ?? null, + chunkingStatus: (item.chunkingStatus as any) ?? null, + content: item.content, + createdAt: item.createdAt, + editorData: item.editorData, + embeddingError: item.embeddingError ?? null, + embeddingStatus: (item.embeddingStatus as any) ?? null, + fileType: item.fileType, + finishEmbedding: item.finishEmbedding ?? false, + id: item.id, + metadata: item.metadata, + name: item.name, + parentId: item.parentId, + size: item.size, + slug: item.slug, + sourceType: item.sourceType, + updatedAt: item.updatedAt, + url: item.url ?? '', + }), + ) ?? [], + [resourceList], ); // Sort data using current sort settings - const data = sortFileList(rawData, sorter, sortType) || []; + const data = useMemo( + () => sortFileList(rawData, sorter, sortType) || [], + [rawData, sorter, sortType], + ); const dataLength = data.length; const effectiveIsLoading = isLoading ?? false; const effectiveIsNavigating = isNavigating ?? false; const effectiveIsValidating = isValidating ?? false; - const effectiveIsTransitioning = storeIsTransitioning ?? false; - const effectiveIsMasonryReady = storeIsMasonryReady; - - const showSkeleton = - (effectiveIsLoading && dataLength === 0) || - (effectiveIsNavigating && effectiveIsValidating) || - effectiveIsTransitioning || - !effectiveIsMasonryReady; - - const masonryContext = useMemo( - () => ({ - knowledgeBaseId: libraryId, - selectFileIds: selectedFileIds, - setSelectedFileIds, - }), - [libraryId, selectedFileIds, setSelectedFileIds], - ); + const { isMasonryReady, showSkeleton } = useMasonryViewState({ + dataLength, + isLoading: effectiveIsLoading, + isNavigating: effectiveIsNavigating, + isValidating: effectiveIsValidating, + viewMode, + }); + const { handleSelectAll, handleSelectAllResources, selectAllState, selectedFileIds, toggleItemSelection } = + useExplorerSelectionActions(data); + const { allSelected, indeterminate, selectedCount, showSelectAllHint } = useExplorerSelectionSummary({ + data, + hasMore, + }); + const isAllResultsSelected = selectAllState === 'all' && total === selectedCount; // Handle automatic load more when scrolling to bottom const handleLoadMore = useCallback(async () => { @@ -131,7 +153,24 @@ const MasonryView = memo(function MasonryView() { } finally { setIsLoadingMore(false); } - }, [hasMore, loadMoreResources, isLoadingMore]); + }, [hasMore, isLoadingMore, loadMoreResources]); + + const handleSelectionChange = useCallback( + (id: string, checked: boolean) => { + toggleItemSelection(id, checked); + }, + [toggleItemSelection], + ); + + const masonryContext = useMemo( + () => ({ + knowledgeBaseId: libraryId, + onSelectedChange: handleSelectionChange, + selectAllState, + selectFileIds: selectedFileIds, + }), + [handleSelectionChange, libraryId, selectAllState, selectedFileIds], + ); // Handle scroll event to detect when near bottom const handleScroll = useCallback( @@ -156,13 +195,74 @@ const MasonryView = memo(function MasonryView() { style={{ flex: 1, height: '100%', - opacity: effectiveIsMasonryReady ? 1 : 0, + opacity: isMasonryReady ? 1 : 0, overflowY: 'auto', transition: 'opacity 0.2s ease-in-out', }} onScroll={handleScroll} >
+ + + + {selectedCount > 0 || selectAllState === 'all' + ? t( + selectAllState === 'all' + ? total + ? isAllResultsSelected + ? 'FileManager.total.allSelectedCount' + : 'FileManager.total.selectedCount' + : 'FileManager.total.allSelectedFallback' + : 'FileManager.total.selectedCount', + { + count: selectedCount, + ns: 'components', + }, + ) + : t('FileManager.total.fileCount', { + count: total || dataLength, + ns: 'components', + })} + + + {showSelectAllHint && ( + + + {t( + selectAllState === 'all' + ? total + ? isAllResultsSelected + ? 'FileManager.total.allSelectedCount' + : 'FileManager.total.selectedCount' + : 'FileManager.total.allSelectedFallback' + : 'FileManager.total.loadedSelectedCount', + { + count: selectedCount, + ns: 'components', + }, + )} + + {selectAllState !== 'all' && ( + + )} + + )} { + const showSkeleton = useMemo( + () => (isLoading && dataLength === 0) || (isNavigating && isValidating), + [dataLength, isLoading, isNavigating, isValidating], + ); + + const isMasonryReady = !showSkeleton; + + return { + isMasonryReady, + showSkeleton, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/SearchResultsOverlay.tsx b/src/features/ResourceManager/components/Explorer/SearchResultsOverlay.tsx index 2ce62a6bcd..ea48e1ad31 100644 --- a/src/features/ResourceManager/components/Explorer/SearchResultsOverlay.tsx +++ b/src/features/ResourceManager/components/Explorer/SearchResultsOverlay.tsx @@ -77,8 +77,15 @@ const SearchResultsOverlay = memo(() => { const masonryContext = useMemo( () => ({ knowledgeBaseId: libraryId ?? undefined, + onSelectedChange: (id: string, checked: boolean) => { + if (checked) { + setSelectedFileIds((prev) => [...prev, id]); + } else { + setSelectedFileIds((prev) => prev.filter((fid) => fid !== id)); + } + }, + selectAllState: 'loaded' as const, selectFileIds: selectedFileIds, - setSelectedFileIds, }), [libraryId, selectedFileIds], ); @@ -181,10 +188,8 @@ const SearchResultsOverlay = memo(() => { {}} onSelectedChange={(id, checked) => { if (checked) { setSelectedFileIds((prev) => [...prev, id]); diff --git a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx index efa465754e..40a48401ee 100644 --- a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx +++ b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx @@ -12,6 +12,7 @@ import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import RepoIcon from '@/components/LibIcon'; +import { useKnowledgeBaseListContext } from '@/features/ResourceManager/components/KnowledgeBaseListProvider'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; import { useKnowledgeBaseStore } from '@/store/library'; @@ -34,15 +35,13 @@ const BatchActionsDropdown = memo(({ selectCount, onA const { t } = useTranslation(['components', 'common', 'file', 'knowledgeBase']); const { modal, message } = App.useApp(); - const [libraryId, selectedFileIds] = useResourceManagerStore((s) => [ - s.libraryId, - s.selectedFileIds, + const libraryId = useResourceManagerStore((s) => s.libraryId); + const [resolveSelectedResourceIds, selectAllState] = useResourceManagerStore((s) => [ + s.resolveSelectedResourceIds, + s.selectAllState, ]); - const [useFetchKnowledgeBaseList, addFilesToKnowledgeBase] = useKnowledgeBaseStore((s) => [ - s.useFetchKnowledgeBaseList, - s.addFilesToKnowledgeBase, - ]); - const { data: knowledgeBases } = useFetchKnowledgeBaseList(); + const addFilesToKnowledgeBase = useKnowledgeBaseStore((s) => s.addFilesToKnowledgeBase); + const knowledgeBases = useKnowledgeBaseListContext(); const menuItems = useMemo(() => { const items: DropdownItem[] = []; @@ -70,7 +69,7 @@ const BatchActionsDropdown = memo(({ selectCount, onA } // Filter out current knowledge base and create submenu items - const availableKnowledgeBases = (knowledgeBases || []).filter((kb) => kb.id !== libraryId); + const availableKnowledgeBases = knowledgeBases.filter((kb) => kb.id !== libraryId); const addToKnowledgeBaseSubmenu: DropdownItem[] = availableKnowledgeBases.map((kb) => ({ disabled: selectCount === 0, @@ -79,10 +78,11 @@ const BatchActionsDropdown = memo(({ selectCount, onA label: {kb.name}, onClick: async () => { try { - await addFilesToKnowledgeBase(kb.id, selectedFileIds); + const effectiveSelectedIds = await resolveSelectedResourceIds(); + await addFilesToKnowledgeBase(kb.id, effectiveSelectedIds); message.success( t('addToKnowledgeBase.addSuccess', { - count: selectCount, + count: selectAllState === 'all' ? effectiveSelectedIds.length : selectCount, ns: 'knowledgeBase', }), ); @@ -172,9 +172,10 @@ const BatchActionsDropdown = memo(({ selectCount, onA }, [ libraryId, selectCount, - selectedFileIds, + selectAllState, onActionClick, addFilesToKnowledgeBase, + resolveSelectedResourceIds, t, modal, message, diff --git a/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.test.tsx b/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.test.tsx new file mode 100644 index 0000000000..6caa5c3e93 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.test.tsx @@ -0,0 +1,59 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { initialState } from '@/routes/(main)/resource/features/store/initialState'; + +import { useExplorerSelectionActions } from './useExplorerSelection'; + +describe('useExplorerSelectionActions', () => { + beforeEach(() => { + useResourceManagerStore.setState(initialState); + }); + + it('should keep all-selection mode and store deselected ids as exclusions', () => { + useResourceManagerStore.setState({ selectAllState: 'all', selectedFileIds: [] }); + + const { result } = renderHook(() => + useExplorerSelectionActions([{ id: 'file-1' }, { id: 'file-2' }]), + ); + + act(() => { + result.current.toggleItemSelection('file-1', false); + }); + + expect(useResourceManagerStore.getState()).toMatchObject({ + selectAllState: 'all', + selectedFileIds: ['file-1'], + }); + + act(() => { + result.current.toggleItemSelection('file-1', true); + }); + + expect(useResourceManagerStore.getState()).toMatchObject({ + selectAllState: 'all', + selectedFileIds: [], + }); + }); + + it('should reselect excluded items on the current page without clearing cross-page selection', () => { + useResourceManagerStore.setState({ + selectAllState: 'all', + selectedFileIds: ['file-1', 'file-9'], + }); + + const { result } = renderHook(() => + useExplorerSelectionActions([{ id: 'file-1' }, { id: 'file-2' }]), + ); + + act(() => { + result.current.handleSelectAll(true); + }); + + expect(useResourceManagerStore.getState()).toMatchObject({ + selectAllState: 'all', + selectedFileIds: ['file-9'], + }); + }); +}); diff --git a/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.ts b/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.ts new file mode 100644 index 0000000000..a2d30e51ae --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/hooks/useExplorerSelection.ts @@ -0,0 +1,139 @@ +import { useCallback, useMemo } from 'react'; + +import { useEventCallback } from '@/hooks/useEventCallback'; +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { + getExplorerSelectAllUiState, + getExplorerSelectedCount, + isExplorerItemSelected, +} from '@/routes/(main)/resource/features/store/selectors'; +import { useFileStore } from '@/store/file'; + +interface ExplorerSelectionOptions { + data: Array<{ id: string }>; + hasMore: boolean; +} + +export const useExplorerSelectionSummary = ({ data, hasMore }: ExplorerSelectionOptions) => { + const [selectAllState, selectedFileIds] = useResourceManagerStore((s) => [ + s.selectAllState, + s.selectedFileIds, + ]); + const total = useFileStore((s) => s.total); + const selectedCount = useMemo( + () => getExplorerSelectedCount({ selectAllState, selectedIds: selectedFileIds, total }), + [selectAllState, selectedFileIds, total], + ); + + const uiState = useMemo( + () => + getExplorerSelectAllUiState({ + data, + hasMore, + selectAllState, + selectedIds: selectedFileIds, + }), + [data, hasMore, selectAllState, selectedFileIds], + ); + + return { + ...uiState, + selectedCount, + selectAllState, + selectedFileIds, + total, + }; +}; + +export const useExplorerSelectionActions = (data: Array<{ id: string }>) => { + const [ + clearSelectAllState, + selectAllLoadedResources, + selectAllResources, + setSelectedFileIds, + selectedFileIds, + selectAllState, + ] = useResourceManagerStore((s) => [ + s.clearSelectAllState, + s.selectAllLoadedResources, + s.selectAllResources, + s.setSelectedFileIds, + s.selectedFileIds, + s.selectAllState, + ]); + + const handleSelectAll = useEventCallback((checked?: boolean) => { + const store = useResourceManagerStore.getState(); + const allLoadedSelected = + data.length > 0 && + data.every((item) => + isExplorerItemSelected({ + id: item.id, + selectAllState: store.selectAllState, + selectedIds: store.selectedFileIds, + }), + ); + + if (checked === false || (store.selectAllState !== 'all' && allLoadedSelected)) { + clearSelectAllState(); + return; + } + + if (store.selectAllState === 'all') { + const loadedIds = new Set(data.map((item) => item.id)); + const nextExcludedIds = store.selectedFileIds.filter((id) => !loadedIds.has(id)); + + if (nextExcludedIds.length !== store.selectedFileIds.length) { + setSelectedFileIds(nextExcludedIds); + } + + return; + } + + selectAllLoadedResources(data.map((item) => item.id)); + }); + + const handleSelectAllResources = useCallback(() => { + selectAllResources(); + }, [selectAllResources]); + + const toggleItemSelection = useCallback( + (id: string, checked: boolean) => { + const { selectAllState: currentSelectAllState, selectedFileIds: currentSelected } = + useResourceManagerStore.getState(); + + if (currentSelectAllState === 'all') { + if (checked) { + if (!currentSelected.includes(id)) return; + setSelectedFileIds(currentSelected.filter((item) => item !== id)); + return; + } + + if (currentSelected.includes(id)) return; + setSelectedFileIds([...currentSelected, id]); + return; + } + + clearSelectAllState(); + + if (checked) { + if (currentSelected.includes(id)) return; + setSelectedFileIds([...currentSelected, id]); + return; + } + + setSelectedFileIds(currentSelected.filter((item) => item !== id)); + }, + [clearSelectAllState, setSelectedFileIds], + ); + + return { + clearSelectAllState, + handleSelectAll, + handleSelectAllResources, + selectAllState, + selectedFileIds, + setSelectedFileIds, + toggleItemSelection, + }; +}; diff --git a/src/features/ResourceManager/components/Explorer/hooks/useFileSelection.ts b/src/features/ResourceManager/components/Explorer/hooks/useFileSelection.ts deleted file mode 100644 index 8d08e37185..0000000000 --- a/src/features/ResourceManager/components/Explorer/hooks/useFileSelection.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; -import { type FileListItem } from '@/types/files'; - -/** - * Hook to manage file selection with shift-click range selection support - */ -export const useFileSelection = (data?: FileListItem[]) => { - const [selectFileIds, setSelectedFileIds] = useResourceManagerStore((s) => [ - s.selectedFileIds, - s.setSelectedFileIds, - ]); - const [lastSelectedIndex, setLastSelectedIndex] = useState(null); - - // Handle selection change with shift-click support for range selection - const handleSelectionChange = useCallback( - (id: string, checked: boolean, shiftKey: boolean, clickedIndex: number) => { - if (shiftKey && lastSelectedIndex !== null && selectFileIds.length > 0 && data) { - const start = Math.min(lastSelectedIndex, clickedIndex); - const end = Math.max(lastSelectedIndex, clickedIndex); - const rangeIds = data.slice(start, end + 1).map((item) => item.id); - - const prevSet = new Set(selectFileIds); - rangeIds.forEach((rangeId) => prevSet.add(rangeId)); - setSelectedFileIds(Array.from(prevSet)); - } else { - if (checked) { - setSelectedFileIds([...selectFileIds, id]); - } else { - setSelectedFileIds(selectFileIds.filter((item) => item !== id)); - } - } - setLastSelectedIndex(clickedIndex); - }, - [lastSelectedIndex, selectFileIds, data, setSelectedFileIds], - ); - - // Clean up invalid selections when data changes - useEffect(() => { - if (data && selectFileIds.length > 0) { - const validFileIds = new Set(data.map((item) => item?.id).filter(Boolean)); - const filteredSelection = selectFileIds.filter((id) => validFileIds.has(id)); - if (filteredSelection.length !== selectFileIds.length) { - setSelectedFileIds(filteredSelection); - } - } - }, [data]); - - // Reset last selected index when all selections are cleared - useEffect(() => { - if (selectFileIds.length === 0) { - setLastSelectedIndex(null); - } - }, [selectFileIds.length]); - - return { - handleSelectionChange, - selectFileIds, - setSelectedFileIds, - }; -}; diff --git a/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.test.tsx b/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.test.tsx new file mode 100644 index 0000000000..49eff21860 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.test.tsx @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import { initialState } from '@/routes/(main)/resource/features/store/initialState'; +import { FilesTabs } from '@/types/files'; + +import { useResetSelectionOnQueryChange } from './useResetSelectionOnQueryChange'; + +describe('useResetSelectionOnQueryChange', () => { + beforeEach(() => { + useResourceManagerStore.setState(initialState); + }); + + it('should clear all-selection mode when the search query changes', () => { + const { rerender } = renderHook( + (props: { searchQuery: string | null }) => + useResetSelectionOnQueryChange({ + category: FilesTabs.All, + currentFolderSlug: 'folder-a', + libraryId: undefined, + searchQuery: props.searchQuery, + }), + { + initialProps: { searchQuery: null as string | null }, + }, + ); + + act(() => { + useResourceManagerStore.setState({ + selectAllState: 'all', + selectedFileIds: ['file-1'], + }); + }); + + act(() => { + rerender({ searchQuery: 'report' }); + }); + + expect(useResourceManagerStore.getState()).toMatchObject({ + selectAllState: 'none', + selectedFileIds: [], + }); + }); + + it('should clear selection when the folder changes', () => { + const { rerender } = renderHook( + (props: { currentFolderSlug: string | null }) => + useResetSelectionOnQueryChange({ + category: FilesTabs.All, + currentFolderSlug: props.currentFolderSlug, + libraryId: undefined, + searchQuery: null, + }), + { + initialProps: { currentFolderSlug: 'folder-a' }, + }, + ); + + act(() => { + useResourceManagerStore.setState({ + selectAllState: 'loaded', + selectedFileIds: ['file-1', 'file-2'], + }); + }); + + act(() => { + rerender({ currentFolderSlug: 'folder-b' }); + }); + + expect(useResourceManagerStore.getState()).toMatchObject({ + selectAllState: 'none', + selectedFileIds: [], + }); + }); +}); diff --git a/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.ts b/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.ts new file mode 100644 index 0000000000..922cdbc3e6 --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/hooks/useResetSelectionOnQueryChange.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; +import type { FilesTabs } from '@/types/files'; + +interface ResetSelectionOnQueryChangeOptions { + category: FilesTabs; + currentFolderSlug?: string | null; + libraryId?: string; + searchQuery: string | null; +} + +export const useResetSelectionOnQueryChange = ({ + category, + currentFolderSlug, + libraryId, + searchQuery, +}: ResetSelectionOnQueryChangeOptions) => { + const clearSelectAllState = useResourceManagerStore((s) => s.clearSelectAllState); + + useEffect(() => { + clearSelectAllState(); + }, [category, clearSelectAllState, currentFolderSlug, libraryId, searchQuery]); +}; diff --git a/src/features/ResourceManager/components/Explorer/index.tsx b/src/features/ResourceManager/components/Explorer/index.tsx index 31e436f622..ee6c0f1ca0 100644 --- a/src/features/ResourceManager/components/Explorer/index.tsx +++ b/src/features/ResourceManager/components/Explorer/index.tsx @@ -1,7 +1,7 @@ 'use client'; import { Flexbox } from '@lobehub/ui'; -import { memo, useEffect, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath'; import { useResourceManagerUrlSync } from '@/routes/(main)/resource/features/hooks/useResourceManagerUrlSync'; @@ -9,13 +9,14 @@ import { useResourceManagerStore } from '@/routes/(main)/resource/features/store import { sortFileList } from '@/routes/(main)/resource/features/store/selectors'; import { useFetchResources, useResourceStore } from '@/store/file/slices/resource/hooks'; +import { KnowledgeBaseListProvider } from '../KnowledgeBaseListProvider'; import EmptyPlaceholder from './EmptyPlaceholder'; import Header from './Header'; +import { useResetSelectionOnQueryChange } from './hooks/useResetSelectionOnQueryChange'; import ListView from './ListView'; import MasonryView from './MasonryView'; import SearchResultsOverlay from './SearchResultsOverlay'; import { useCheckTaskStatus } from './useCheckTaskStatus'; -import { useResourceExplorer } from './useResourceExplorer'; /** * Explore resource items in a library @@ -30,18 +31,9 @@ const ResourceExplorer = memo(() => { useResourceManagerUrlSync(); // Get state from Resource Manager store - const [libraryId, category, viewMode, searchQuery, setSelectedFileIds, sorter, sortType] = - useResourceManagerStore((s) => [ - s.libraryId, - s.category, - s.viewMode, - s.searchQuery, - s.setSelectedFileIds, - s.sorter, - s.sortType, - ]); - - // searchQuery is still subscribed above for selection-clearing effect below + const [libraryId, category, viewMode, searchQuery, sorter, sortType] = useResourceManagerStore( + (s) => [s.libraryId, s.category, s.viewMode, s.searchQuery, s.sorter, s.sortType], + ); // Get folder path for empty state check const { currentFolderSlug } = useFolderPath(); @@ -87,30 +79,35 @@ const ResourceExplorer = memo(() => { // Check task status useCheckTaskStatus(data); - // Initialize folder/file navigation effects (still need hook for complex effects) - useResourceExplorer({ category, libraryId }); - - // Clear selections when category/library/search changes - useEffect(() => { - setSelectedFileIds([]); - }, [category, libraryId, searchQuery, setSelectedFileIds]); + useResetSelectionOnQueryChange({ + category, + currentFolderSlug, + libraryId, + searchQuery, + }); const showEmptyStatus = !isLoading && !isValidating && data?.length === 0 && !currentFolderSlug; return ( - -
-
- {showEmptyStatus ? ( - - ) : viewMode === 'list' ? ( - - ) : ( - - )} - -
- + + +
+
+ {showEmptyStatus ? ( + + ) : viewMode === 'list' ? ( + + ) : ( + + )} + +
+ + ); }); diff --git a/src/features/ResourceManager/components/Explorer/useCheckTaskStatus.ts b/src/features/ResourceManager/components/Explorer/useCheckTaskStatus.ts index 8b332b98ee..dbbed4192f 100644 --- a/src/features/ResourceManager/components/Explorer/useCheckTaskStatus.ts +++ b/src/features/ResourceManager/components/Explorer/useCheckTaskStatus.ts @@ -1,29 +1,39 @@ import { useEffect } from 'react'; -import { revalidateResources } from '@/store/file/slices/resource/hooks'; +import { resourceService } from '@/services/resource'; +import { useFileStore } from '@/store/file'; import { AsyncTaskStatus } from '@/types/asyncTask'; import { type FileListItem } from '@/types/files'; export const useCheckTaskStatus = (data: FileListItem[] | undefined) => { - const hasProcessingChunkTask = data?.some( - (item) => item.chunkingStatus === AsyncTaskStatus.Processing, - ); - const hasProcessingEmbeddingTask = data?.some( - (item) => item.embeddingStatus === AsyncTaskStatus.Processing, - ); - - const isProcessing = hasProcessingChunkTask || hasProcessingEmbeddingTask; + const processingFileIds = + data + ?.filter( + (item) => + item.sourceType === 'file' && + (item.chunkingStatus === AsyncTaskStatus.Processing || + item.embeddingStatus === AsyncTaskStatus.Processing), + ) + .map((item) => item.id) ?? []; + const processingKey = processingFileIds.join(','); // Poll every 5s to check if chunking/embedding status has changed useEffect(() => { - if (!isProcessing) return; + if (processingFileIds.length === 0) return; const interval = setInterval(() => { - // Re-fetch with the same query params used for initial load - revalidateResources(); + void resourceService + .getResourceStatusesByIds(processingFileIds) + .then((items) => { + useFileStore.getState().patchLocalResourceStatuses(items); + }) + .catch((error) => { + console.error('Failed to sync knowledge item statuses:', error); + }); }, 5000); + return () => { clearInterval(interval); }; - }, [isProcessing]); + }, [processingKey]); }; diff --git a/src/features/ResourceManager/components/Explorer/useResourceExplorer.ts b/src/features/ResourceManager/components/Explorer/useResourceExplorer.ts deleted file mode 100644 index 3fdd3e6f1f..0000000000 --- a/src/features/ResourceManager/components/Explorer/useResourceExplorer.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; - -import { useAddFilesToKnowledgeBaseModal } from '@/features/LibraryModal'; -import { useFolderPath } from '@/routes/(main)/resource/features/hooks/useFolderPath'; -import { - useResourceManagerFetchFolderBreadcrumb, - useResourceManagerFetchKnowledgeItem, - useResourceManagerFetchKnowledgeItems, - useResourceManagerStore, -} from '@/routes/(main)/resource/features/store'; -import { type MultiSelectActionType } from '@/routes/(main)/resource/features/store/action'; -import { selectors, sortFileList } from '@/routes/(main)/resource/features/store/selectors'; -import { fileManagerSelectors, useFileStore } from '@/store/file'; -import { type FilesTabs } from '@/types/files'; - -import { useFileSelection } from './hooks/useFileSelection'; -import { useCheckTaskStatus } from './useCheckTaskStatus'; - -interface UseFileExplorerProps { - category?: FilesTabs; - libraryId?: string; -} - -export const useResourceExplorer = ({ - category: categoryProp, - libraryId, -}: UseFileExplorerProps) => { - const [, setSearchParams] = useSearchParams(); - - // Get state from Resource Manager store - const [ - viewMode, - currentViewItemId, - isTransitioning, - isMasonryReady, - searchQuery, - setCurrentFolderId, - setIsTransitioning, - setIsMasonryReady, - handleBackToList, - onActionClick, - pendingRenameItemId, - loadMoreKnowledgeItems, - fileListHasMore, - sorter, - sortType, - ] = useResourceManagerStore((s) => [ - s.viewMode, - s.currentViewItemId, - s.isTransitioning, - s.isMasonryReady, - s.searchQuery, - s.setCurrentFolderId, - s.setIsTransitioning, - s.setIsMasonryReady, - s.handleBackToList, - s.onActionClick, - s.pendingRenameItemId, - s.loadMoreKnowledgeItems, - s.fileListHasMore, - s.sorter, - s.sortType, - ]); - - const categoryFromStore = useResourceManagerStore((s) => s.category); - const category = categoryProp ?? categoryFromStore; - - // Folder navigation - const { currentFolderSlug } = useFolderPath(); - - // Current file - const { data: fetchedCurrentFile } = useResourceManagerFetchKnowledgeItem(currentViewItemId); - const currentFile = - useFileStore(fileManagerSelectors.getFileById(currentViewItemId)) || fetchedCurrentFile; - - // Folder operations - const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug); - - // Fetch data with SWR - const { data: rawData, isLoading } = useResourceManagerFetchKnowledgeItems({ - category, - knowledgeBaseId: libraryId, - parentId: currentFolderSlug || null, - q: searchQuery ?? undefined, - showFilesInKnowledgeBase: false, - }); - - // Sort data using current sort settings - const data = sortFileList(rawData, sorter, sortType); - - const { handleSelectionChange, selectFileIds, setSelectedFileIds } = useFileSelection(data); - - useCheckTaskStatus(data); - - // Get modal handler for knowledge base operations - const { open: openAddModal } = useAddFilesToKnowledgeBaseModal(); - - // Wrap onActionClick to handle modal operations that need React hooks - const handleActionClick = useCallback( - async (type: MultiSelectActionType) => { - // Handle modal-based actions here (can't be in store due to React hooks) - if (type === 'addToKnowledgeBase') { - openAddModal({ - fileIds: selectFileIds, - onClose: () => setSelectedFileIds([]), - }); - return; - } - - if (type === 'moveToOtherKnowledgeBase') { - openAddModal({ - fileIds: selectFileIds, - knowledgeBaseId: libraryId, - onClose: () => setSelectedFileIds([]), - }); - return; - } - - // Delegate other actions to store - await onActionClick(type); - }, - [onActionClick, openAddModal, selectFileIds, setSelectedFileIds, libraryId], - ); - - // Wrap handleBackToList to also update URL params - const handleBackToListWithUrl = useCallback(() => { - handleBackToList(); - setSearchParams((prev) => { - const newParams = new URLSearchParams(prev); - newParams.delete('file'); - return newParams; - }); - }, [handleBackToList, setSearchParams]); - - // Effects - Folder navigation - useEffect(() => { - if (!currentFolderSlug) { - setCurrentFolderId(null); - } else if (folderBreadcrumb && folderBreadcrumb.length > 0) { - const currentFolder = folderBreadcrumb.at(-1); - setCurrentFolderId(currentFolder?.id ?? null); - } - }, [currentFolderSlug, folderBreadcrumb, setCurrentFolderId]); - - // Handle view mode transition effects - useEffect(() => { - if (viewMode === 'masonry') { - setIsTransitioning(true); - setIsMasonryReady(false); - } - }, [viewMode, setIsTransitioning, setIsMasonryReady]); - - useEffect(() => { - if (isTransitioning && data) { - requestAnimationFrame(() => { - const timer = setTimeout(() => { - setIsTransitioning(false); - }, 100); - return () => clearTimeout(timer); - }); - } - }, [isTransitioning, data, setIsTransitioning]); - - useEffect(() => { - if (viewMode === 'masonry' && data && !isLoading && !isTransitioning) { - const timer = setTimeout(() => { - setIsMasonryReady(true); - }, 300); - return () => clearTimeout(timer); - } else if (viewMode === 'list') { - setIsMasonryReady(false); - } - }, [viewMode, data, isLoading, isTransitioning, setIsMasonryReady]); - - const showEmptyStatus = !isLoading && data?.length === 0 && !currentFolderSlug; - const isFilePreviewMode = useResourceManagerStore(selectors.isFilePreviewMode); - - return { - // Data - category, - currentFile, - currentFolderSlug, - currentViewItemId, - data, - // Handlers - handleBackToList: handleBackToListWithUrl, - - handleSelectionChange, - - hasMore: fileListHasMore, - - isFilePreviewMode, - - isLoading, - - // State - isMasonryReady, - - isTransitioning, - - // Pagination - loadMoreKnowledgeItems, - - onActionClick: handleActionClick, - - pendingRenameItemId, - - selectFileIds, - setSelectedFileIds, - showEmptyStatus, - viewMode, - }; -}; diff --git a/src/features/ResourceManager/components/FolderTree/index.tsx b/src/features/ResourceManager/components/FolderTree/index.tsx index 84f75b4094..1d7f5cbf19 100644 --- a/src/features/ResourceManager/components/FolderTree/index.tsx +++ b/src/features/ResourceManager/components/FolderTree/index.tsx @@ -4,7 +4,7 @@ import { CaretDownFilled } from '@ant-design/icons'; import { ActionIcon, Flexbox, Icon } from '@lobehub/ui'; import { createStaticStyles, cx } from 'antd-style'; import { FolderIcon, FolderOpenIcon } from 'lucide-react'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { memo, useCallback } from 'react'; const styles = createStaticStyles(({ css, cssVar }) => ({ @@ -89,7 +89,7 @@ export const FolderTreeItemComponent = memo( style={{ paddingInlineStart: level * 16 + 8 }} onClick={handleClick} > - @@ -101,7 +101,7 @@ export const FolderTreeItemComponent = memo( handleToggle(); }} /> - + ( {isExpanded && item.children && item.children.length > 0 && ( - ( /> ))} - + )} ); diff --git a/src/features/ResourceManager/components/Header/AddButton.tsx b/src/features/ResourceManager/components/Header/AddButton.tsx index 84a538886d..94de13ebe9 100644 --- a/src/features/ResourceManager/components/Header/AddButton.tsx +++ b/src/features/ResourceManager/components/Header/AddButton.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { message } from '@/components/AntdStaticMethods'; import GuideModal from '@/components/GuideModal'; import GuideVideo from '@/components/GuideVideo'; +import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; import { useFileStore } from '@/store/file'; import { FilesTabs } from '@/types/files'; @@ -46,28 +47,21 @@ const AddButton = () => { const uploadFolderWithStructure = useFileStore((s) => s.uploadFolderWithStructure); const createResourceAndSync = useFileStore((s) => s.createResourceAndSync); const [menuOpen, setMenuOpen] = useState(false); + const currentFolderId = useCurrentFolderId(); // TODO: Migrate Notion import to use createResource // Keep old functions temporarily for components not yet migrated const createDocument = useFileStore((s) => s.createDocument); - const [ - libraryId, - category, - currentFolderId, - setCategory, - setCurrentViewItemId, - setMode, - setPendingRenameItemId, - ] = useResourceManagerStore((s) => [ - s.libraryId, - s.category, - s.currentFolderId, - s.setCategory, - s.setCurrentViewItemId, - s.setMode, - s.setPendingRenameItemId, - ]); + const [libraryId, category, setCategory, setCurrentViewItemId, setMode, setPendingRenameItemId] = + useResourceManagerStore((s) => [ + s.libraryId, + s.category, + s.setCategory, + s.setCurrentViewItemId, + s.setMode, + s.setPendingRenameItemId, + ]); const handleOpenPageEditor = useCallback(async () => { // Navigate to "All" category first if not already there @@ -167,10 +161,6 @@ const AddButton = () => { createDocument, currentFolderId, libraryId, - refetchResources: async () => { - const { revalidateResources } = await import('@/store/file/slices/resource/hooks'); - await revalidateResources(); - }, t, }); diff --git a/src/features/ResourceManager/components/Header/hooks/useNotionImport.ts b/src/features/ResourceManager/components/Header/hooks/useNotionImport.ts index d6436aff02..499d86efa4 100644 --- a/src/features/ResourceManager/components/Header/hooks/useNotionImport.ts +++ b/src/features/ResourceManager/components/Header/hooks/useNotionImport.ts @@ -12,7 +12,7 @@ interface UseNotionImportOptions { createDocument: DocumentAction['createDocument']; currentFolderId?: string | null; libraryId?: string | null; - refetchResources: () => Promise; + refetchResources?: () => Promise; t: TFunction<'file'>; } @@ -172,8 +172,7 @@ const useNotionImport = ({ ); } - // Refetch resources to show imported documents - await refetchResources(); + await refetchResources?.(); } catch (error) { console.error('Failed to import Notion export:', error); const { message } = await import('antd'); diff --git a/src/features/ResourceManager/components/KnowledgeBaseListProvider.tsx b/src/features/ResourceManager/components/KnowledgeBaseListProvider.tsx new file mode 100644 index 0000000000..c234e4e920 --- /dev/null +++ b/src/features/ResourceManager/components/KnowledgeBaseListProvider.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { PropsWithChildren } from 'react'; +import { createContext, memo, use, useMemo } from 'react'; + +import { useKnowledgeBaseStore } from '@/store/library'; +import type { KnowledgeBaseItem } from '@/types/knowledgeBase'; + +const KnowledgeBaseListContext = createContext(null); + +export const KnowledgeBaseListProvider = memo(({ children }) => { + const useFetchKnowledgeBaseList = useKnowledgeBaseStore((s) => s.useFetchKnowledgeBaseList); + const { data } = useFetchKnowledgeBaseList(); + + const knowledgeBases = useMemo(() => data ?? [], [data]); + + return ( + + {children} + + ); +}); + +KnowledgeBaseListProvider.displayName = 'KnowledgeBaseListProvider'; + +export const useKnowledgeBaseListContext = () => { + const context = use(KnowledgeBaseListContext); + + if (!context) { + throw new Error('useKnowledgeBaseListContext must be used within KnowledgeBaseListProvider'); + } + + return context; +}; diff --git a/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx index c749c11651..d0b1626315 100644 --- a/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx +++ b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx @@ -5,7 +5,7 @@ import { ActionIcon, Block, Flexbox, Icon, showContextMenu, stopPropagation } fr import { App, Input } from 'antd'; import { cx } from 'antd-style'; import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -14,7 +14,7 @@ import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; import { getTransparentDragImage, useDragActive, - useDragState, + useSetCurrentDrag, } from '@/routes/(main)/resource/features/DndContextWrapper'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; import { useFileStore } from '@/store/file'; @@ -135,7 +135,7 @@ export const HierarchyNode = memo( }); const isDragActive = useDragActive(); - const { setCurrentDrag } = useDragState(); + const setCurrentDrag = useSetCurrentDrag(); const [isDragging, setIsDragging] = useState(false); const [isOver, setIsOver] = useState(false); @@ -264,7 +264,7 @@ export const HierarchyNode = memo( {isLoading ? ( ) : ( - ( handleToggle(); }} /> - + )} { : currentFolderSlug; return ( - - - {visibleNodes.map(({ item, key, level }) => ( -
- -
- ))} -
-
+ + + + {visibleNodes.map(({ item, key, level }) => ( +
+ +
+ ))} +
+
+
); }); diff --git a/src/features/ResourceManager/components/UploadDock/index.tsx b/src/features/ResourceManager/components/UploadDock/index.tsx index 59b9694973..5488c5e671 100644 --- a/src/features/ResourceManager/components/UploadDock/index.tsx +++ b/src/features/ResourceManager/components/UploadDock/index.tsx @@ -3,12 +3,11 @@ import { ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; import isEqual from 'fast-deep-equal'; import { UploadIcon, XIcon } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import { memo, useEffect, useMemo, useState } from 'react'; +import { AnimatePresence, m } from 'motion/react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { fileManagerSelectors, useFileStore } from '@/store/file'; -import { convertAlphaToSolid } from '@/utils/colorUtils'; import Item from './Item'; @@ -60,12 +59,26 @@ const UploadDock = memo(() => { const setExpand = useFileStore((s) => s.setUploadDockExpanded); const totalUploadingProgress = useFileStore(fileManagerSelectors.overviewUploadingProgress); const fileList = useFileStore(fileManagerSelectors.dockFileList, isEqual); + const cancelUploads = useFileStore((s) => s.cancelUploads); const overviewUploadingStatus = useFileStore( fileManagerSelectors.overviewUploadingStatus, isEqual, ); const isUploading = overviewUploadingStatus === 'uploading'; + const hasCancellableUploads = useMemo( + () => fileList.some((item) => item.status === 'uploading' || item.status === 'pending'), + [fileList], + ); + + const cancelAllActiveUploads = useCallback(() => { + cancelUploads( + fileList + .filter((item) => item.status === 'uploading' || item.status === 'pending') + .map((item) => item.id), + ); + }, [cancelUploads, fileList]); + const icon = useMemo(() => { switch (overviewUploadingStatus) { case 'success': { @@ -111,35 +124,44 @@ const UploadDock = memo(() => { onClick={() => { setExpand(!expand); }} - onMouseEnter={(e) => { - e.currentTarget.style.background = convertAlphaToSolid( - cssVar.colorFillTertiary, - cssVar.colorBgContainer, - ); - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = cssVar.colorBgContainer; - }} > {icon} {t(`uploadDock.uploadStatus.${overviewUploadingStatus}`)} ·{' '} {t('uploadDock.totalCount', { count })} - {!isUploading && ( - { - setShow(false); - dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' }); - }} - /> - )} + { + e.stopPropagation(); + }} + > + {hasCancellableUploads && ( + + {t('uploadDock.header.cancelAll')} + + )} + {!isUploading && ( + { + setShow(false); + dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' }); + }} + /> + )} +
{expand ? ( - { transition={{ duration: 0.3, ease: 'easeInOut' }} > { height: 400, }} > - + {fileList.map((item) => ( ))} -
- { - setExpand(false); - }} - > - {t('uploadDock.body.collapse')} - -
+ {isUploading && ( +
+ { + setExpand(false); + }} + > + {t('uploadDock.body.collapse')} + +
+ )}
-
+ ) : ( overviewUploadingStatus !== 'pending' && ( - { insetInlineEnd: `${100 - totalUploadingProgress}%`, }} /> - + ) )}
diff --git a/src/features/ResourceManager/index.tsx b/src/features/ResourceManager/index.tsx index a89d41bbb3..1e057a5d30 100644 --- a/src/features/ResourceManager/index.tsx +++ b/src/features/ResourceManager/index.tsx @@ -9,6 +9,7 @@ import { useSearchParams } from 'react-router-dom'; import DragUploadZone from '@/components/DragUploadZone'; import { PageEditor } from '@/features/PageEditor'; import dynamic from '@/libs/next/dynamic'; +import { useCurrentFolderId } from '@/routes/(main)/resource/features/hooks/useCurrentFolderId'; import { useResourceManagerStore } from '@/routes/(main)/resource/features/store'; import { documentService } from '@/services/document'; import { useFileStore } from '@/store/file'; @@ -59,12 +60,12 @@ export type ResourceManagerMode = 'editor' | 'explorer' | 'page'; const ResourceManager = memo(() => { const theme = useTheme(); const [, setSearchParams] = useSearchParams(); - const [mode, currentViewItemId, libraryId, currentFolderId, setMode, setCurrentViewItemId] = + const currentFolderId = useCurrentFolderId(); + const [mode, currentViewItemId, libraryId, setMode, setCurrentViewItemId] = useResourceManagerStore((s) => [ s.mode, s.currentViewItemId, s.libraryId, - s.currentFolderId, s.setMode, s.setCurrentViewItemId, ]); diff --git a/src/initialize.ts b/src/initialize.ts index 3626dd327f..325a96699b 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -3,11 +3,12 @@ import isToday from 'dayjs/plugin/isToday'; import isYesterday from 'dayjs/plugin/isYesterday'; import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; -import { enableMapSet } from 'immer'; +import { enableMapSet, enablePatches } from 'immer'; import { scan } from 'react-scan'; import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; +enablePatches(); enableMapSet(); // Dayjs plugins - extend once at app init to avoid duplicate extensions in components diff --git a/src/layout/GlobalProvider/AppTheme.tsx b/src/layout/GlobalProvider/AppTheme.tsx index 26ad8a3666..52151ddd9a 100644 --- a/src/layout/GlobalProvider/AppTheme.tsx +++ b/src/layout/GlobalProvider/AppTheme.tsx @@ -8,7 +8,7 @@ import { ConfigProvider, FontLoader, ThemeProvider } from '@lobehub/ui'; import { message as antdMessage } from 'antd'; import { AppConfigContext } from 'antd/es/app/context'; import { createStaticStyles, cx, useTheme } from 'antd-style'; -import * as motion from 'motion/react-m'; +import * as m from 'motion/react-m'; import { type ReactNode } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; @@ -176,7 +176,7 @@ const AppTheme = memo( (({ id, title, fav, active, threadId, meta {hasUnread && ( - (({ id, title, fav, active, threadId, meta opacity: 0, }} > - (({ id, title, fav, active, threadId, meta ease: 'easeInOut', }} /> - + )} diff --git a/src/routes/(main)/home/features/InputArea/index.tsx b/src/routes/(main)/home/features/InputArea/index.tsx index d0ef0e5928..0fc5db30b7 100644 --- a/src/routes/(main)/home/features/InputArea/index.tsx +++ b/src/routes/(main)/home/features/InputArea/index.tsx @@ -1,5 +1,5 @@ import { Flexbox } from '@lobehub/ui'; -import { AnimatePresence, m as motion } from 'motion/react'; +import { AnimatePresence, m } from 'motion/react'; import { useEffect, useMemo, useRef } from 'react'; import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone'; @@ -126,7 +126,7 @@ const InputArea = () => {
{showSuggestQuestions && ( - { - + )} diff --git a/src/routes/(main)/resource/features/DndContextWrapper.tsx b/src/routes/(main)/resource/features/DndContextWrapper.tsx index fc50f0dfb1..2155623060 100644 --- a/src/routes/(main)/resource/features/DndContextWrapper.tsx +++ b/src/routes/(main)/resource/features/DndContextWrapper.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Icon } from '@lobehub/ui'; +import { Icon, useAppElement } from '@lobehub/ui'; import { App } from 'antd'; import { cssVar } from 'antd-style'; import { FileText, FolderIcon } from 'lucide-react'; @@ -51,16 +51,20 @@ interface DragState { type: 'file' | 'folder'; } -const DragStateContext = createContext<{ - currentDrag: DragState | null; +const CurrentDragContext = createContext(null); +const SetCurrentDragContext = createContext<((_state: DragState | null) => void) | null>(null); - setCurrentDrag: (_state: DragState | null) => void; -}>({ - currentDrag: null, - setCurrentDrag: () => {}, -}); +export const useCurrentDrag = () => use(CurrentDragContext); -export const useDragState = () => use(DragStateContext); +export const useSetCurrentDrag = () => { + const setCurrentDrag = use(SetCurrentDragContext); + + if (!setCurrentDrag) { + throw new Error('useSetCurrentDrag must be used within DndContextWrapper'); + } + + return setCurrentDrag; +}; /** * Pragmatic DnD wrapper for resource drag-and-drop @@ -71,10 +75,11 @@ export const DndContextWrapper = memo(({ children }) => { const { message } = App.useApp(); const [currentDrag, setCurrentDrag] = useState(null); const overlayRef = useRef(null); - const moveResource = useFileStore((s) => s.moveResource); - const resourceList = useFileStore((s) => s.resourceList); - const selectedFileIds = useResourceManagerStore((s) => s.selectedFileIds); - const setSelectedFileIds = useResourceManagerStore((s) => s.setSelectedFileIds); + const [moveResource, resourceList] = useFileStore((s) => [s.moveResource, s.resourceList]); + const [selectedFileIds, setSelectedFileIds] = useResourceManagerStore((s) => [ + s.selectedFileIds, + s.setSelectedFileIds, + ]); const libraryId = useResourceManagerStore((s) => s.libraryId); // Track mouse position and handle drag events @@ -145,10 +150,6 @@ export const DndContextWrapper = memo(({ children }) => { await Promise.all(pools); - // Refetch resources to update the view (items should disappear from current folder) - const { revalidateResources } = await import('@/store/file/slices/resource/hooks'); - await revalidateResources(); - // Clear and reload all expanded folders in Tree's module-level cache if (libraryId) { await clearTreeFolderCache(libraryId); @@ -205,6 +206,8 @@ export const DndContextWrapper = memo(({ children }) => { libraryId, ]); + const appElement = useAppElement(); + // Change cursor to grabbing during drag useEffect(() => { let styleElement: HTMLStyleElement | null = null; @@ -237,93 +240,95 @@ export const DndContextWrapper = memo(({ children }) => { return ( - - {children} - {typeof document !== 'undefined' && - createPortal( - currentDrag ? ( -
+ + + {children} + {typeof document !== 'undefined' && + createPortal( + currentDrag ? (
- {currentDrag.data.fileType === 'custom/folder' ? ( - - ) : currentDrag.data.fileType === 'custom/document' ? ( - - ) : ( - - )} -
- - {currentDrag.data.name} - - {selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && (
- {selectedFileIds.length} + {currentDrag.data.fileType === 'custom/folder' ? ( + + ) : currentDrag.data.fileType === 'custom/document' ? ( + + ) : ( + + )}
- )} -
- ) : null, - document.body, - )} -
+ + {currentDrag.data.name} + + {selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && ( +
+ {selectedFileIds.length} +
+ )} +
+ ) : null, + appElement ?? document.body, + )} + + ); }); diff --git a/src/routes/(main)/resource/features/hooks/useCurrentFolderId.ts b/src/routes/(main)/resource/features/hooks/useCurrentFolderId.ts new file mode 100644 index 0000000000..3c1b5b8540 --- /dev/null +++ b/src/routes/(main)/resource/features/hooks/useCurrentFolderId.ts @@ -0,0 +1,9 @@ +import { useResourceManagerFetchFolderBreadcrumb } from '../store'; +import { useFolderPath } from './useFolderPath'; + +export const useCurrentFolderId = () => { + const { currentFolderSlug } = useFolderPath(); + const { data: folderBreadcrumb } = useResourceManagerFetchFolderBreadcrumb(currentFolderSlug); + + return folderBreadcrumb?.at(-1)?.id || null; +}; diff --git a/src/routes/(main)/resource/features/hooks/useResourceManagerUrlSync.ts b/src/routes/(main)/resource/features/hooks/useResourceManagerUrlSync.ts index 76ac29cd8d..60ee37a4a6 100644 --- a/src/routes/(main)/resource/features/hooks/useResourceManagerUrlSync.ts +++ b/src/routes/(main)/resource/features/hooks/useResourceManagerUrlSync.ts @@ -11,10 +11,9 @@ import { SortType } from '@/types/files'; export const useResourceManagerUrlSync = () => { const [searchParams, setSearchParams] = useSearchParams(); - const [sorter, sortType, viewMode, setSorter, setSortType] = useResourceManagerStore((s) => [ + const [sorter, sortType, setSorter, setSortType] = useResourceManagerStore((s) => [ s.sorter, s.sortType, - s.viewMode, s.setSorter, s.setSortType, ]); @@ -51,16 +50,9 @@ export const useResourceManagerUrlSync = () => { newParams.set('sortType', sortType); } - // View mode (clear if default) - if (viewMode === 'list') { - newParams.delete('view'); - } else { - newParams.set('view', viewMode); - } - return newParams; }, { replace: true }, ); // Use replace to avoid polluting history - }, [sorter, sortType, viewMode, setSearchParams]); + }, [sorter, sortType, setSearchParams]); }; diff --git a/src/routes/(main)/resource/features/store/action.test.ts b/src/routes/(main)/resource/features/store/action.test.ts new file mode 100644 index 0000000000..7d4bac8ad8 --- /dev/null +++ b/src/routes/(main)/resource/features/store/action.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { initialState as fileInitialState } from '@/store/file/initialState'; +import { useFileStore } from '@/store/file/store'; + +import { useResourceManagerStore } from '.'; +import { initialState } from './initialState'; + +const { mockDeleteResourcesByQuery, mockResolveSelectionIds } = vi.hoisted(() => ({ + mockDeleteResourcesByQuery: vi.fn(), + mockResolveSelectionIds: vi.fn(), +})); + +vi.mock('@/services/resource', () => ({ + resourceService: { + deleteResourcesByQuery: mockDeleteResourcesByQuery, + resolveSelectionIds: mockResolveSelectionIds, + }, +})); + +describe('resource manager store actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + useResourceManagerStore.setState(initialState); + useFileStore.setState(fileInitialState); + }); + + it('should exclude deselected ids when resolving all-selected resources', async () => { + useResourceManagerStore.setState({ + selectAllState: 'all', + selectedFileIds: ['file-2'], + }); + useFileStore.setState({ + queryParams: { q: 'report' } as any, + }); + mockResolveSelectionIds.mockResolvedValue({ + ids: ['file-1', 'file-2', 'file-3'], + }); + + const result = await useResourceManagerStore.getState().resolveSelectedResourceIds(); + + expect(mockResolveSelectionIds).toHaveBeenCalledWith({ q: 'report' }); + expect(result).toEqual(['file-1', 'file-3']); + }); + + it('should avoid delete-by-query when all-selected mode has exclusions', async () => { + const deleteResources = vi.fn().mockResolvedValue(undefined); + + useResourceManagerStore.setState({ + selectAllState: 'all', + selectedFileIds: ['file-2'], + }); + useFileStore.setState({ + clearCurrentQueryResources: vi.fn(), + deleteResources, + queryParams: { q: 'report' } as any, + }); + mockResolveSelectionIds.mockResolvedValue({ + ids: ['file-1', 'file-2', 'file-3'], + }); + + await useResourceManagerStore.getState().onActionClick('delete'); + + expect(mockDeleteResourcesByQuery).not.toHaveBeenCalled(); + expect(deleteResources).toHaveBeenCalledWith(['file-1', 'file-3']); + }); +}); diff --git a/src/routes/(main)/resource/features/store/action.ts b/src/routes/(main)/resource/features/store/action.ts index af005a59b2..c8cc5290bf 100644 --- a/src/routes/(main)/resource/features/store/action.ts +++ b/src/routes/(main)/resource/features/store/action.ts @@ -1,9 +1,11 @@ -import { type StateCreator } from 'zustand/vanilla'; +import type { StateCreator } from 'zustand/vanilla'; -import { type ResourceManagerMode } from '@/features/ResourceManager'; -import { type FilesTabs, type SortType } from '@/types/files'; +import type { ResourceManagerMode } from '@/features/ResourceManager'; +import type { StoreSetter } from '@/store/types'; +import { flattenActions } from '@/store/utils/flattenActions'; +import type { FilesTabs, SortType } from '@/types/files'; -import { type State, type ViewMode } from './initialState'; +import type { SelectAllState, State, ViewMode } from './initialState'; import { initialState } from './initialState'; export type MultiSelectActionType = @@ -20,116 +22,30 @@ export interface FolderCrumb { slug: string; } -export interface Action { - /** - * Handle navigating back to list from file preview - */ - handleBackToList: () => void; - /** - * Load more knowledge items (pagination) - */ - loadMoreKnowledgeItems: () => Promise; - /** - * Handle multi-select actions (delete, chunking, KB operations, etc.) - */ - onActionClick: (type: MultiSelectActionType) => Promise; - /** - * Set the current file category filter - */ - setCategory: (category: FilesTabs) => void; - /** - * Set the current folder ID - */ - setCurrentFolderId: (folderId: string | null | undefined) => void; - /** - * Set the current view item ID - */ - setCurrentViewItemId: (id?: string) => void; - /** - * Set whether there are more files to load - */ - setFileListHasMore: (value: boolean) => void; - /** - * Set the pagination offset - */ - setFileListOffset: (value: number) => void; - /** - * Set masonry ready state - */ - setIsMasonryReady: (value: boolean) => void; - /** - * Set view transition state - */ - setIsTransitioning: (value: boolean) => void; - /** - * Set the current library ID - */ - setLibraryId: (id?: string) => void; - /** - * Set the view mode - */ - setMode: (mode: ResourceManagerMode) => void; - /** - * Set the pending rename item ID - */ - setPendingRenameItemId: (id: string | null) => void; - /** - * Set search query - */ - setSearchQuery: (query: string | null) => void; - /** - * Set selected file IDs - */ - setSelectedFileIds: (ids: string[]) => void; - /** - * Set the field to sort files by - */ - setSorter: (sorter: 'name' | 'createdAt' | 'size') => void; - /** - * Set the sort direction - */ - setSortType: (sortType: SortType) => void; - /** - * Set the file explorer view mode - */ - setViewMode: (viewMode: ViewMode) => void; -} - export type Store = Action & State; -type CreateStore = ( - initState?: Partial, -) => StateCreator; +type Setter = StoreSetter; -export const store: CreateStore = (publicState) => (set, get) => ({ - ...initialState, - ...publicState, +export class ResourceManagerStoreActionImpl { + readonly #get: () => Store; + readonly #set: Setter; - handleBackToList: () => { - set({ currentViewItemId: undefined, mode: 'explorer' }); - }, + constructor(set: Setter, get: () => Store, _api?: unknown) { + void _api; + this.#set = set; + this.#get = get; + } - loadMoreKnowledgeItems: async () => { - const { fileListHasMore } = get(); + clearSelectAllState = (): void => { + this.#set({ selectAllState: 'none', selectedFileIds: [] }); + }; - // Don't load if there's no more data - if (!fileListHasMore) return; + handleBackToList = (): void => { + this.#set({ currentViewItemId: undefined, mode: 'explorer' }); + }; - const { useFileStore } = await import('@/store/file'); - const fileStore = useFileStore.getState(); - - // Delegate to FileStore's loadMoreKnowledgeItems - await fileStore.loadMoreKnowledgeItems(); - - // Sync pagination state back to ResourceManagerStore - set({ - fileListHasMore: fileStore.fileListHasMore, - fileListOffset: fileStore.fileListOffset, - }); - }, - - onActionClick: async (type) => { - const { selectedFileIds, libraryId } = get(); + onActionClick = async (type: MultiSelectActionType): Promise => { + const { libraryId, resolveSelectedResourceIds, selectAllState, selectedFileIds } = this.#get(); const { useFileStore } = await import('@/store/file'); const { useKnowledgeBaseStore } = await import('@/store/library'); const { isChunkingUnsupported } = await import('@/utils/isChunkingUnsupported'); @@ -139,122 +55,152 @@ export const store: CreateStore = (publicState) => (set, get) => ({ switch (type) { case 'delete': { - await fileStore.deleteResources(selectedFileIds); + if (selectAllState === 'all' && selectedFileIds.length === 0 && fileStore.queryParams) { + const { resourceService } = await import('@/services/resource'); - set({ selectedFileIds: [] }); + await resourceService.deleteResourcesByQuery(fileStore.queryParams as any); + fileStore.clearCurrentQueryResources(); + + this.#set({ selectAllState: 'none', selectedFileIds: [] }); + return; + } + + const resourceIds = + selectAllState === 'all' ? await resolveSelectedResourceIds() : selectedFileIds; + + await fileStore.deleteResources(resourceIds); + + this.#set({ selectAllState: 'none', selectedFileIds: [] }); return; } case 'removeFromKnowledgeBase': { + const resourceIds = await resolveSelectedResourceIds(); if (!libraryId) return; - await kbStore.removeFilesFromKnowledgeBase(libraryId, selectedFileIds); - set({ selectedFileIds: [] }); - return; - } - - case 'addToKnowledgeBase': { - // Modal operations need to be handled in component layer - // Store just marks that action was requested - // Component will handle opening modal via useAddFilesToKnowledgeBaseModal hook + + await kbStore.removeFilesFromKnowledgeBase(libraryId, resourceIds); + this.#set({ selectAllState: 'none', selectedFileIds: [] }); return; } + case 'addToKnowledgeBase': case 'moveToOtherKnowledgeBase': { - // Modal operations need to be handled in component layer - // Store just marks that action was requested - // Component will handle opening modal via useAddFilesToKnowledgeBaseModal hook return; } case 'batchChunking': { - const chunkableFileIds = selectedFileIds.filter((id) => { + const resourceIds = await resolveSelectedResourceIds(); + const chunkableFileIds = resourceIds.filter((id) => { const resource = fileStore.resourceMap?.get(id); - return resource && !isChunkingUnsupported(resource.fileType); + // For server-resolved IDs not yet in the local map, include them + // and let the server handle unsupported type filtering + if (!resource) return selectAllState === 'all'; + return !isChunkingUnsupported(resource.fileType); }); + await fileStore.parseFilesToChunks(chunkableFileIds, { skipExist: true }); - set({ selectedFileIds: [] }); + this.#set({ selectAllState: 'none', selectedFileIds: [] }); return; } case 'deleteLibrary': { if (!libraryId) return; + await kbStore.removeKnowledgeBase(libraryId); - // Navigate to knowledge base page using window.location - // (can't use useNavigate hook from store) + if (typeof window !== 'undefined') { window.location.href = '/knowledge'; } - return; } } - }, + }; - setCategory: (category) => { - set({ category }); - }, + resolveSelectedResourceIds = async (): Promise => { + const { selectAllState, selectedFileIds } = this.#get(); + if (selectAllState !== 'all') return selectedFileIds; - setCurrentFolderId: (currentFolderId) => { - set({ currentFolderId }); - }, + const { resourceService } = await import('@/services/resource'); + const { useFileStore } = await import('@/store/file'); + const queryParams = useFileStore.getState().queryParams; - setCurrentViewItemId: (currentViewItemId) => { - set({ currentViewItemId }); - }, + if (!queryParams) return selectedFileIds; - setFileListHasMore: (fileListHasMore) => { - set({ fileListHasMore }); - }, + const result = await resourceService.resolveSelectionIds(queryParams as any); + return result.ids.filter((id) => !selectedFileIds.includes(id)); + }; - setFileListOffset: (fileListOffset) => { - set({ fileListOffset }); - }, + selectAllLoadedResources = (selectedFileIds: string[]): void => { + this.#set({ selectedFileIds, selectAllState: 'loaded' }); + }; - setIsMasonryReady: (isMasonryReady) => { - set({ isMasonryReady }); - }, + selectAllResources = (): void => { + this.#set({ selectAllState: 'all', selectedFileIds: [] }); + }; - setIsTransitioning: (isTransitioning) => { - set({ isTransitioning }); - }, + setCategory = (category: FilesTabs): void => { + this.#set({ category }); + }; - setLibraryId: (libraryId) => { - set({ libraryId }); + setCurrentViewItemId = (currentViewItemId?: string): void => { + this.#set({ currentViewItemId }); + }; - // Reset pagination state when switching libraries to prevent showing stale data - set({ - fileListHasMore: false, - fileListOffset: 0, + setLibraryId = (libraryId?: string): void => { + this.#set({ libraryId }); + }; + + setMode = (mode: ResourceManagerMode): void => { + this.#set({ mode }); + }; + + setPendingRenameItemId = (pendingRenameItemId: string | null): void => { + this.#set({ pendingRenameItemId }); + }; + + setSearchQuery = (searchQuery: string | null): void => { + this.#set({ searchQuery }); + }; + + setSelectAllState = (selectAllState: SelectAllState): void => { + this.#set({ selectAllState }); + }; + + setSelectedFileIds = (selectedFileIds: string[]): void => { + const { selectAllState } = this.#get(); + + this.#set({ + selectAllState: + selectedFileIds.length === 0 && selectAllState !== 'all' ? 'none' : selectAllState, + selectedFileIds, }); + }; - // Note: No need to manually refresh - Explorer's useEffect will automatically - // call fetchResources when libraryId changes - }, + setSorter = (sorter: 'name' | 'createdAt' | 'size'): void => { + this.#set({ sorter }); + }; - setMode: (mode) => { - set({ mode }); - }, + setSortType = (sortType: SortType): void => { + this.#set({ sortType }); + }; - setPendingRenameItemId: (pendingRenameItemId) => { - set({ pendingRenameItemId }); - }, + setViewMode = (viewMode: ViewMode): void => { + this.#set({ viewMode }); + }; +} - setSearchQuery: (searchQuery) => { - set({ searchQuery }); - }, +export type Action = Pick; - setSelectedFileIds: (selectedFileIds) => { - set({ selectedFileIds }); - }, +export const createResourceManagerStoreSlice = (set: Setter, get: () => Store, _api?: unknown) => + new ResourceManagerStoreActionImpl(set, get, _api); - setSortType: (sortType) => { - set({ sortType }); - }, +type CreateStore = ( + initState?: Partial, +) => StateCreator; - setSorter: (sorter) => { - set({ sorter }); - }, - - setViewMode: (viewMode) => { - set({ viewMode }); - }, -}); +export const store: CreateStore = + (publicState) => + (...params) => ({ + ...initialState, + ...publicState, + ...flattenActions([createResourceManagerStoreSlice(...params)]), + }); diff --git a/src/routes/(main)/resource/features/store/index.ts b/src/routes/(main)/resource/features/store/index.ts index effbb5f530..84fd899726 100644 --- a/src/routes/(main)/resource/features/store/index.ts +++ b/src/routes/(main)/resource/features/store/index.ts @@ -1,57 +1,24 @@ 'use client'; -import { useEffect } from 'react'; -import { type SWRResponse } from 'swr'; +import type { SWRResponse } from 'swr'; import { subscribeWithSelector } from 'zustand/middleware'; import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; import { useFileStore } from '@/store/file'; -import { type FileListItem, type QueryFileListParams } from '@/types/files'; -import { type FolderCrumb } from './action'; +import type { FolderCrumb, Store } from './action'; import { store } from './action'; export type { State } from './initialState'; -// Create a global store instance instead of context-based -export const useResourceManagerStore = createWithEqualityFn( - subscribeWithSelector(store()), - shallow, -); +export const createStore = () => + createWithEqualityFn()(subscribeWithSelector(store()), shallow); + +export const useResourceManagerStore = createStore(); export { selectors } from './selectors'; -/** - * Hook wrappers that delegate to FileStore hooks and sync pagination state - * These must be separate functions, not stored in Zustand state - */ - -export const useResourceManagerFetchKnowledgeItems = ( - params: QueryFileListParams, -): SWRResponse => { - const result = useFileStore((s) => s.useFetchKnowledgeItems)(params); - - // Sync pagination state from FileStore to ResourceManagerStore using subscription - // This ensures the sync happens reactively when FileStore updates, not just during render - const fileListHasMore = useFileStore((s) => s.fileListHasMore); - const fileListOffset = useFileStore((s) => s.fileListOffset); - - useEffect(() => { - const resourceManagerStore = useResourceManagerStore.getState(); - resourceManagerStore.setFileListHasMore?.(fileListHasMore); - resourceManagerStore.setFileListOffset?.(fileListOffset); - }, [fileListHasMore, fileListOffset]); - - return result; -}; - -export const useResourceManagerFetchKnowledgeItem = ( - id?: string, -): SWRResponse => { - return useFileStore((s) => s.useFetchKnowledgeItem)(id); -}; - export const useResourceManagerFetchFolderBreadcrumb = ( slug?: string | null, ): SWRResponse => { diff --git a/src/routes/(main)/resource/features/store/initialState.ts b/src/routes/(main)/resource/features/store/initialState.ts index bd5834a7e6..9f3a0d5e87 100644 --- a/src/routes/(main)/resource/features/store/initialState.ts +++ b/src/routes/(main)/resource/features/store/initialState.ts @@ -2,36 +2,17 @@ import { type ResourceManagerMode } from '@/features/ResourceManager'; import { FilesTabs, SortType } from '@/types/files'; export type ViewMode = 'list' | 'masonry'; +export type SelectAllState = 'all' | 'loaded' | 'none'; export interface State { /** * Current file category filter */ category: FilesTabs; - /** - * Current folder ID for navigation - */ - currentFolderId?: string | null; /** * Current view item ID (document ID or file ID) */ currentViewItemId?: string; - /** - * Whether there are more files to load (pagination) - */ - fileListHasMore: boolean; - /** - * Current pagination offset - */ - fileListOffset: number; - /** - * Masonry view ready state - */ - isMasonryReady: boolean; - /** - * View transition state - */ - isTransitioning: boolean; /** * Current library ID */ @@ -49,7 +30,12 @@ export interface State { */ searchQuery: string | null; /** - * Selected file IDs in the file explorer + * Current select-all mode shared across explorer views + */ + selectAllState: SelectAllState; + /** + * Selected file IDs in the file explorer. + * When selectAllState === 'all', this stores excluded IDs instead. */ selectedFileIds: string[]; /** @@ -68,16 +54,12 @@ export interface State { export const initialState: State = { category: FilesTabs.All, - currentFolderId: undefined, currentViewItemId: undefined, - fileListHasMore: false, - fileListOffset: 0, - isMasonryReady: false, - isTransitioning: false, libraryId: undefined, mode: 'explorer', pendingRenameItemId: null, searchQuery: null, + selectAllState: 'none', selectedFileIds: [], sortType: SortType.Desc, sorter: 'createdAt', diff --git a/src/routes/(main)/resource/features/store/selectors.test.ts b/src/routes/(main)/resource/features/store/selectors.test.ts new file mode 100644 index 0000000000..9e9ffc412d --- /dev/null +++ b/src/routes/(main)/resource/features/store/selectors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + getExplorerSelectAllUiState, + getExplorerSelectedCount, + isExplorerItemSelected, +} from './selectors'; + +describe('resource manager selectors', () => { + it('should treat selected ids as exclusions in all-selection mode', () => { + expect( + isExplorerItemSelected({ + id: 'file-1', + selectAllState: 'all', + selectedIds: ['file-1'], + }), + ).toBe(false); + expect( + isExplorerItemSelected({ + id: 'file-2', + selectAllState: 'all', + selectedIds: ['file-1'], + }), + ).toBe(true); + expect( + getExplorerSelectedCount({ + selectAllState: 'all', + selectedIds: ['file-1'], + total: 5, + }), + ).toBe(4); + }); + + it('should show an indeterminate checkbox when a loaded item is excluded from all-selection mode', () => { + expect( + getExplorerSelectAllUiState({ + data: [{ id: 'file-1' }, { id: 'file-2' }], + hasMore: true, + selectAllState: 'all', + selectedIds: ['file-1'], + }), + ).toEqual({ + allSelected: false, + indeterminate: true, + showSelectAllHint: true, + }); + }); +}); diff --git a/src/routes/(main)/resource/features/store/selectors.ts b/src/routes/(main)/resource/features/store/selectors.ts index 5994a163e0..d4a2e34027 100644 --- a/src/routes/(main)/resource/features/store/selectors.ts +++ b/src/routes/(main)/resource/features/store/selectors.ts @@ -2,7 +2,7 @@ import { fileManagerSelectors, useFileStore } from '@/store/file'; import { type FileListItem } from '@/types/files'; import { SortType } from '@/types/files'; -import { type State } from './initialState'; +import { type SelectAllState, type State } from './initialState'; /** * Sort a file list based on sort settings @@ -69,6 +69,55 @@ const getCurrentFile = (s: State): FileListItem | undefined => { const isFilePreviewMode = (s: State) => s.mode === 'editor' && !!s.currentViewItemId; +export const isExplorerItemSelected = ({ + id, + selectAllState, + selectedIds, +}: { + id: string; + selectAllState: SelectAllState; + selectedIds: string[]; +}) => (selectAllState === 'all' ? !selectedIds.includes(id) : selectedIds.includes(id)); + +export const getExplorerSelectAllUiState = ({ + data, + hasMore, + selectAllState, + selectedIds, +}: { + data: Array<{ id: string }>; + hasMore: boolean; + selectAllState: SelectAllState; + selectedIds: string[]; +}) => { + const fileCount = data.length; + const selectedCount = data.filter((item) => + isExplorerItemSelected({ id: item.id, selectAllState, selectedIds }), + ).length; + const allLoadedSelected = fileCount > 0 && selectedCount === fileCount; + + return { + allSelected: allLoadedSelected, + indeterminate: selectedCount > 0 && !allLoadedSelected, + showSelectAllHint: selectAllState !== 'none' && (hasMore || selectAllState === 'all'), + }; +}; + +export const getExplorerSelectedCount = ({ + selectAllState, + selectedIds, + total, +}: { + selectAllState: SelectAllState; + selectedIds: string[]; + total?: number; +}) => { + if (selectAllState !== 'all') return selectedIds.length; + if (typeof total !== 'number') return 0; + + return Math.max(total - selectedIds.length, 0); +}; + export const selectors = { category: (s: State) => s.category, currentViewItemId: (s: State) => s.currentViewItemId, diff --git a/src/routes/(main)/resource/store/action.ts b/src/routes/(main)/resource/store/action.ts index 07380ab418..cd96dfd25d 100644 --- a/src/routes/(main)/resource/store/action.ts +++ b/src/routes/(main)/resource/store/action.ts @@ -1,44 +1,51 @@ -import { type StateCreator } from 'zustand/vanilla'; +import type { StateCreator } from 'zustand/vanilla'; -import { type ResourceManagerMode } from '@/features/ResourceManager'; +import type { ResourceManagerMode } from '@/features/ResourceManager'; +import type { StoreSetter } from '@/store/types'; +import { flattenActions } from '@/store/utils/flattenActions'; -import { type State } from './initialState'; +import type { State } from './initialState'; import { initialState } from './initialState'; -export interface Action { - /** - * Set the current view item ID - */ - setCurrentViewItemId: (id?: string) => void; - /** - * Set the view mode - */ - setMode: (mode: ResourceManagerMode) => void; - /** - * Set selected file IDs - */ - setSelectedFileIds: (ids: string[]) => void; +export type Store = Action & State; + +type Setter = StoreSetter; + +export class ResourceStoreActionImpl { + readonly #set: Setter; + + constructor(set: Setter, _get: () => Store, _api?: unknown) { + void _api; + void _get; + this.#set = set; + } + + setCurrentViewItemId = (currentViewItemId?: string): void => { + this.#set({ currentViewItemId }); + }; + + setMode = (mode: ResourceManagerMode): void => { + this.#set({ mode }); + }; + + setSelectedFileIds = (selectedFileIds: string[]): void => { + this.#set({ selectedFileIds }); + }; } -export type Store = Action & State; +export type Action = Pick; + +export const createResourceStoreSlice = (set: Setter, get: () => Store, _api?: unknown) => + new ResourceStoreActionImpl(set, get, _api); type CreateStore = ( initState?: Partial, ) => StateCreator; -export const store: CreateStore = (publicState) => (set) => ({ - ...initialState, - ...publicState, - - setCurrentViewItemId: (currentViewItemId) => { - set({ currentViewItemId }); - }, - - setMode: (mode) => { - set({ mode }); - }, - - setSelectedFileIds: (selectedFileIds) => { - set({ selectedFileIds }); - }, -}); +export const store: CreateStore = + (publicState) => + (...params) => ({ + ...initialState, + ...publicState, + ...flattenActions([createResourceStoreSlice(...params)]), + }); diff --git a/src/routes/(main)/settings/profile/features/EmailRow.tsx b/src/routes/(main)/settings/profile/features/EmailRow.tsx index 9587737aec..bcfac892f4 100644 --- a/src/routes/(main)/settings/profile/features/EmailRow.tsx +++ b/src/routes/(main)/settings/profile/features/EmailRow.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button, Flexbox, Input, Text } from '@lobehub/ui'; -import { AnimatePresence, m as motion } from 'motion/react'; +import { AnimatePresence, m } from 'motion/react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -66,7 +66,7 @@ const EmailRow = ({ mobile }: EmailRowProps) => { }, [editValue, t]); const editingContent = ( - { - + ); const displayContent = ( - { )} - + ); if (mobile) { diff --git a/src/server/routers/lambda/__tests__/file.test.ts b/src/server/routers/lambda/__tests__/file.test.ts index c0f2cafbbd..92d64450b9 100644 --- a/src/server/routers/lambda/__tests__/file.test.ts +++ b/src/server/routers/lambda/__tests__/file.test.ts @@ -11,6 +11,7 @@ function createCallerWithCtx(partialCtx: any = {}) { checkHash: vi.fn().mockResolvedValue({ isExist: true }), create: vi.fn().mockResolvedValue({ id: 'test-id' }), findById: vi.fn().mockResolvedValue(undefined), + findByIds: vi.fn().mockResolvedValue([]), query: vi.fn().mockResolvedValue([]), delete: vi.fn().mockResolvedValue(undefined), deleteMany: vi.fn().mockResolvedValue([]), @@ -45,6 +46,9 @@ function createCallerWithCtx(partialCtx: any = {}) { }; const documentModel = {}; + const documentService = { + deleteDocuments: vi.fn().mockResolvedValue(undefined), + }; const ctx = { serverDB: {} as any, @@ -52,6 +56,7 @@ function createCallerWithCtx(partialCtx: any = {}) { asyncTaskModel, chunkModel, documentModel, + documentService, fileModel, fileService, knowledgeRepo, @@ -99,6 +104,7 @@ const mockFileModelCreate = vi.fn(); const mockFileModelDelete = vi.fn(); const mockFileModelDeleteMany = vi.fn(); const mockFileModelFindById = vi.fn(); +const mockFileModelFindByIds = vi.fn(); const mockFileModelQuery = vi.fn(); const mockFileModelClear = vi.fn(); @@ -109,6 +115,7 @@ vi.mock('@/database/models/file', () => ({ delete: mockFileModelDelete, deleteMany: mockFileModelDeleteMany, findById: mockFileModelFindById, + findByIds: mockFileModelFindByIds, query: mockFileModelQuery, clear: mockFileModelClear, })), @@ -127,6 +134,7 @@ vi.mock('@/server/services/file', () => ({ })); const mockKnowledgeRepoQuery = vi.fn().mockResolvedValue([]); +const mockDocumentServiceDeleteDocuments = vi.fn(); vi.mock('@/database/repositories/knowledge', () => ({ KnowledgeRepo: vi.fn(() => ({ @@ -138,6 +146,12 @@ vi.mock('@/database/models/document', () => ({ DocumentModel: vi.fn(() => ({})), })); +vi.mock('@/server/services/document', () => ({ + DocumentService: vi.fn(() => ({ + deleteDocuments: mockDocumentServiceDeleteDocuments, + })), +})); + describe('fileRouter', () => { let ctx: any; let caller: any; @@ -445,6 +459,60 @@ describe('fileRouter', () => { }); }); + describe('getKnowledgeItemStatusesByIds', () => { + it('should return lightweight status fields in input order and skip missing ids', async () => { + mockFileModelFindByIds.mockResolvedValue([ + { + ...mockFile, + chunkTaskId: null, + embeddingTaskId: 'emb-2', + id: 'file-2', + }, + { + ...mockFile, + chunkTaskId: 'chunk-1', + embeddingTaskId: 'emb-1', + id: 'file-1', + }, + ]); + mockChunkCountByFileIds.mockResolvedValue([ + { count: 3, id: 'file-2' }, + { count: 10, id: 'file-1' }, + ]); + mockAsyncTaskFindByIds + .mockResolvedValueOnce([{ error: null, id: 'chunk-1', status: AsyncTaskStatus.Success }]) + .mockResolvedValueOnce([ + { error: null, id: 'emb-2', status: AsyncTaskStatus.Processing }, + { error: null, id: 'emb-1', status: AsyncTaskStatus.Success }, + ]); + + const result = await caller.getKnowledgeItemStatusesByIds({ + ids: ['file-2', 'missing-id', 'file-1'], + }); + + expect(result).toEqual([ + { + chunkCount: 3, + chunkingError: null, + chunkingStatus: null, + embeddingError: null, + embeddingStatus: AsyncTaskStatus.Processing, + finishEmbedding: false, + id: 'file-2', + }, + { + chunkCount: 10, + chunkingError: null, + chunkingStatus: AsyncTaskStatus.Success, + embeddingError: null, + embeddingStatus: AsyncTaskStatus.Success, + finishEmbedding: true, + id: 'file-1', + }, + ]); + }); + }); + describe('removeFile', () => { it('should do nothing when file not found', async () => { ctx.fileModel.delete.mockResolvedValue(null); @@ -465,6 +533,49 @@ describe('fileRouter', () => { }); }); + describe('removeAllFiles', () => { + it('should include knowledge-base files when clearing all user files', async () => { + mockFileModelQuery.mockResolvedValue([ + { id: 'file-1' }, + { id: 'file-2' }, + ]); + mockFileModelDeleteMany.mockResolvedValue([]); + + await caller.removeAllFiles(); + + expect(mockFileModelQuery).toHaveBeenCalledWith({ showFilesInKnowledgeBase: true }); + expect(mockFileModelDeleteMany).toHaveBeenCalledWith(['file-1', 'file-2'], false); + }); + }); + + describe('deleteKnowledgeItemsByQuery', () => { + it('should delete page-backed knowledge items via documentService and plain files via fileModel', async () => { + mockKnowledgeRepoQuery.mockResolvedValue([ + { + documentId: 'doc-1', + fileId: 'file-1', + fileType: 'custom/page', + id: 'doc-1', + sourceType: 'file', + }, + { + documentId: null, + fileId: 'file-2', + fileType: 'text/plain', + id: 'file-2', + sourceType: 'file', + }, + ]); + mockFileModelDeleteMany.mockResolvedValue([]); + + const result = await caller.deleteKnowledgeItemsByQuery({}); + + expect(mockDocumentServiceDeleteDocuments).toHaveBeenCalledWith(['doc-1']); + expect(mockFileModelDeleteMany).toHaveBeenCalledWith(['file-2'], false); + expect(result).toEqual({ count: 2 }); + }); + }); + describe('removeFileAsyncTask', () => { it('should do nothing when file not found', async () => { ctx.fileModel.findById.mockResolvedValue(null); diff --git a/src/server/routers/lambda/file.ts b/src/server/routers/lambda/file.ts index 5cd4d3a4fc..3a9ac9eb1c 100644 --- a/src/server/routers/lambda/file.ts +++ b/src/server/routers/lambda/file.ts @@ -12,9 +12,10 @@ import { KnowledgeRepo } from '@/database/repositories/knowledge'; import { appEnv } from '@/envs/app'; import { authedProcedure, router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; +import { DocumentService } from '@/server/services/document'; import { FileService } from '@/server/services/file'; -import { AsyncTaskStatus, AsyncTaskType } from '@/types/asyncTask'; -import { type FileListItem } from '@/types/files'; +import { AsyncTaskStatus, AsyncTaskType, type IAsyncTaskError } from '@/types/asyncTask'; +import type { FileListItem, KnowledgeItemStatus } from '@/types/files'; import { QueryFileListSchema, UploadFileSchema } from '@/types/files'; /** @@ -23,6 +24,84 @@ import { QueryFileListSchema, UploadFileSchema } from '@/types/files'; */ const getFileProxyUrl = (fileId: string): string => `${appEnv.APP_URL}/f/${fileId}`; +const filterKnowledgeItems = < + T extends { + fileType: string; + sourceType: string; + }, +>( + items: T[], + knowledgeBaseId?: string, +) => { + return !knowledgeBaseId + ? items.filter((item) => !(item.sourceType === 'document' && item.fileType === 'custom/folder')) + : items; +}; + +const getKnowledgeItemStatusMap = async ( + ctx: { + asyncTaskModel: AsyncTaskModel; + chunkModel: ChunkModel; + }, + fileItems: Array<{ + chunkTaskId?: string | null; + embeddingTaskId?: string | null; + id: string; + }>, +): Promise> => { + if (fileItems.length === 0) return new Map(); + + const fileIds = fileItems.map((item) => item.id); + const chunkTaskIds = [ + ...new Set(fileItems.map((item) => item.chunkTaskId).filter(Boolean)), + ] as string[]; + const embeddingTaskIds = [ + ...new Set(fileItems.map((item) => item.embeddingTaskId).filter(Boolean)), + ] as string[]; + + const [chunks, chunkTasks, embeddingTasks] = await Promise.all([ + ctx.chunkModel.countByFileIds(fileIds), + chunkTaskIds.length > 0 + ? ctx.asyncTaskModel.findByIds(chunkTaskIds, AsyncTaskType.Chunking) + : Promise.resolve([]), + embeddingTaskIds.length > 0 + ? ctx.asyncTaskModel.findByIds(embeddingTaskIds, AsyncTaskType.Embedding) + : Promise.resolve([]), + ]); + + const chunkRows = chunks ?? []; + const chunkTaskRows = chunkTasks ?? []; + const embeddingTaskRows = embeddingTasks ?? []; + + const chunkCountMap = new Map( + chunkRows.filter((item) => item.id).map((item) => [item.id, item.count] as const), + ); + const chunkTaskMap = new Map(chunkTaskRows.map((task) => [task.id, task] as const)); + const embeddingTaskMap = new Map(embeddingTaskRows.map((task) => [task.id, task] as const)); + + return new Map( + fileItems.map((item) => { + const chunkTask = item.chunkTaskId ? chunkTaskMap.get(item.chunkTaskId) : null; + const embeddingTask = item.embeddingTaskId + ? embeddingTaskMap.get(item.embeddingTaskId) + : null; + + return [ + item.id, + { + chunkCount: chunkCountMap.get(item.id) ?? null, + chunkingError: (chunkTask?.error as IAsyncTaskError | null | undefined) ?? null, + chunkingStatus: (chunkTask?.status as AsyncTaskStatus | null | undefined) ?? null, + embeddingError: (embeddingTask?.error as IAsyncTaskError | null | undefined) ?? null, + embeddingStatus: (embeddingTask?.status as AsyncTaskStatus | null | undefined) ?? null, + finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success, + id: item.id, + }, + ] as const; + }), + ); +}; + const fileProcedure = authedProcedure.use(serverDatabase).use(async (opts) => { const { ctx } = opts; @@ -31,6 +110,7 @@ const fileProcedure = authedProcedure.use(serverDatabase).use(async (opts) => { asyncTaskModel: new AsyncTaskModel(ctx.serverDB, ctx.userId), chunkModel: new ChunkModel(ctx.serverDB, ctx.userId), documentModel: new DocumentModel(ctx.serverDB, ctx.userId), + documentService: new DocumentService(ctx.serverDB, ctx.userId), fileModel: new FileModel(ctx.serverDB, ctx.userId), fileService: new FileService(ctx.serverDB, ctx.userId), knowledgeRepo: new KnowledgeRepo(ctx.serverDB, ctx.userId), @@ -145,26 +225,18 @@ export const fileRouter = router({ if (!item) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }); - let embeddingTask = null; - if (item.embeddingTaskId) { - embeddingTask = await ctx.asyncTaskModel.findById(item.embeddingTaskId); - } - let chunkingTask = null; - if (item.chunkTaskId) { - chunkingTask = await ctx.asyncTaskModel.findById(item.chunkTaskId); - } - - const chunkCount = await ctx.chunkModel.countByFileId(input.id); + const statusMap = await getKnowledgeItemStatusMap(ctx, [item]); + const status = statusMap.get(item.id)!; return { - chunkCount, - chunkingError: chunkingTask?.error, - chunkingStatus: chunkingTask?.status as AsyncTaskStatus, createdAt: item.createdAt, - embeddingError: embeddingTask?.error, - embeddingStatus: embeddingTask?.status as AsyncTaskStatus, + chunkCount: status.chunkCount ?? null, + chunkingError: status.chunkingError, + chunkingStatus: status.chunkingStatus, + embeddingError: status.embeddingError, + embeddingStatus: status.embeddingStatus, fileType: item.fileType, - finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success, + finishEmbedding: status.finishEmbedding ?? false, id: item.id, metadata: item.metadata as Record | null | undefined, name: item.name, @@ -177,39 +249,16 @@ export const fileRouter = router({ getFiles: fileProcedure.input(QueryFileListSchema).query(async ({ ctx, input }) => { const fileList = await ctx.fileModel.query(input); - - const fileIds = fileList.map((item) => item.id); - const chunks = await ctx.chunkModel.countByFileIds(fileIds); - - const chunkTaskIds = fileList.map((result) => result.chunkTaskId).filter(Boolean) as string[]; - - const chunkTasks = await ctx.asyncTaskModel.findByIds(chunkTaskIds, AsyncTaskType.Chunking); - - const embeddingTaskIds = fileList - .map((result) => result.embeddingTaskId) - .filter(Boolean) as string[]; - const embeddingTasks = await ctx.asyncTaskModel.findByIds( - embeddingTaskIds, - AsyncTaskType.Embedding, - ); + const statusMap = await getKnowledgeItemStatusMap(ctx, fileList); const resultFiles = [] as any[]; - for (const { chunkTaskId, embeddingTaskId, ...item } of fileList as any[]) { - const chunkTask = chunkTaskId ? chunkTasks.find((task) => task.id === chunkTaskId) : null; - const embeddingTask = embeddingTaskId - ? embeddingTasks.find((task) => task.id === embeddingTaskId) - : null; - + for (const item of fileList as any[]) { + const status = statusMap.get(item.id)!; const fileItem = { ...item, - chunkCount: chunks.find((chunk) => chunk.id === item.id)?.count ?? null, - chunkingError: chunkTask?.error ?? null, - chunkingStatus: chunkTask?.status as AsyncTaskStatus, - embeddingError: embeddingTask?.error ?? null, - embeddingStatus: embeddingTask?.status as AsyncTaskStatus, - finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success, sourceType: 'file' as const, url: getFileProxyUrl(item.id), + ...status, } as FileListItem; resultFiles.push(fileItem); } @@ -217,6 +266,25 @@ export const fileRouter = router({ return resultFiles; }), + getKnowledgeItemStatusesByIds: fileProcedure + .input( + z.object({ + ids: z.array(z.string()), + }), + ) + .query(async ({ ctx, input }): Promise => { + const ids = [...new Set(input.ids)]; + if (ids.length === 0) return []; + + const fileItems = await ctx.fileModel.findByIds(ids); + const statusMap = await getKnowledgeItemStatusMap(ctx, fileItems); + + return ids.flatMap((id) => { + const status = statusMap.get(id); + return status ? [status] : []; + }); + }), + getKnowledgeItems: fileProcedure.input(QueryFileListSchema).query(async ({ ctx, input }) => { // Request one more item than limit to check if there are more items const limit = input.limit ?? 50; @@ -232,49 +300,22 @@ export const fileRouter = router({ const itemsToProcess = hasMore ? knowledgeItems.slice(0, limit) : knowledgeItems; // Filter out folders from Documents category when in Inbox (no knowledgeBaseId) - const filteredItems = !input.knowledgeBaseId - ? itemsToProcess.filter( - (item) => !(item.sourceType === 'document' && item.fileType === 'custom/folder'), - ) - : itemsToProcess; + const filteredItems = filterKnowledgeItems(itemsToProcess, input.knowledgeBaseId); // Process files (add chunk info and async task status) const fileItems = filteredItems.filter((item) => item.sourceType === 'file'); - const fileIds = fileItems.map((item) => item.id); - const chunks = await ctx.chunkModel.countByFileIds(fileIds); - - const chunkTaskIds = fileItems.map((item) => item.chunkTaskId).filter(Boolean) as string[]; - const chunkTasks = await ctx.asyncTaskModel.findByIds(chunkTaskIds, AsyncTaskType.Chunking); - - const embeddingTaskIds = fileItems - .map((item) => item.embeddingTaskId) - .filter(Boolean) as string[]; - const embeddingTasks = await ctx.asyncTaskModel.findByIds( - embeddingTaskIds, - AsyncTaskType.Embedding, - ); + const statusMap = await getKnowledgeItemStatusMap(ctx, fileItems); // Combine all items with their metadata const resultItems = [] as any[]; for (const item of filteredItems) { if (item.sourceType === 'file') { - const chunkTask = item.chunkTaskId - ? chunkTasks.find((task) => task.id === item.chunkTaskId) - : null; - const embeddingTask = item.embeddingTaskId - ? embeddingTasks.find((task) => task.id === item.embeddingTaskId) - : null; - + const status = statusMap.get(item.id)!; resultItems.push({ ...item, - chunkCount: chunks.find((chunk) => chunk.id === item.id)?.count ?? null, - chunkingError: chunkTask?.error ?? null, - chunkingStatus: chunkTask?.status as AsyncTaskStatus, editorData: null, - embeddingError: embeddingTask?.error ?? null, - embeddingStatus: embeddingTask?.status as AsyncTaskStatus, - finishEmbedding: embeddingTask?.status === AsyncTaskStatus.Success, url: getFileProxyUrl(item.id), + ...status, } as FileListItem); } else { // Document item - no chunk processing needed, includes editorData @@ -297,6 +338,90 @@ export const fileRouter = router({ }; }), + resolveKnowledgeItemIds: fileProcedure + .input(QueryFileListSchema) + .query(async ({ ctx, input }): Promise<{ ids: string[]; total: number }> => { + const ids: string[] = []; + const batchSize = 500; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const knowledgeItems = await ctx.knowledgeRepo.query({ + ...input, + limit: batchSize + 1, + offset, + }); + + const currentHasMore = knowledgeItems.length > batchSize; + const itemsToProcess = currentHasMore ? knowledgeItems.slice(0, batchSize) : knowledgeItems; + const filteredItems = filterKnowledgeItems(itemsToProcess, input.knowledgeBaseId); + + ids.push(...filteredItems.map((item) => item.id)); + + offset += itemsToProcess.length; + hasMore = currentHasMore; + } + + return { ids, total: ids.length }; + }), + + deleteKnowledgeItemsByQuery: fileProcedure + .input(QueryFileListSchema) + .mutation(async ({ ctx, input }): Promise<{ count: number }> => { + const fileIds: string[] = []; + const documentIds: string[] = []; + const batchSize = 500; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const knowledgeItems = await ctx.knowledgeRepo.query({ + ...input, + limit: batchSize + 1, + offset, + }); + + const currentHasMore = knowledgeItems.length > batchSize; + const itemsToProcess = currentHasMore ? knowledgeItems.slice(0, batchSize) : knowledgeItems; + const filteredItems = filterKnowledgeItems(itemsToProcess, input.knowledgeBaseId); + + for (const item of filteredItems) { + if (item.sourceType === 'document') { + documentIds.push(item.documentId ?? item.id); + continue; + } + + if (item.documentId) { + documentIds.push(item.documentId); + continue; + } + + fileIds.push(item.fileId ?? item.id); + } + + offset += itemsToProcess.length; + hasMore = currentHasMore; + } + + if (documentIds.length > 0) { + await ctx.documentService.deleteDocuments(documentIds); + } + + if (fileIds.length > 0) { + const needToRemoveFileList = await ctx.fileModel.deleteMany( + fileIds, + serverDBEnv.REMOVE_GLOBAL_FILE, + ); + + if (needToRemoveFileList && needToRemoveFileList.length > 0) { + await ctx.fileService.deleteFiles(needToRemoveFileList.map((file) => file.url!)); + } + } + + return { count: fileIds.length + documentIds.length }; + }), + recentFiles: fileProcedure .input(z.object({ limit: z.number().optional() }).optional()) .query(async ({ ctx, input }) => { @@ -369,7 +494,20 @@ export const fileRouter = router({ }), removeAllFiles: fileProcedure.mutation(async ({ ctx }) => { - return ctx.fileModel.clear(); + // Get all file IDs for this user + const allFiles = await ctx.fileModel.query({ showFilesInKnowledgeBase: true }); + const fileIds = allFiles.map((f) => f.id); + + // Use deleteMany to properly handle shared files (globalFiles reference counting) + const needToRemoveFileList = await ctx.fileModel.deleteMany( + fileIds, + serverDBEnv.REMOVE_GLOBAL_FILE, + ); + + // Delete S3 files only if no other users reference them + if (needToRemoveFileList && needToRemoveFileList.length > 0) { + await ctx.fileService.deleteFiles(needToRemoveFileList.map((file) => file.url!)); + } }), removeFile: fileProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => { diff --git a/src/services/file/index.ts b/src/services/file/index.ts index 2cf44a6c18..6f22eb5b18 100644 --- a/src/services/file/index.ts +++ b/src/services/file/index.ts @@ -3,6 +3,7 @@ import { type CheckFileHashResult, type FileItem, type FileListItem, + type KnowledgeItemStatus, type QueryFileListParams, type QueryFileListSchemaType, type UploadFileParams, @@ -58,6 +59,18 @@ export class FileService { return lambdaClient.file.getKnowledgeItems.query(params as QueryFileListSchemaType); }; + getKnowledgeItemStatusesByIds = async (ids: string[]): Promise => { + return lambdaClient.file.getKnowledgeItemStatusesByIds.query({ ids }); + }; + + resolveKnowledgeItemIds = async (params: QueryFileListParams) => { + return lambdaClient.file.resolveKnowledgeItemIds.query(params as QueryFileListSchemaType); + }; + + deleteKnowledgeItemsByQuery = async (params: QueryFileListParams) => { + return lambdaClient.file.deleteKnowledgeItemsByQuery.mutate(params as QueryFileListSchemaType); + }; + // V2.0 Migrate from getFileItem to getKnowledgeItem // This method handles both files (file_ prefix) and documents (docs_ prefix) getKnowledgeItem = async (id: string) => { diff --git a/src/services/resource/index.ts b/src/services/resource/index.ts index 5d5ccb34b1..3404ab25dc 100644 --- a/src/services/resource/index.ts +++ b/src/services/resource/index.ts @@ -1,4 +1,4 @@ -import { type FileListItem } from '@/types/files'; +import { type FileListItem, type KnowledgeItemStatus } from '@/types/files'; import { type CreateResourceParams, type ResourceItem, @@ -58,6 +58,29 @@ const mapToResourceItem = (item: FileListItem): ResourceItem => { }; }; +type ResourceStatusItem = Pick< + ResourceItem, + | 'chunkCount' + | 'chunkingError' + | 'chunkingStatus' + | 'embeddingError' + | 'embeddingStatus' + | 'finishEmbedding' + | 'id' +>; + +const mapStatusToResourceItem = (item: KnowledgeItemStatus): ResourceStatusItem => { + return { + chunkCount: item.chunkCount, + chunkingError: item.chunkingError, + chunkingStatus: item.chunkingStatus, + embeddingError: item.embeddingError, + embeddingStatus: item.embeddingStatus, + finishEmbedding: item.finishEmbedding, + id: item.id, + }; +}; + /** * ResourceService - Unified service for both files and documents * Provides a thin wrapper over FileService and DocumentService @@ -89,6 +112,28 @@ export class ResourceService { }; } + async resolveSelectionIds( + params: ResourceQueryParams, + ): Promise<{ ids: string[]; total: number }> { + const backendParams = { + ...params, + knowledgeBaseId: params.libraryId, + libraryId: undefined, + }; + + return fileService.resolveKnowledgeItemIds(backendParams); + } + + async deleteResourcesByQuery(params: ResourceQueryParams): Promise<{ count: number }> { + const backendParams = { + ...params, + knowledgeBaseId: params.libraryId, + libraryId: undefined, + }; + + return fileService.deleteKnowledgeItemsByQuery(backendParams); + } + /** * Get a single resource by ID */ @@ -97,6 +142,15 @@ export class ResourceService { return item ? mapToResourceItem(item) : undefined; } + async getKnowledgeItemStatusesByIds(ids: string[]): Promise { + const items = await fileService.getKnowledgeItemStatusesByIds(ids); + return items.map(mapStatusToResourceItem); + } + + async getResourceStatusesByIds(ids: string[]): Promise { + return this.getKnowledgeItemStatusesByIds(ids); + } + /** * Create a new resource (file or document) */ diff --git a/src/store/file/reducers/uploadFileList.ts b/src/store/file/reducers/uploadFileList.ts index 72c2ade909..0227c6a2f6 100644 --- a/src/store/file/reducers/uploadFileList.ts +++ b/src/store/file/reducers/uploadFileList.ts @@ -30,6 +30,12 @@ interface UpdateFileStatus { type: 'updateFileStatus'; } +interface UpdateFileStatuses { + ids: string[]; + status: FileUploadStatus; + type: 'updateFileStatuses'; +} + interface UpdateFileUploadState { id: string; type: 'updateFileUploadState'; @@ -49,6 +55,7 @@ interface RemoveFiles { export type UploadFileListDispatch = | AddFile | UpdateFileStatus + | UpdateFileStatuses | UpdateFileUploadState | RemoveFile | AddFiles @@ -102,6 +109,19 @@ export const uploadFileListReducer = ( } }); } + + case 'updateFileStatuses': { + return produce(state, (draftState) => { + const ids = new Set(action.ids); + + for (const file of draftState) { + if (ids.has(file.id)) { + file.status = action.status; + } + } + }); + } + case 'updateFileUploadState': { return produce(state, (draftState) => { const file = draftState.find((f) => f.id === action.id); diff --git a/src/store/file/slices/document/action.test.ts b/src/store/file/slices/document/action.test.ts new file mode 100644 index 0000000000..b2e1be095d --- /dev/null +++ b/src/store/file/slices/document/action.test.ts @@ -0,0 +1,270 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { documentService } from '@/services/document'; +import { DocumentSourceType, type LobeDocument } from '@/types/document'; +import { type ResourceItem } from '@/types/resource'; + +import { useFileStore as useStore } from '../../store'; +import { getResourceQueryKey } from '../resource/utils'; + +vi.mock('zustand/traditional'); + +vi.mock('@/services/document', () => ({ + documentService: { + createDocument: vi.fn(), + deleteDocument: vi.fn(), + getDocumentById: vi.fn(), + queryDocuments: vi.fn(), + updateDocument: vi.fn(), + }, +})); + +const createDocumentFixture = (overrides: Partial = {}): LobeDocument => ({ + content: 'Body', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + editorData: {}, + fileType: 'custom/document', + filename: 'Old title', + id: 'doc-1', + metadata: {}, + source: 'document', + sourceType: DocumentSourceType.EDITOR, + title: 'Old title', + totalCharCount: 4, + totalLineCount: 1, + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, +}); + +const createResourceFixture = (overrides: Partial = {}): ResourceItem => ({ + content: 'Body', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + editorData: {}, + fileType: 'custom/document', + id: 'doc-1', + knowledgeBaseId: 'kb-1', + metadata: {}, + name: 'Old title', + parentId: null, + size: 4, + sourceType: 'document', + title: 'Old title', + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + url: 'document', + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + + useStore.setState( + { + documents: [], + localDocumentMap: new Map(), + queryParams: undefined, + resourceList: [], + resourceMap: new Map(), + }, + false, + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('DocumentAction', () => { + it('creates a folder in the current resource list without full revalidation', async () => { + const { result } = renderHook(() => useStore()); + + vi.mocked(documentService.createDocument).mockResolvedValue({ + content: '', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + editorData: '{}', + fileType: 'custom/folder', + id: 'folder-1', + metadata: {}, + parentId: null, + slug: 'new-folder', + source: 'document', + title: 'New Folder', + totalCharCount: 0, + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + } as any); + + act(() => { + useStore.setState( + { + queryParams: { + libraryId: 'kb-1', + parentId: null, + }, + }, + false, + ); + }); + + await act(async () => { + await result.current.createFolder('New Folder', undefined, 'kb-1'); + }); + + expect(useStore.getState().resourceList.map((item) => item.id)).toEqual(['folder-1']); + expect(useStore.getState().resourceMap.get('folder-1')).toMatchObject({ + fileType: 'custom/folder', + id: 'folder-1', + knowledgeBaseId: 'kb-1', + name: 'New Folder', + parentId: null, + slug: 'new-folder', + sourceType: 'document', + title: 'New Folder', + }); + }); + + it('updates the local document and visible resource after a successful save', async () => { + const { result } = renderHook(() => useStore()); + const existingDocument = createDocumentFixture(); + const existingResource = createResourceFixture(); + + vi.mocked(documentService.updateDocument).mockResolvedValue(undefined); + + act(() => { + useStore.setState( + { + documents: [existingDocument], + queryParams: { + libraryId: 'kb-1', + parentId: null, + }, + resourceList: [existingResource], + resourceMap: new Map([[existingResource.id, existingResource]]), + }, + false, + ); + }); + + await act(async () => { + await result.current.updateDocument('doc-1', { + metadata: { emoji: 'page' }, + title: 'Renamed title', + }); + }); + + expect(documentService.updateDocument).toHaveBeenCalledWith({ + content: undefined, + editorData: undefined, + id: 'doc-1', + metadata: { emoji: 'page' }, + parentId: undefined, + title: 'Renamed title', + }); + expect(useStore.getState().localDocumentMap.get('doc-1')).toMatchObject({ + metadata: { emoji: 'page' }, + title: 'Renamed title', + }); + expect(useStore.getState().resourceMap.get('doc-1')).toMatchObject({ + metadata: { emoji: 'page' }, + name: 'Renamed title', + title: 'Renamed title', + }); + }); + + it('updates the resource optimistically and clears the marker after sync', async () => { + const { result } = renderHook(() => useStore()); + const existingDocument = createDocumentFixture(); + const existingResource = createResourceFixture(); + + let resolveUpdate: (() => void) | undefined; + vi.mocked(documentService.updateDocument).mockImplementation( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + + act(() => { + useStore.setState( + { + documents: [existingDocument], + queryParams: { + libraryId: 'kb-1', + parentId: null, + }, + resourceList: [existingResource], + resourceMap: new Map([[existingResource.id, existingResource]]), + }, + false, + ); + }); + + let pendingUpdate!: Promise; + act(() => { + pendingUpdate = result.current.updateDocumentOptimistically('doc-1', { + title: 'Optimistic title', + }); + }); + + expect(useStore.getState().resourceMap.get('doc-1')).toMatchObject({ + _optimistic: { + isPending: true, + queryKey: getResourceQueryKey(useStore.getState().queryParams), + retryCount: 0, + }, + name: 'Optimistic title', + title: 'Optimistic title', + }); + + resolveUpdate?.(); + + await act(async () => { + await pendingUpdate; + }); + + expect(useStore.getState().resourceMap.get('doc-1')).toMatchObject({ + name: 'Optimistic title', + title: 'Optimistic title', + }); + expect(useStore.getState().resourceMap.get('doc-1')?._optimistic).toBeUndefined(); + }); + + it('reverts optimistic resource updates when the sync fails', async () => { + const { result } = renderHook(() => useStore()); + const existingDocument = createDocumentFixture(); + const existingResource = createResourceFixture(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.mocked(documentService.updateDocument).mockRejectedValue(new Error('sync failed')); + + act(() => { + useStore.setState( + { + documents: [existingDocument], + queryParams: { + libraryId: 'kb-1', + parentId: null, + }, + resourceList: [existingResource], + resourceMap: new Map([[existingResource.id, existingResource]]), + }, + false, + ); + }); + + await act(async () => { + await result.current.updateDocumentOptimistically('doc-1', { + title: 'Broken title', + }); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(useStore.getState().localDocumentMap.get('doc-1')).toMatchObject({ + title: 'Old title', + }); + expect(useStore.getState().resourceMap.get('doc-1')).toMatchObject({ + name: 'Old title', + title: 'Old title', + }); + expect(useStore.getState().resourceMap.get('doc-1')?._optimistic).toBeUndefined(); + }); +}); diff --git a/src/store/file/slices/document/action.ts b/src/store/file/slices/document/action.ts index 55887d5b7c..3fc38f8d7d 100644 --- a/src/store/file/slices/document/action.ts +++ b/src/store/file/slices/document/action.ts @@ -7,9 +7,11 @@ import { useGlobalStore } from '@/store/global'; import { type StoreSetter } from '@/store/types'; import { type LobeDocument } from '@/types/document'; import { DocumentSourceType } from '@/types/document'; +import { type ResourceItem } from '@/types/resource'; import { setNamespace } from '@/utils/storeDebug'; import { type FileStore } from '../../store'; +import { getResourceQueryKey } from '../resource/utils'; import { type DocumentQueryFilter } from './initialState'; const n = setNamespace('document'); @@ -18,6 +20,22 @@ const ALLOWED_DOCUMENT_SOURCE_TYPES = new Set(['editor', 'file', 'api']); const ALLOWED_DOCUMENT_FILE_TYPES = new Set(['custom/document', 'application/pdf']); const EDITOR_DOCUMENT_FILE_TYPE = 'custom/document'; +interface ResourceDocumentSnapshot { + content?: string | null; + createdAt?: Date | string; + editorData?: LobeDocument['editorData'] | string; + fileType?: string; + id: string; + knowledgeBaseId?: string; + metadata?: LobeDocument['metadata'] | null; + parentId?: string | null; + slug?: string | null; + source?: string | null; + title?: string | null; + totalCharCount?: number; + updatedAt?: Date | string; +} + /** * Check if a page should be displayed in the page list */ @@ -42,6 +60,149 @@ export class DocumentActionImpl { this.#get = get; } + #findExistingDocument = (documentId: string): LobeDocument | undefined => { + const { documents, localDocumentMap } = this.#get(); + + return localDocumentMap.get(documentId) ?? documents.find((doc) => doc.id === documentId); + }; + + #normalizeDate = (value: Date | string | undefined, fallback: Date) => { + return value ? new Date(value) : fallback; + }; + + #parseEditorData = ( + editorData: LobeDocument['editorData'] | string | undefined, + fallback: ResourceItem['editorData'], + ): ResourceItem['editorData'] => { + if (editorData === undefined) return fallback; + if (editorData === null) return null; + + return typeof editorData === 'string' ? JSON.parse(editorData) : editorData; + }; + + #createUpdatedDocument = ( + existingDocument: LobeDocument, + updates: Partial, + ): LobeDocument => { + const mergedMetadata = + updates.metadata !== undefined + ? { ...existingDocument.metadata, ...updates.metadata } + : existingDocument.metadata; + + const cleanedMetadata = mergedMetadata + ? Object.fromEntries(Object.entries(mergedMetadata).filter(([, v]) => v !== undefined)) + : {}; + + return { + ...existingDocument, + ...updates, + metadata: cleanedMetadata, + title: updates.title || existingDocument.title, + updatedAt: new Date(), + }; + }; + + #setLocalDocument = (documentId: string, document: LobeDocument, actionName: string) => { + const { localDocumentMap } = this.#get(); + const newMap = new Map(localDocumentMap); + newMap.set(documentId, document); + this.#set({ localDocumentMap: newMap }, false, actionName); + }; + + #isResourceVisibleInCurrentQuery = (resource: ResourceItem): boolean => { + const { queryParams, resourceMap } = this.#get(); + + if (!queryParams) return false; + + if ( + queryParams.libraryId !== undefined && + (resource.knowledgeBaseId ?? undefined) !== queryParams.libraryId + ) { + return false; + } + + const keyword = queryParams.q?.trim().toLowerCase(); + if (keyword) { + const candidate = `${resource.name} ${resource.title ?? ''}`.trim().toLowerCase(); + if (!candidate.includes(keyword)) return false; + } + + if (queryParams.parentId == null) { + return (resource.parentId ?? null) === null; + } + + if (!resource.parentId) return false; + if (resource.parentId === queryParams.parentId) return true; + + const parentResource = resourceMap.get(resource.parentId); + return parentResource?.slug === queryParams.parentId; + }; + + #createResourceItem = ( + document: ResourceDocumentSnapshot, + fallback?: ResourceItem, + options?: { optimistic?: boolean }, + ): ResourceItem => { + const optimistic = options?.optimistic ?? false; + const now = new Date(); + + return { + ...fallback, + _optimistic: optimistic + ? { + error: fallback?._optimistic?.error, + isPending: true, + lastSyncAttempt: fallback?._optimistic?.lastSyncAttempt, + queryKey: getResourceQueryKey(this.#get().queryParams), + retryCount: fallback?._optimistic?.retryCount ?? 0, + } + : undefined, + content: document.content !== undefined ? document.content : (fallback?.content ?? null), + createdAt: this.#normalizeDate(document.createdAt, fallback?.createdAt ?? now), + editorData: this.#parseEditorData(document.editorData, fallback?.editorData), + fileType: document.fileType ?? fallback?.fileType ?? EDITOR_DOCUMENT_FILE_TYPE, + id: document.id, + knowledgeBaseId: document.knowledgeBaseId ?? fallback?.knowledgeBaseId, + metadata: document.metadata ?? fallback?.metadata, + name: + document.title !== undefined + ? (document.title ?? 'Untitled') + : (fallback?.name ?? fallback?.title ?? 'Untitled'), + parentId: document.parentId !== undefined ? document.parentId : (fallback?.parentId ?? null), + size: document.totalCharCount ?? fallback?.size ?? document.content?.length ?? 0, + slug: document.slug !== undefined ? document.slug : fallback?.slug, + sourceType: 'document', + title: + document.title !== undefined + ? (document.title ?? undefined) + : (fallback?.title ?? undefined), + updatedAt: this.#normalizeDate(document.updatedAt, fallback?.updatedAt ?? now), + url: document.source !== undefined ? (document.source ?? '') : (fallback?.url ?? ''), + }; + }; + + #syncResourceItem = (resource: ResourceItem, options?: { allowInsert?: boolean }) => { + const { queryParams, removeLocalResource, replaceLocalResource, resourceList, resourceMap } = + this.#get(); + const exists = + resourceMap.has(resource.id) || resourceList.some((item) => item.id === resource.id); + + if (exists) { + if (!queryParams || this.#isResourceVisibleInCurrentQuery(resource)) { + replaceLocalResource(resource.id, resource); + } else { + removeLocalResource(resource.id); + } + + return; + } + + if (options?.allowInsert === false || !queryParams) return; + if (!this.#isResourceVisibleInCurrentQuery(resource)) return; + + replaceLocalResource(resource.id, resource); + }; + createDocument = async ({ title, content, @@ -68,9 +229,25 @@ export class DocumentActionImpl { title, }); - // Don't refresh pages here - the caller will handle replacing the temp page - // with the real one via replaceTempDocumentWithReal, which provides a smooth UX - // without triggering the loading skeleton + this.#syncResourceItem( + this.#createResourceItem( + { + content: newPage.content, + createdAt: newPage.createdAt, + editorData: newPage.editorData, + fileType: newPage.fileType, + id: newPage.id, + knowledgeBaseId, + metadata: newPage.metadata, + parentId: newPage.parentId, + source: newPage.source, + title: newPage.title, + totalCharCount: newPage.totalCharCount, + updatedAt: newPage.updatedAt, + }, + undefined, + ), + ); return newPage; }; @@ -99,9 +276,26 @@ export class DocumentActionImpl { title: name, }); - // Refetch resource list to show the new folder - const { revalidateResources } = await import('../resource/hooks'); - await revalidateResources(); + this.#syncResourceItem( + this.#createResourceItem( + { + content: folder.content, + createdAt: folder.createdAt, + editorData: folder.editorData, + fileType: folder.fileType, + id: folder.id, + knowledgeBaseId, + metadata: folder.metadata, + parentId: folder.parentId, + slug: folder.slug, + source: folder.source, + title: folder.title, + totalCharCount: folder.totalCharCount, + updatedAt: folder.updatedAt, + }, + undefined, + ), + ); return folder.id; }; @@ -426,9 +620,35 @@ export class DocumentActionImpl { title: updates.title, }); - // Refetch resource list to show updated document - const { revalidateResources } = await import('../resource/hooks'); - await revalidateResources(); + const existingDocument = this.#findExistingDocument(id); + + if (existingDocument) { + const updatedDocument = this.#createUpdatedDocument(existingDocument, updates); + this.#setLocalDocument(id, updatedDocument, n('updateDocument')); + this.#syncResourceItem( + this.#createResourceItem(updatedDocument, this.#get().resourceMap.get(id)), + ); + return; + } + + const existingResource = this.#get().resourceMap.get(id); + if (!existingResource) return; + + this.#syncResourceItem( + this.#createResourceItem( + { + content: updates.content, + editorData: updates.editorData, + fileType: existingResource.fileType, + id, + metadata: updates.metadata, + parentId: updates.parentId, + title: updates.title, + updatedAt: new Date(), + }, + existingResource, + ), + ); }; updateDocumentOptimistically = async ( @@ -448,30 +668,17 @@ export class DocumentActionImpl { return; } - // Create updated page with new timestamp - // Merge metadata if both exist, otherwise use the update's metadata or preserve existing - const mergedMetadata = - updates.metadata !== undefined - ? { ...existingPage.metadata, ...updates.metadata } - : existingPage.metadata; - - // Clean up undefined values from metadata - const cleanedMetadata = mergedMetadata - ? Object.fromEntries(Object.entries(mergedMetadata).filter(([, v]) => v !== undefined)) - : {}; - - const updatedPage: LobeDocument = { - ...existingPage, - ...updates, - metadata: cleanedMetadata, - title: updates.title || existingPage.title, - updatedAt: new Date(), - }; + const existingResource = this.#get().resourceMap.get(documentId); + const updatedPage = this.#createUpdatedDocument(existingPage, updates); // Update local map immediately for optimistic UI - const newMap = new Map(localDocumentMap); - newMap.set(documentId, updatedPage); - this.#set({ localDocumentMap: newMap }, false, n('updateDocumentOptimistically')); + this.#setLocalDocument(documentId, updatedPage, n('updateDocumentOptimistically')); + + if (existingResource) { + this.#syncResourceItem( + this.#createResourceItem(updatedPage, existingResource, { optimistic: true }), + ); + } // Queue background sync to DB try { @@ -483,13 +690,12 @@ export class DocumentActionImpl { : JSON.stringify(updatedPage.editorData || {}), id: documentId, metadata: updatedPage.metadata || {}, - parentId: updatedPage.parentId || undefined, + parentId: updatedPage.parentId !== undefined ? updatedPage.parentId : undefined, title: updatedPage.title || updatedPage.filename, }); - - // After successful sync, refetch resources to get server state - const { revalidateResources } = await import('../resource/hooks'); - await revalidateResources(); + if (existingResource) { + this.#syncResourceItem(this.#createResourceItem(updatedPage, existingResource)); + } } catch (error) { console.error('[updateDocumentOptimistically] Failed to sync to DB:', error); // On error, revert the optimistic update @@ -500,6 +706,10 @@ export class DocumentActionImpl { revertMap.delete(documentId); } this.#set({ localDocumentMap: revertMap }, false, n('revertOptimisticUpdate')); + + if (existingResource) { + this.#syncResourceItem(existingResource); + } } }; diff --git a/src/store/file/slices/fileManager/action.test.ts b/src/store/file/slices/fileManager/action.test.ts index c894793d30..04ded7e396 100644 --- a/src/store/file/slices/fileManager/action.test.ts +++ b/src/store/file/slices/fileManager/action.test.ts @@ -12,6 +12,7 @@ import { unzipFile } from '@/utils/unzipFile'; import { withSWR } from '~test-utils'; import { useFileStore as useStore } from '../../store'; +import * as resourceHooks from '../resource/hooks'; vi.mock('zustand/traditional'); @@ -75,6 +76,8 @@ beforeEach(() => { creatingEmbeddingTaskIds: [], dockUploadFileList: [], fileList: [], + resourceList: [], + resourceMap: new Map(), queryListParams: undefined, }, false, @@ -86,6 +89,47 @@ afterEach(() => { }); describe('FileManagerActions', () => { + describe('cancelUploads', () => { + it('should abort matched uploads and update their status in a batch', () => { + const { result } = renderHook(() => useStore()); + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); + + act(() => { + useStore.setState({ + dockUploadFileList: [ + { + abortController: controller1, + file: new File([], 'test-1.txt'), + id: 'file-1', + status: 'pending', + }, + { + abortController: controller2, + file: new File([], 'test-2.txt'), + id: 'file-2', + status: 'uploading', + }, + { file: new File([], 'test-3.txt'), id: 'file-3', status: 'success' }, + ] as UploadFileItem[], + }); + }); + + act(() => { + result.current.cancelUploads(['file-1', 'file-2', 'file-404']); + }); + + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(true); + expect(dispatchSpy).toHaveBeenCalledWith({ + ids: ['file-1', 'file-2'], + status: 'cancelled', + type: 'updateFileStatuses', + }); + }); + }); + describe('dispatchDockFileList', () => { it('should update dockUploadFileList with new value', () => { const { result } = renderHook(() => useStore()); @@ -137,6 +181,32 @@ describe('FileManagerActions', () => { expect(result.current.dockUploadFileList[0].status).toBe('success'); }); + it('should handle updateFileStatuses dispatch', () => { + const { result } = renderHook(() => useStore()); + + act(() => { + useStore.setState({ + dockUploadFileList: [ + { file: new File([], 'test-1.txt'), id: 'file-1', status: 'pending' }, + { file: new File([], 'test-2.txt'), id: 'file-2', status: 'uploading' }, + { file: new File([], 'test-3.txt'), id: 'file-3', status: 'success' }, + ] as UploadFileItem[], + }); + }); + + act(() => { + result.current.dispatchDockFileList({ + ids: ['file-1', 'file-2'], + status: 'cancelled', + type: 'updateFileStatuses', + }); + }); + + expect(result.current.dockUploadFileList[0].status).toBe('cancelled'); + expect(result.current.dockUploadFileList[1].status).toBe('cancelled'); + expect(result.current.dockUploadFileList[2].status).toBe('success'); + }); + it('should handle removeFile dispatch', () => { const { result } = renderHook(() => useStore()); @@ -266,7 +336,6 @@ describe('FileManagerActions', () => { const uploadSpy = vi .spyOn(result.current, 'uploadWithProgress') .mockResolvedValue({ id: 'file-1', url: 'http://example.com/file-1' }); - const refreshSpy = vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); @@ -278,7 +347,7 @@ describe('FileManagerActions', () => { expect(dispatchSpy).toHaveBeenCalledWith({ atStart: true, files: [ - expect.objectContaining({ file: validFile, id: validFile.name, status: 'pending' }), + expect.objectContaining({ file: validFile, id: expect.any(String), status: 'pending' }), ], type: 'addFiles', }); @@ -288,8 +357,9 @@ describe('FileManagerActions', () => { file: validFile, knowledgeBaseId: undefined, onStatusUpdate: expect.any(Function), + parentId: undefined, + uploadId: expect.any(String), }); - expect(refreshSpy).toHaveBeenCalled(); // Should auto-parse text files expect(parseSpy).toHaveBeenCalledWith(['file-1'], { skipExist: false }); }); @@ -302,7 +372,6 @@ describe('FileManagerActions', () => { const uploadSpy = vi .spyOn(result.current, 'uploadWithProgress') .mockResolvedValue({ id: 'file-1', url: 'http://example.com/file-1' }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); await act(async () => { @@ -314,6 +383,8 @@ describe('FileManagerActions', () => { file, knowledgeBaseId: 'kb-123', onStatusUpdate: expect.any(Function), + parentId: undefined, + uploadId: expect.any(String), }); }); @@ -324,11 +395,14 @@ describe('FileManagerActions', () => { const uploadSpy = vi .spyOn(result.current, 'uploadWithProgress') - .mockImplementation(async ({ onStatusUpdate }) => { - onStatusUpdate?.({ id: file.name, type: 'updateFile', value: { status: 'uploading' } }); + .mockImplementation(async ({ onStatusUpdate, uploadId }) => { + onStatusUpdate?.({ + id: uploadId!, + type: 'updateFile', + value: { status: 'uploading' }, + }); return { id: 'file-1', url: 'http://example.com/file-1' }; }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); @@ -344,7 +418,6 @@ describe('FileManagerActions', () => { const { result } = renderHook(() => useStore()); const uploadSpy = vi.spyOn(result.current, 'uploadWithProgress'); - const refreshSpy = vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks'); await act(async () => { @@ -352,8 +425,6 @@ describe('FileManagerActions', () => { }); expect(uploadSpy).not.toHaveBeenCalled(); - // refreshFileList is always called after uploads complete, even for empty list - expect(refreshSpy).toHaveBeenCalled(); expect(parseSpy).not.toHaveBeenCalled(); }); @@ -366,7 +437,6 @@ describe('FileManagerActions', () => { vi.spyOn(result.current, 'uploadWithProgress') .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' }) .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); await act(async () => { @@ -388,7 +458,6 @@ describe('FileManagerActions', () => { .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' }) .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' }) .mockResolvedValueOnce({ id: 'file-3', url: 'http://example.com/file-3' }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); await act(async () => { @@ -410,7 +479,6 @@ describe('FileManagerActions', () => { .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' }) .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' }) .mockResolvedValueOnce({ id: 'file-3', url: 'http://example.com/file-3' }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); await act(async () => { @@ -427,7 +495,6 @@ describe('FileManagerActions', () => { const textFile = new File(['text content'], 'doc.txt', { type: 'text/plain' }); vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue(undefined); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); await act(async () => { @@ -452,7 +519,6 @@ describe('FileManagerActions', () => { id: 'file-1', url: 'http://example.com/file-1', }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); @@ -491,7 +557,6 @@ describe('FileManagerActions', () => { id: 'file-1', url: 'http://example.com/file-1', }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); @@ -506,7 +571,7 @@ describe('FileManagerActions', () => { expect(dispatchSpy).toHaveBeenCalledWith({ atStart: true, files: extractedFiles.map((file) => - expect.objectContaining({ file, id: file.name, status: 'pending' }), + expect.objectContaining({ file, id: expect.any(String), status: 'pending' }), ), type: 'addFiles', }); @@ -523,7 +588,6 @@ describe('FileManagerActions', () => { id: 'file-1', url: 'http://example.com/file-1', }); - vi.spyOn(result.current, 'refreshFileList').mockResolvedValue(); vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList'); @@ -537,10 +601,53 @@ describe('FileManagerActions', () => { // Should fallback to uploading the ZIP file itself expect(dispatchSpy).toHaveBeenCalledWith({ atStart: true, - files: [expect.objectContaining({ file: zipFile, id: zipFile.name, status: 'pending' })], + files: [ + expect.objectContaining({ file: zipFile, id: expect.any(String), status: 'pending' }), + ], type: 'addFiles', }); }); + + it('should insert optimistic resources and replace them with real resources after upload', async () => { + const { result } = renderHook(() => useStore()); + + const file = new File(['content'], 'test.txt', { type: 'text/plain' }); + vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue({ + id: 'file-1', + url: 'http://example.com/file-1', + }); + vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); + + await act(async () => { + await result.current.pushDockFileList([file]); + }); + + expect(result.current.resourceList).toEqual([ + expect.objectContaining({ + fileType: 'text/plain', + id: 'file-1', + name: 'test.txt', + size: file.size, + url: 'http://example.com/file-1', + }), + ]); + expect(result.current.resourceMap.has('file-1')).toBe(true); + }); + + it('should remove optimistic resources when upload returns undefined', async () => { + const { result } = renderHook(() => useStore()); + + const file = new File(['content'], 'test.txt', { type: 'text/plain' }); + vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue(undefined); + vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue(); + + await act(async () => { + await result.current.pushDockFileList([file]); + }); + + expect(result.current.resourceList).toEqual([]); + expect(result.current.resourceMap.size).toBe(0); + }); }); describe('reEmbeddingChunks', () => { @@ -606,17 +713,36 @@ describe('FileManagerActions', () => { }); describe('refreshFileList', () => { - it('should call mutate with key matcher function and revalidate option', async () => { + it('should refresh knowledge caches and revalidate resources by default', async () => { const { result } = renderHook(() => useStore()); + const revalidateResourcesSpy = vi + .spyOn(resourceHooks, 'revalidateResources') + .mockResolvedValue(undefined); await act(async () => { await result.current.refreshFileList(); }); - // The implementation now uses a key matcher function expect(mutate).toHaveBeenCalledWith(expect.any(Function), expect.any(Function), { revalidate: true, }); + expect(revalidateResourcesSpy).toHaveBeenCalledTimes(1); + }); + + it('should skip resource revalidation when explicitly disabled', async () => { + const { result } = renderHook(() => useStore()); + const revalidateResourcesSpy = vi + .spyOn(resourceHooks, 'revalidateResources') + .mockResolvedValue(undefined); + + await act(async () => { + await result.current.refreshFileList({ revalidateResources: false }); + }); + + expect(mutate).toHaveBeenCalledWith(expect.any(Function), expect.any(Function), { + revalidate: true, + }); + expect(revalidateResourcesSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/store/file/slices/fileManager/action.ts b/src/store/file/slices/fileManager/action.ts index af2c6822e3..dc591b8412 100644 --- a/src/store/file/slices/fileManager/action.ts +++ b/src/store/file/slices/fileManager/action.ts @@ -18,6 +18,7 @@ import { type UploadFileListDispatch } from '@/store/file/reducers/uploadFileLis import { uploadFileListReducer } from '@/store/file/reducers/uploadFileList'; import { type StoreSetter } from '@/store/types'; import { type FileListItem, type QueryFileListParams } from '@/types/files'; +import { type ResourceItem } from '@/types/resource'; import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported'; import { unzipFile } from '@/utils/unzipFile'; @@ -33,6 +34,10 @@ export interface FolderCrumb { slug: string; } +interface RefreshFileListOptions { + revalidateResources?: boolean; +} + type Setter = StoreSetter; export const createFileManageSlice = (set: Setter, get: () => FileStore, _api?: unknown) => new FileManageActionImpl(set, get, _api); @@ -47,6 +52,53 @@ export class FileManageActionImpl { this.#get = get; } + #buildOptimisticUploadResource = ( + file: File, + result: { id: string; url: string }, + knowledgeBaseId?: string, + parentId?: string, + ): ResourceItem => { + const existing = this.#get().resourceMap.get(result.id); + + return { + ...(existing || { + createdAt: new Date(), + fileType: file.type || 'application/octet-stream', + name: file.name, + size: file.size, + sourceType: 'file' as const, + }), + _optimistic: undefined, + id: result.id, + knowledgeBaseId, + name: file.name, + parentId, + size: file.size, + updatedAt: new Date(), + url: result.url, + }; + }; + + #insertOptimisticUpload = ( + id: string, + file: File, + knowledgeBaseId?: string, + parentId?: string, + ) => { + this.#get().insertLocalResource( + { + fileType: file.type || 'application/octet-stream', + knowledgeBaseId, + name: file.name, + parentId, + size: file.size, + sourceType: 'file', + url: '', + }, + id, + ); + }; + cancelUpload = (id: string): void => { const { dockUploadFileList, dispatchDockFileList } = this.#get(); const uploadItem = dockUploadFileList.find((item) => item.id === id); @@ -63,6 +115,29 @@ export class FileManageActionImpl { }); }; + cancelUploads = (ids: string[]): void => { + if (ids.length === 0) return; + + const { dockUploadFileList, dispatchDockFileList } = this.#get(); + const cancellableIds = new Set(ids); + const cancelledIds: string[] = []; + + for (const uploadItem of dockUploadFileList) { + if (!cancellableIds.has(uploadItem.id)) continue; + + uploadItem.abortController?.abort(); + cancelledIds.push(uploadItem.id); + } + + if (cancelledIds.length === 0) return; + + dispatchDockFileList({ + ids: cancelledIds, + status: 'cancelled', + type: 'updateFileStatuses', + }); + }; + dispatchDockFileList = (payload: UploadFileListDispatch): void => { const nextValue = uploadFileListReducer(this.#get().dockUploadFileList, payload); if (nextValue === this.#get().dockUploadFileList) return; @@ -167,6 +242,7 @@ export class FileManageActionImpl { parentId?: string, ): Promise => { const { dispatchDockFileList } = this.#get(); + const generateUploadId = createNanoId(12); // 0. Process ZIP files and extract their contents const filesToUpload: File[] = []; @@ -194,11 +270,15 @@ export class FileManageActionImpl { return { abortController, file, - id: file.name, + id: `upload_${generateUploadId()}`, status: 'pending' as const, }; }); + for (const uploadFile of uploadFiles) { + this.#insertOptimisticUpload(uploadFile.id, uploadFile.file, knowledgeBaseId, parentId); + } + // 3. Add all files to dock dispatchDockFileList({ atStart: true, @@ -216,10 +296,22 @@ export class FileManageActionImpl { knowledgeBaseId, onStatusUpdate: dispatchDockFileList, parentId, + uploadId: uploadFileItem.id, }); - // Note: Don't refresh after each file to avoid flickering - // We'll refresh once at the end + if (!result) { + this.#get().removeLocalResource(uploadFileItem.id); + } else { + this.#get().replaceLocalResource( + uploadFileItem.id, + this.#buildOptimisticUploadResource( + uploadFileItem.file, + result, + knowledgeBaseId, + parentId, + ), + ); + } return { file: uploadFileItem.file, @@ -228,10 +320,13 @@ export class FileManageActionImpl { }; }, { concurrency: MAX_UPLOAD_FILE_COUNT }, - ); + ).catch((error) => { + for (const uploadFile of uploadFiles) { + this.#get().removeLocalResource(uploadFile.id); + } - // Refresh file list to show newly uploaded files - await this.#get().refreshFileList(); + throw error; + }); // 5. auto-embed files that support chunking const fileIdsToEmbed = uploadResults @@ -271,7 +366,7 @@ export class FileManageActionImpl { this.#get().toggleParsingIds([id], false); }; - refreshFileList = async (): Promise => { + #refreshKnowledgeListCaches = async (): Promise => { // Invalidate all queries that start with FETCH_ALL_KNOWLEDGE_KEY // This ensures all file lists (explorer, tree, etc.) are refreshed // Note: We don't pass data as undefined to avoid clearing the cache, @@ -283,9 +378,13 @@ export class FileManageActionImpl { revalidate: true, }, ); + }; + + refreshFileList = async (options?: RefreshFileListOptions): Promise => { + await this.#refreshKnowledgeListCaches(); + + if (options?.revalidateResources === false) return; - // Also revalidate the ResourceManager resource list cache (SWR_RESOURCES) - // so uploaded files appear immediately in the Explorer without a full refresh. const { revalidateResources } = await import('../resource/hooks'); await revalidateResources(); }; @@ -381,6 +480,7 @@ export class FileManageActionImpl { currentFolderId?: string, ): Promise => { const { dispatchDockFileList } = this.#get(); + const generateUploadId = createNanoId(12); // 1. Build folder tree from file paths const { filesByFolder, folders } = buildFolderTree(files); @@ -476,34 +576,72 @@ export class FileManageActionImpl { ({ file }) => !FILE_UPLOAD_BLACKLIST.includes(file.name), ); + const uploadItems = validUploads.map(({ file, parentId }) => ({ + abortController: new AbortController(), + file, + id: `upload_${generateUploadId()}`, + parentId, + shouldShowInCurrentList: (parentId ?? undefined) === currentFolderId, + })); + // 7. Add all files to dock dispatchDockFileList({ atStart: true, - files: validUploads.map(({ file }) => ({ file, id: file.name, status: 'pending' })), + files: uploadItems.map(({ abortController, file, id }) => ({ + abortController, + file, + id, + status: 'pending' as const, + })), type: 'addFiles', }); + for (const uploadItem of uploadItems) { + if (!uploadItem.shouldShowInCurrentList) continue; + + this.#insertOptimisticUpload( + uploadItem.id, + uploadItem.file, + knowledgeBaseId, + uploadItem.parentId, + ); + } + // 8. Upload files with concurrency limit const uploadResults = await pMap( - validUploads, - async ({ file, parentId }) => { + uploadItems, + async ({ abortController, file, id, parentId, shouldShowInCurrentList }) => { const result = await this.#get().uploadWithProgress({ + abortController, file, knowledgeBaseId, onStatusUpdate: dispatchDockFileList, parentId, + uploadId: id, }); - // Note: Don't refresh after each file to avoid flickering - // We'll refresh once at the end + if (shouldShowInCurrentList) { + if (!result) { + this.#get().removeLocalResource(id); + } else { + this.#get().replaceLocalResource( + id, + this.#buildOptimisticUploadResource(file, result, knowledgeBaseId, parentId), + ); + } + } return { file, fileId: result?.id, fileType: file.type }; }, { concurrency: MAX_UPLOAD_FILE_COUNT }, - ); + ).catch((error) => { + for (const uploadItem of uploadItems) { + if (!uploadItem.shouldShowInCurrentList) continue; + this.#get().removeLocalResource(uploadItem.id); + } - // Refresh the file list once after all uploads are complete - await this.#get().refreshFileList(); + throw error; + }); // 9. Auto-embed files that support chunking const fileIdsToEmbed = uploadResults diff --git a/src/store/file/slices/resource/action.test.ts b/src/store/file/slices/resource/action.test.ts new file mode 100644 index 0000000000..4e4fcaaa53 --- /dev/null +++ b/src/store/file/slices/resource/action.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { initialState } from '@/store/file/initialState'; +import { useFileStore } from '@/store/file/store'; +import type { ResourceItem } from '@/types/resource'; + +const { mockMoveResource } = vi.hoisted(() => ({ + mockMoveResource: vi.fn(), +})); + +vi.mock('@/services/resource', () => ({ + resourceService: { + moveResource: mockMoveResource, + }, +})); + +const createResource = (overrides: Partial = {}): ResourceItem => ({ + createdAt: new Date('2026-01-01T00:00:00.000Z'), + fileType: 'text/plain', + id: 'resource-1', + name: 'Resource 1', + parentId: null, + size: 1, + sourceType: 'file', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + url: 'files/resource-1.txt', + ...overrides, +}); + +describe('resource actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + useFileStore.setState(initialState); + }); + + it('should keep completed background uploads out of the current resource list when they are off-screen', () => { + const visibleResource = createResource({ + id: 'visible-1', + name: 'Visible resource', + parentId: 'folder-b', + }); + const optimisticResource = createResource({ + _optimistic: { + isPending: true, + retryCount: 0, + }, + id: 'temp-a', + name: 'Background upload', + parentId: 'folder-a', + }); + const completedResource = createResource({ + id: 'file-a', + name: 'Background upload', + parentId: 'folder-a', + }); + + useFileStore.setState({ + queryParams: { parentId: 'folder-b' }, + resourceList: [visibleResource], + resourceMap: new Map([ + [visibleResource.id, visibleResource], + [optimisticResource.id, optimisticResource], + ]), + }); + + useFileStore.getState().replaceLocalResource(optimisticResource.id, completedResource); + + const { resourceList, resourceMap } = useFileStore.getState(); + + expect(resourceList).toEqual([visibleResource]); + expect(resourceMap.has(optimisticResource.id)).toBe(false); + expect(resourceMap.get(completedResource.id)).toEqual(completedResource); + }); + + it('should remove a root item from the visible list when moving it into a folder', async () => { + const rootResource = createResource({ + id: 'root-1', + name: 'Root resource', + parentId: null, + }); + const movedResource = createResource({ + id: 'root-1', + name: 'Root resource', + parentId: 'folder-a', + }); + + mockMoveResource.mockResolvedValue(movedResource); + + useFileStore.setState({ + queryParams: { parentId: null }, + resourceList: [rootResource], + resourceMap: new Map([[rootResource.id, rootResource]]), + }); + + await useFileStore.getState().moveResource(rootResource.id, 'folder-a'); + + const { resourceList, resourceMap } = useFileStore.getState(); + + expect(resourceList).toEqual([]); + expect(resourceMap.has(rootResource.id)).toBe(false); + }); +}); diff --git a/src/store/file/slices/resource/action.ts b/src/store/file/slices/resource/action.ts index a0215880cc..7c52891d42 100644 --- a/src/store/file/slices/resource/action.ts +++ b/src/store/file/slices/resource/action.ts @@ -1,290 +1,515 @@ import debug from 'debug'; -import { documentService } from '@/services/document'; -import { fileService } from '@/services/file'; +import { knowledgeBaseService } from '@/services/knowledgeBase'; import { resourceService } from '@/services/resource'; -import { type StoreSetter } from '@/store/types'; -import { - type CreateResourceParams, - type ResourceItem, - type UpdateResourceParams, -} from '@/types/resource'; +import type { StoreSetter } from '@/store/types'; +import { OptimisticEngine } from '@/store/utils/optimisticEngine'; +import type { CreateResourceParams, ResourceItem, UpdateResourceParams } from '@/types/resource'; -import { type FileStore } from '../../store'; -import { type ResourceState } from './initialState'; +import type { FileStore } from '../../store'; +import type { ResourceState } from './initialState'; import { initialResourceState } from './initialState'; -import { ResourceSyncEngine } from './syncEngine'; +import { getResourceQueryKey } from './utils'; const log = debug('resource-manager:action'); -let syncEngineInstance: ResourceSyncEngine | null = null; +interface ResourceStoreState extends Pick< + ResourceState, + | 'hasMore' + | 'isLoadingMore' + | 'isSyncing' + | 'lastSyncTime' + | 'offset' + | 'queryParams' + | 'resourceList' + | 'resourceMap' + | 'syncError' + | 'syncQueue' + | 'syncingIds' + | 'total' +> {} type Setter = StoreSetter; -export const createResourceSlice = (set: Setter, get: () => FileStore, _api?: unknown) => ({ - ...initialResourceState, - ...new ResourceActionImpl(set, get, _api), -}); + +export const createResourceSlice = (set: Setter, get: () => FileStore, api?: unknown) => + new ResourceActionImpl(set, get, api); + +const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error))); export class ResourceActionImpl { readonly #get: () => FileStore; + readonly #resourceStoreHandle: { + getState: () => ResourceStoreState; + setState: (nextState: ResourceStoreState) => void; + }; readonly #set: Setter; + #syncEngine?: OptimisticEngine; constructor(set: Setter, get: () => FileStore, _api?: unknown) { void _api; - this.#set = set; this.#get = get; + this.#set = set; + this.#resourceStoreHandle = { + getState: () => { + const state = this.#get(); + + return { + hasMore: state.hasMore, + isLoadingMore: state.isLoadingMore, + isSyncing: state.isSyncing, + lastSyncTime: state.lastSyncTime, + offset: state.offset, + queryParams: state.queryParams, + resourceList: state.resourceList, + resourceMap: state.resourceMap, + syncError: state.syncError, + syncQueue: state.syncQueue, + syncingIds: state.syncingIds, + total: state.total, + }; + }, + setState: (nextState) => { + this.#set(nextState as Partial, false, 'resourceSyncEngine/setState'); + }, + }; } - #getSyncEngine = () => { - if (!syncEngineInstance) { - syncEngineInstance = new ResourceSyncEngine( - () => { - const state = this.#get(); - return { - resourceList: state.resourceList || [], - resourceMap: state.resourceMap || new Map(), - syncQueue: state.syncQueue || [], - syncingIds: state.syncingIds || new Set(), - }; - }, - (partial) => { - this.#set(partial as any, false, 'syncEngine/update'); - }, - ); - } - return syncEngineInstance; + #clearSyncingId = (id: string) => { + this.#set( + (state) => { + if (!state.syncingIds.has(id)) return {}; + + const syncingIds = new Set(state.syncingIds); + syncingIds.delete(id); + + return { syncingIds }; + }, + false, + 'resource/clearSyncingId', + ); }; - /** - * Clear all resources and reset state - */ + #clearResourceOptimisticState = (resource: ResourceItem): ResourceItem => { + const { _optimistic, ...rest } = resource; + + void _optimistic; + + return rest; + }; + + #createOptimisticResource = (params: CreateResourceParams, id?: string): ResourceItem => ({ + _optimistic: { + isPending: true, + queryKey: getResourceQueryKey(this.#get().queryParams), + retryCount: 0, + }, + createdAt: new Date(), + fileType: params.fileType, + id: id || `temp-resource-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, + knowledgeBaseId: params.knowledgeBaseId, + metadata: params.metadata, + name: 'title' in params ? params.title : params.name, + parentId: params.parentId, + size: 'size' in params ? params.size : 0, + sourceType: params.sourceType, + updatedAt: new Date(), + ...(params.sourceType === 'file' + ? { + url: params.url, + } + : { + content: params.content, + editorData: params.editorData ?? {}, + slug: params.slug, + title: params.title, + }), + }); + + #getSyncEngine = () => { + if (this.#syncEngine) return this.#syncEngine; + + this.#syncEngine = new OptimisticEngine(this.#resourceStoreHandle, { + maxRetries: 1, + onMutationError: (_snapshot, error) => { + this.#set( + { + syncError: toError(error), + }, + false, + 'resourceSyncEngine/error', + ); + }, + onMutationSuccess: () => { + this.#set( + { + lastSyncTime: new Date(), + syncError: undefined, + }, + false, + 'resourceSyncEngine/success', + ); + }, + onQueueChange: (snapshots) => { + this.#set( + { + isSyncing: snapshots.some( + (item) => item.status === 'pending' || item.status === 'inflight', + ), + syncQueue: snapshots, + }, + false, + 'resourceSyncEngine/queueChange', + ); + }, + }); + + return this.#syncEngine; + }; + + #replaceLocalResource = (targetId: string, resource: ResourceItem) => { + const { resourceList, resourceMap } = this.#get(); + const nextMap = new Map(resourceMap); + nextMap.delete(targetId); + nextMap.set(resource.id, resource); + + const targetIndex = resourceList.findIndex((item) => item.id === targetId); + const nextList = resourceList.filter((item) => item.id !== targetId && item.id !== resource.id); + // If the replaced item was already visible, keep the replacement visible too. + // This avoids slug-vs-UUID mismatches when queryParams.parentId is a slug + // but resource.parentId is a UUID and the parent folder isn't in resourceMap. + const shouldInsert = targetIndex !== -1 || this.#isResourceVisibleInCurrentQuery(resource); + + if (shouldInsert) { + const insertIndex = targetIndex === -1 ? 0 : Math.min(targetIndex, nextList.length); + nextList.splice(insertIndex, 0, resource); + } + + this.#set( + { + resourceList: nextList, + resourceMap: nextMap, + }, + false, + 'resource/replaceLocalResource', + ); + }; + + #isResourceVisibleInCurrentQuery = (resource: ResourceItem): boolean => { + const { queryParams, resourceMap } = this.#get(); + + if (!queryParams) return false; + + if ( + queryParams.libraryId !== undefined && + (resource.knowledgeBaseId ?? undefined) !== queryParams.libraryId + ) { + return false; + } + + const keyword = queryParams.q?.trim().toLowerCase(); + if (keyword) { + const candidate = `${resource.name} ${resource.title ?? ''}`.trim().toLowerCase(); + if (!candidate.includes(keyword)) return false; + } + + if (queryParams.parentId == null) { + return (resource.parentId ?? null) === null; + } + + if (!resource.parentId) return false; + if (resource.parentId === queryParams.parentId) return true; + + const parentResource = resourceMap.get(resource.parentId); + return parentResource?.slug === queryParams.parentId; + }; + + #patchLocalResourceEntries = ( + ids: Set, + updater: (resource: ResourceItem) => ResourceItem | null, + actionName: string, + onComplete?: (draft: ResourceStoreState, meta: { visibleChangedCount: number }) => void, + ) => { + this.#set( + (state) => { + if (ids.size === 0) return {}; + + const resourceMap = new Map(state.resourceMap); + let changed = false; + let visibleChangedCount = 0; + + const resourceList = state.resourceList.flatMap((item) => { + if (!ids.has(item.id)) return [item]; + + const nextItem = updater(resourceMap.get(item.id) ?? item); + + visibleChangedCount += 1; + changed = true; + + if (!nextItem) { + resourceMap.delete(item.id); + return []; + } + + resourceMap.set(nextItem.id, nextItem); + return [nextItem]; + }); + + for (const id of ids) { + if (state.resourceList.some((item) => item.id === id)) continue; + + const existing = resourceMap.get(id); + if (!existing) continue; + + const nextItem = updater(existing); + changed = true; + + if (!nextItem) { + resourceMap.delete(id); + continue; + } + + resourceMap.set(nextItem.id, nextItem); + } + + if (!changed) return {}; + + const draft: ResourceStoreState = { + ...state, + resourceList, + resourceMap, + }; + + onComplete?.(draft, { visibleChangedCount }); + + return draft; + }, + false, + actionName, + ); + }; + + #toPendingResource = (resource: ResourceItem, patch?: Partial): ResourceItem => ({ + ...resource, + ...patch, + _optimistic: { + ...(resource._optimistic || { + queryKey: getResourceQueryKey(this.#get().queryParams), + retryCount: 0, + }), + isPending: true, + }, + updatedAt: patch?.updatedAt ?? new Date(), + }); + clearResources = (): void => { this.#set( { - hasMore: false, - offset: 0, - queryParams: undefined, - resourceList: [], - resourceMap: new Map(), - syncQueue: [], - total: 0, + ...initialResourceState, }, false, - 'clearResources', + 'resource/clearResources', + ); + }; + + clearCurrentQueryResources = (): void => { + this.#set( + (state) => { + const visibleIds = new Set(state.resourceList.map((item) => item.id)); + const syncingIds = new Set( + Array.from(state.syncingIds).filter((id) => !visibleIds.has(id)), + ); + + // Preserve off-screen optimistic items from other queries + const preservedMap = new Map(); + for (const [id, item] of state.resourceMap) { + if (!visibleIds.has(id) && item._optimistic) { + preservedMap.set(id, item); + } + } + + return { + hasMore: false, + offset: 0, + resourceList: [], + resourceMap: preservedMap, + syncingIds, + total: 0, + }; + }, + false, + 'resource/clearCurrentQueryResources', ); }; - /** - * Create a new resource with optimistic update - * Returns temp ID for immediate UI feedback - */ createResource = async (params: CreateResourceParams): Promise => { - const tempId = `temp-resource-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const optimisticResource = this.#createOptimisticResource(params); + const syncEngine = this.#getSyncEngine(); + const tx = syncEngine.createTransaction(`createResource(${optimisticResource.id})`); - // 1. Create optimistic resource - const optimisticResource: ResourceItem = { - _optimistic: { isPending: true, retryCount: 0 }, - createdAt: new Date(), - fileType: params.fileType, - id: tempId, - knowledgeBaseId: params.knowledgeBaseId, - name: 'title' in params ? params.title : params.name, - parentId: params.parentId, - size: 'size' in params ? params.size : 0, - sourceType: params.sourceType, - updatedAt: new Date(), - ...(params.sourceType === 'file' - ? { - url: 'url' in params ? params.url : '', - } - : { - content: 'content' in params ? params.content : '', - editorData: 'editorData' in params ? params.editorData : {}, - slug: 'slug' in params ? params.slug : undefined, - title: 'title' in params ? params.title : 'Untitled', - }), - metadata: params.metadata, + tx.set((draft) => { + draft.resourceList.unshift(optimisticResource); + draft.resourceMap.set(optimisticResource.id, optimisticResource); + draft.syncingIds.add(optimisticResource.id); + }); + tx.mutation = () => resourceService.createResource(params); + tx.onSuccess = async (result) => { + this.#replaceLocalResource(optimisticResource.id, result as ResourceItem); + this.#clearSyncingId(optimisticResource.id); + }; + tx.onError = async (error) => { + this.#clearSyncingId(optimisticResource.id); + this.markLocalResourceError(optimisticResource.id, toError(error)); }; - // 2. Update store immediately (UI instant feedback) - const { resourceMap, resourceList } = this.#get(); - const newMap = new Map(resourceMap); - newMap.set(tempId, optimisticResource); - - this.#set( - { - resourceList: [optimisticResource, ...resourceList], - resourceMap: newMap, - }, - false, - 'createResource/optimistic', - ); - - // 3. Enqueue sync (background) - const syncEngine = this.#getSyncEngine(); - syncEngine.enqueue({ - id: `sync-${tempId}`, - payload: params, - resourceId: tempId, - retryCount: 0, - timestamp: new Date(), - type: 'create', + void tx.commit().catch((error) => { + console.error('Failed to create resource:', error); }); - return tempId; + return optimisticResource.id; }; - /** - * Create a new resource and wait for sync to complete - * Returns real ID from server (useful for auto-rename after creation) - */ createResourceAndSync = async (params: CreateResourceParams): Promise => { - const tempId = `temp-resource-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const optimisticResource = this.#createOptimisticResource(params); + const syncEngine = this.#getSyncEngine(); + const tx = syncEngine.createTransaction(`createResourceAndSync(${optimisticResource.id})`); - // 1. Create optimistic resource - const optimisticResource: ResourceItem = { - _optimistic: { isPending: true, retryCount: 0 }, - createdAt: new Date(), - fileType: params.fileType, - id: tempId, - knowledgeBaseId: params.knowledgeBaseId, - name: 'title' in params ? params.title : params.name, - parentId: params.parentId, - size: 'size' in params ? params.size : 0, - sourceType: params.sourceType, - updatedAt: new Date(), - ...(params.sourceType === 'file' - ? { - url: 'url' in params ? params.url : '', - } - : { - content: 'content' in params ? params.content : '', - editorData: 'editorData' in params ? params.editorData : {}, - slug: 'slug' in params ? params.slug : undefined, - title: 'title' in params ? params.title : 'Untitled', - }), - metadata: params.metadata, + tx.set((draft) => { + draft.resourceList.unshift(optimisticResource); + draft.resourceMap.set(optimisticResource.id, optimisticResource); + draft.syncingIds.add(optimisticResource.id); + }); + tx.mutation = () => resourceService.createResource(params); + tx.onSuccess = async (result) => { + this.#replaceLocalResource(optimisticResource.id, result as ResourceItem); + this.#clearSyncingId(optimisticResource.id); + }; + tx.onError = async (error) => { + this.#clearSyncingId(optimisticResource.id); + this.markLocalResourceError(optimisticResource.id, toError(error)); }; - // 2. Update store immediately (UI instant feedback) - const { resourceMap, resourceList } = this.#get(); - const newMap = new Map(resourceMap); - newMap.set(tempId, optimisticResource); - - this.#set( - { - resourceList: [optimisticResource, ...resourceList], - resourceMap: newMap, - }, - false, - 'createResourceAndSync/optimistic', - ); - - // 3. Enqueue sync and wait for completion - const syncEngine = this.#getSyncEngine(); - const realId = await syncEngine.enqueue({ - id: `sync-${tempId}`, - payload: params, - resourceId: tempId, - retryCount: 0, - timestamp: new Date(), - type: 'create', - }); - - return (realId as string) || tempId; + const created = await tx.commit(); + return created.id; }; - /** - * Delete a resource with optimistic update - */ deleteResource = async (id: string): Promise => { - const { resourceList, resourceMap } = this.#get(); - const newMap = new Map(resourceMap); - newMap.delete(id); - - log('deleteResource', id, newMap, resourceList); - - this.#set( - { - resourceList: resourceList.filter((item) => item.id !== id), - resourceMap: newMap, - }, - false, - 'deleteResource/optimistic', - ); - const syncEngine = this.#getSyncEngine(); - await syncEngine.enqueue({ - id: `sync-${id}-${Date.now()}`, - payload: {}, - resourceId: id, - retryCount: 0, - timestamp: new Date(), - type: 'delete', - }); + const tx = syncEngine.createTransaction(`deleteResource(${id})`); - log('enqueue deleteResource', id, syncEngine); + tx.set((draft) => { + draft.resourceList = draft.resourceList.filter((item) => item.id !== id); + draft.resourceMap.delete(id); + }); + tx.mutation = () => resourceService.deleteResource(id); + + await tx.commit(); }; deleteResources = async (ids: string[]) => { if (ids.length === 0) return; - // 1. Read sourceType from resourceMap for each ID (client-side, no API call) - const { resourceMap, resourceList } = this.#get(); - const fileIds: string[] = []; - const documentIds: string[] = []; - - for (const id of ids) { - const resource = resourceMap.get(id); - if (resource?.sourceType === 'document') { - documentIds.push(id); - } else { - fileIds.push(id); - } - } - - // 2. Optimistically remove all items from store in one set() call const idsSet = new Set(ids); - const newMap = new Map(resourceMap); - for (const id of ids) { - newMap.delete(id); - } + const syncEngine = this.#getSyncEngine(); + const tx = syncEngine.createTransaction(`deleteResources(${ids.join(',')})`); + + tx.set((draft) => { + draft.resourceList = draft.resourceList.filter((item) => !idsSet.has(item.id)); + for (const id of idsSet) { + draft.resourceMap.delete(id); + } + }); + tx.mutation = () => resourceService.deleteResources(ids); + + await tx.commit(); + }; + + flushSync = async (): Promise => { + await this.#getSyncEngine().flush(); + }; + + insertLocalResource = (params: CreateResourceParams, id?: string): string => { + const optimisticResource = this.#createOptimisticResource(params, id); this.#set( - { - resourceList: resourceList.filter((r) => !idsSet.has(r.id)), - resourceMap: newMap, + (state) => { + const resourceMap = new Map(state.resourceMap); + resourceMap.set(optimisticResource.id, optimisticResource); + + return { + resourceList: [optimisticResource, ...state.resourceList], + resourceMap, + }; }, false, - 'deleteResources/optimistic', + 'resource/insertLocalResource', ); - // 3. Fire batch delete APIs in background (no await — UI already updated) - const promises: Promise[] = []; - if (fileIds.length > 0) promises.push(fileService.removeFiles(fileIds)); - if (documentIds.length > 0) promises.push(documentService.deleteDocuments(documentIds)); - - Promise.all(promises).catch((error) => { - console.error('Failed to delete resources:', error); - }); + return optimisticResource.id; }; - /** - * Flush pending sync operations immediately - */ - flushSync = async (): Promise => { - const syncEngine = this.#getSyncEngine(); - await syncEngine.flush(); + patchLocalResource = ( + id: string, + updates: Partial, + actionName: string = 'resource/patchLocalResource', + ): void => { + this.#patchLocalResourceEntries( + new Set([id]), + (resource) => ({ + ...resource, + ...updates, + }), + actionName, + ); + }; + + patchLocalResourceStatuses = ( + items: Array< + Pick< + ResourceItem, + | 'id' + | 'chunkCount' + | 'chunkingError' + | 'chunkingStatus' + | 'embeddingError' + | 'embeddingStatus' + | 'finishEmbedding' + > + >, + ): void => { + if (items.length === 0) return; + + const statusMap = new Map(items.map((item) => [item.id, item])); + + this.#patchLocalResourceEntries( + new Set(statusMap.keys()), + (resource) => { + const patch = statusMap.get(resource.id); + if (!patch) return resource; + + return { + ...resource, + chunkCount: patch.chunkCount !== undefined ? patch.chunkCount : resource.chunkCount, + chunkingError: + patch.chunkingError !== undefined ? patch.chunkingError : resource.chunkingError, + chunkingStatus: + patch.chunkingStatus !== undefined ? patch.chunkingStatus : resource.chunkingStatus, + embeddingError: + patch.embeddingError !== undefined ? patch.embeddingError : resource.embeddingError, + embeddingStatus: + patch.embeddingStatus !== undefined ? patch.embeddingStatus : resource.embeddingStatus, + finishEmbedding: + patch.finishEmbedding !== undefined ? patch.finishEmbedding : resource.finishEmbedding, + }; + }, + 'resource/patchLocalResourceStatuses', + ); }; - /** - * Load more resources (pagination) - */ loadMoreResources = async (): Promise => { - const { offset, queryParams, hasMore } = this.#get(); + const { hasMore, offset, queryParams } = this.#get(); if (!hasMore || !queryParams) return; - this.#set({ isLoadingMore: true }, false, 'loadMoreResources/start'); + this.#set({ isLoadingMore: true }, false, 'resource/loadMoreResources/start'); try { const { items } = await resourceService.queryResources({ @@ -293,32 +518,71 @@ export class ResourceActionImpl { offset, }); - const { resourceMap, resourceList } = this.#get(); - const newMap = new Map(resourceMap); - items.forEach((item) => newMap.set(item.id, item)); - this.#set( - { - hasMore: items.length === 50, - isLoadingMore: false, - offset: offset + items.length, - resourceList: [...resourceList, ...items], - resourceMap: newMap, + (state) => { + const existingIds = new Set(state.resourceList.map((item) => item.id)); + const resourceMap = new Map(state.resourceMap); + + for (const item of items) { + resourceMap.set(item.id, item); + } + + return { + hasMore: items.length === 50, + isLoadingMore: false, + offset: offset + items.length, + resourceList: [ + ...state.resourceList, + ...items.filter((item) => !existingIds.has(item.id)), + ], + resourceMap, + }; }, false, - 'loadMoreResources/success', + 'resource/loadMoreResources/success', ); } catch (error) { - this.#set({ isLoadingMore: false }, false, 'loadMoreResources/error'); + this.#set({ isLoadingMore: false }, false, 'resource/loadMoreResources/error'); throw error; } }; - /** - * Move a resource to a different parent folder - */ + markLocalResourceError = (id: string, error: Error): void => { + const { resourceMap } = this.#get(); + const resource = resourceMap.get(id); + if (!resource) return; + + const nextResource: ResourceItem = { + ...resource, + _optimistic: { + ...(resource._optimistic || { + isPending: false, + queryKey: getResourceQueryKey(this.#get().queryParams), + retryCount: 0, + }), + error, + isPending: false, + lastSyncAttempt: new Date(), + }, + }; + + this.#set( + (state) => { + const resourceMap = new Map(state.resourceMap); + resourceMap.set(id, nextResource); + + return { + resourceList: state.resourceList.map((item) => (item.id === id ? nextResource : item)), + resourceMap, + }; + }, + false, + 'resource/markLocalResourceError', + ); + }; + moveResource = async (id: string, parentId: string | null): Promise => { - const { resourceMap, resourceList } = this.#get(); + const { queryParams, resourceMap } = this.#get(); const existing = resourceMap.get(id); if (!existing) { @@ -326,81 +590,165 @@ export class ResourceActionImpl { return; } - const newMap = new Map(resourceMap); - newMap.delete(id); - - this.#set( - { - resourceList: resourceList.filter((item) => item.id !== id), - resourceMap: newMap, - }, - false, - 'moveResource/optimistic', - ); + if ((existing.parentId ?? null) === parentId) return; const syncEngine = this.#getSyncEngine(); - await syncEngine.enqueue({ - id: `sync-move-${id}-${Date.now()}`, - payload: { parentId }, - resourceId: id, - retryCount: 0, - timestamp: new Date(), - type: 'move', - }); - }; - - /** - * Retry a failed sync operation - */ - retrySync = async (resourceId: string): Promise => { - const { resourceMap } = this.#get(); - const resource = resourceMap.get(resourceId); - - if (resource?._optimistic?.error) { - const updated: ResourceItem = { - ...resource, - _optimistic: { - isPending: true, + const tx = syncEngine.createTransaction(`moveResource(${id})`); + const movedResource: ResourceItem = { + ...existing, + _optimistic: { + ...(existing._optimistic || { + queryKey: getResourceQueryKey(this.#get().queryParams), retryCount: 0, - }, - }; + }), + isPending: true, + }, + parentId, + updatedAt: new Date(), + }; + const shouldKeepVisible = !queryParams || this.#isResourceVisibleInCurrentQuery(movedResource); - const newMap = new Map(resourceMap); - newMap.set(resourceId, updated); - - const { resourceList } = this.#get(); - const listIndex = resourceList.findIndex((item) => item.id === resourceId); - const newList = [...resourceList]; - if (listIndex >= 0) { - newList[listIndex] = updated; + tx.set((draft) => { + if (shouldKeepVisible) { + draft.resourceMap.set(id, movedResource); + draft.resourceList = draft.resourceList.map((item) => + item.id === id ? movedResource : item, + ); + return; } - this.#set( - { - resourceList: newList, - resourceMap: newMap, - }, - false, - 'retrySync', - ); + draft.resourceList = draft.resourceList.filter((item) => item.id !== id); + draft.resourceMap.delete(id); + }); + tx.mutation = () => resourceService.moveResource(id, parentId); + tx.onSuccess = async (result) => { + if (!shouldKeepVisible) return; + this.#replaceLocalResource(id, result as ResourceItem); + }; - const syncEngine = this.#getSyncEngine(); - syncEngine.enqueue({ - id: `sync-retry-${resourceId}-${Date.now()}`, - payload: {}, - resourceId, - retryCount: 0, - timestamp: new Date(), - type: 'update', - }); - } + await tx.commit(); + }; + + removeLocalResource = (id: string): void => { + this.#set( + (state) => { + const resourceMap = new Map(state.resourceMap); + resourceMap.delete(id); + + return { + resourceList: state.resourceList.filter((item) => item.id !== id), + resourceMap, + }; + }, + false, + 'resource/removeLocalResource', + ); + }; + + replaceLocalResource = (tempId: string, resource: ResourceItem): void => { + this.#replaceLocalResource(tempId, resource); + }; + + retrySync = async (): Promise => { + await this.flushSync(); + }; + + addResourcesToKnowledgeBase = async (knowledgeBaseId: string, ids: string[]): Promise => { + if (ids.length === 0) return; + + const idsSet = new Set(ids); + const syncEngine = this.#getSyncEngine(); + const tx = syncEngine.createTransaction(`addResourcesToKnowledgeBase(${knowledgeBaseId})`); + + tx.set((draft) => { + for (const item of draft.resourceList) { + if (!idsSet.has(item.id)) continue; + + const nextItem = this.#toPendingResource(item, { knowledgeBaseId }); + draft.resourceMap.set(item.id, nextItem); + } + + draft.resourceList = draft.resourceList.map((item) => { + if (!idsSet.has(item.id)) return item; + return draft.resourceMap.get(item.id) ?? item; + }); + }); + tx.mutation = () => knowledgeBaseService.addFilesToKnowledgeBase(knowledgeBaseId, ids); + tx.onSuccess = async () => { + this.#patchLocalResourceEntries( + idsSet, + (resource) => this.#clearResourceOptimisticState({ ...resource, knowledgeBaseId }), + 'resource/addResourcesToKnowledgeBase/success', + ); + }; + + await tx.commit(); + }; + + removeResourcesFromKnowledgeBase = async ( + knowledgeBaseId: string, + ids: string[], + ): Promise => { + if (ids.length === 0) return; + + const idsSet = new Set(ids); + const isKnowledgeBaseView = this.#get().queryParams?.libraryId === knowledgeBaseId; + const syncEngine = this.#getSyncEngine(); + const tx = syncEngine.createTransaction(`removeResourcesFromKnowledgeBase(${knowledgeBaseId})`); + + tx.set((draft) => { + if (isKnowledgeBaseView) { + let visibleChangedCount = 0; + + draft.resourceList = draft.resourceList.filter((item) => { + if (!idsSet.has(item.id)) return true; + + draft.resourceMap.delete(item.id); + visibleChangedCount += 1; + return false; + }); + + if (typeof draft.total === 'number') { + draft.total = Math.max(0, draft.total - ids.length); + draft.hasMore = draft.total > draft.resourceList.length; + } + + draft.offset = Math.max(0, draft.offset - visibleChangedCount); + return; + } + + for (const item of draft.resourceList) { + if (!idsSet.has(item.id)) continue; + + const nextItem = this.#toPendingResource(item, { knowledgeBaseId: undefined }); + draft.resourceMap.set(item.id, nextItem); + } + + draft.resourceList = draft.resourceList.map((item) => { + if (!idsSet.has(item.id)) return item; + return draft.resourceMap.get(item.id) ?? item; + }); + }); + tx.mutation = () => knowledgeBaseService.removeFilesFromKnowledgeBase(knowledgeBaseId, ids); + tx.onSuccess = async () => { + if (isKnowledgeBaseView) return; + + this.#patchLocalResourceEntries( + idsSet, + (resource) => + this.#clearResourceOptimisticState({ + ...resource, + knowledgeBaseId: undefined, + }), + 'resource/removeResourcesFromKnowledgeBase/success', + ); + }; + + await tx.commit(); }; - /** - * Update a resource with optimistic update - */ updateResource = async (id: string, updates: UpdateResourceParams): Promise => { - const { resourceMap, resourceList } = this.#get(); + const { resourceMap } = this.#get(); const existing = resourceMap.get(id); if (!existing) { @@ -408,47 +756,36 @@ export class ResourceActionImpl { return; } - log('updateResource', id, existing, updates); - const updated: ResourceItem = { ...existing, ...updates, - _optimistic: { isPending: true, retryCount: 0 }, + _optimistic: { + ...(existing._optimistic || { + queryKey: getResourceQueryKey(this.#get().queryParams), + retryCount: 0, + }), + isPending: true, + }, name: updates.name || updates.title || existing.name, updatedAt: new Date(), }; - const newMap = new Map(resourceMap); - newMap.set(id, updated); - - const listIndex = resourceList.findIndex((item) => item.id === id); - const newList = [...resourceList]; - if (listIndex >= 0) { - newList[listIndex] = updated; - } - - this.#set( - { - resourceList: newList, - resourceMap: newMap, - }, - false, - 'updateResource/optimistic', - ); + log('updateResource', id, existing, updates); const syncEngine = this.#getSyncEngine(); - syncEngine.enqueue({ - id: `sync-${id}-${Date.now()}`, - payload: updates, - resourceId: id, - retryCount: 0, - timestamp: new Date(), - type: 'update', - }); + const tx = syncEngine.createTransaction(`updateResource(${id})`); - log('enqueue updateResource', id, syncEngine); + tx.set((draft) => { + draft.resourceMap.set(id, updated); + draft.resourceList = draft.resourceList.map((item) => (item.id === id ? updated : item)); + }); + tx.mutation = () => resourceService.updateResource(id, updates); + tx.onSuccess = async (result) => { + this.#replaceLocalResource(id, result as ResourceItem); + }; + + await tx.commit(); }; } export type ResourceAction = Pick; -export type ResourceSlice = ResourceAction & ResourceState; diff --git a/src/store/file/slices/resource/hooks.ts b/src/store/file/slices/resource/hooks.ts index 840413e264..f17ca7f69b 100644 --- a/src/store/file/slices/resource/hooks.ts +++ b/src/store/file/slices/resource/hooks.ts @@ -6,6 +6,7 @@ import { resourceService } from '@/services/resource'; import { type ResourceQueryParams } from '@/types/resource'; import { useFileStore } from '../../store'; +import { mergeServerResourcesWithOptimistic } from './utils'; const SWR_KEY_RESOURCES = 'SWR_RESOURCES'; @@ -39,9 +40,9 @@ export const useFetchResources = (params: ResourceQueryParams | null, enable: an dedupingInterval: 2000, onSuccess: (data: { hasMore: boolean; items: any[]; total?: number }) => { const { resourceList, resourceMap } = useFileStore.getState(); - - const newResourceMap = new Map(data.items.map((item) => [item.id, item])); - const newResourceList = data.items; + const merged = mergeServerResourcesWithOptimistic(data.items, resourceMap, params); + const newResourceList = merged.resourceList; + const newResourceMap = merged.resourceMap; // Only update store if data actually changed if (!isEqual(newResourceList, resourceList) || !isEqual(newResourceMap, resourceMap)) { diff --git a/src/store/file/slices/resource/initialState.ts b/src/store/file/slices/resource/initialState.ts index 9a3d19ca07..5686506f47 100644 --- a/src/store/file/slices/resource/initialState.ts +++ b/src/store/file/slices/resource/initialState.ts @@ -1,4 +1,5 @@ -import { type ResourceItem, type ResourceQueryParams, type SyncOperation } from '@/types/resource'; +import type { OptimisticMutationSnapshot } from '@/store/utils/optimisticEngine'; +import type { ResourceItem, ResourceQueryParams } from '@/types/resource'; /** * Resource slice state @@ -47,7 +48,7 @@ export interface ResourceState { * Sync queue (FIFO) * Contains pending operations to be synced to server */ - syncQueue: SyncOperation[]; + syncQueue: OptimisticMutationSnapshot[]; total: number; } diff --git a/src/store/file/slices/resource/syncEngine.ts b/src/store/file/slices/resource/syncEngine.ts deleted file mode 100644 index 1659393ae6..0000000000 --- a/src/store/file/slices/resource/syncEngine.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { debounce } from 'es-toolkit'; -import { type DebouncedFunc } from 'es-toolkit/compat'; -import pMap from 'p-map'; - -import { resourceService } from '@/services/resource'; -import { type ResourceItem, type SyncOperation } from '@/types/resource'; - -/** - * Sync configuration - */ -const SYNC_CONFIG = { - BACKOFF_MULTIPLIER: 2, - BATCH_SIZE: 10, - CONCURRENCY: 3, - DEBOUNCE_MS: 300, // Wait after last operation - MAX_RETRIES: 3, - MAX_WAIT_MS: 2000, // Force sync after 2s - RETRY_DELAY_MS: 1000, -} as const; - -/** - * State getter/setter interface - */ -interface StateManager { - getState: () => { - resourceList: ResourceItem[]; - resourceMap: Map; - syncQueue: SyncOperation[]; - syncingIds: Set; - }; - setState: (partial: { - resourceList?: ResourceItem[]; - resourceMap?: Map; - syncQueue?: SyncOperation[]; - syncingIds?: Set; - }) => void; -} - -/** - * ResourceSyncEngine - Background sync engine for resource operations - * - * Features: - * - Debounced sync (300ms, max 2s) - * - Batch processing (10 operations per batch) - * - Concurrency control (3 parallel requests) - * - Retry logic with exponential backoff (3 attempts) - * - Error state marking (no rollback) - */ -export class ResourceSyncEngine { - private debouncedSync: DebouncedFunc<() => Promise>; - private stateManager: StateManager; - - constructor(getState: StateManager['getState'], setState: StateManager['setState']) { - this.stateManager = { getState, setState }; - - this.debouncedSync = debounce(() => this.processQueue(), SYNC_CONFIG.DEBOUNCE_MS, { - edges: ['trailing'], - }) as DebouncedFunc<() => Promise>; - - // Add maxWait behavior manually since es-toolkit debounce doesn't support it - let lastExecution = 0; - const originalSync = this.debouncedSync; - - this.debouncedSync = (() => { - const now = Date.now(); - if (now - lastExecution >= SYNC_CONFIG.MAX_WAIT_MS) { - lastExecution = now; - originalSync.flush?.(); - return originalSync(); - } else { - return originalSync(); - } - }) as unknown as DebouncedFunc<() => Promise>; - - this.debouncedSync.flush = originalSync.flush; - this.debouncedSync.cancel = originalSync.cancel; - } - - /** - * Enqueue a sync operation and return a Promise that resolves when it completes - * For 'create' operations, resolves with the real resource ID - * For other operations, resolves with void - */ - enqueue(operation: Omit): Promise { - return new Promise((resolve, reject) => { - const { syncQueue } = this.stateManager.getState(); - const operationWithPromise: SyncOperation = { - ...operation, - reject, - resolve, - }; - - this.stateManager.setState({ - syncQueue: [...syncQueue, operationWithPromise], - }); - - // Trigger debounced sync - this.debouncedSync(); - }); - } - - /** - * Flush pending operations immediately - */ - async flush(): Promise { - this.debouncedSync.flush?.(); - await this.processQueue(); - } - - /** - * Process the sync queue - */ - private async processQueue(): Promise { - const { syncQueue } = this.stateManager.getState(); - - if (syncQueue.length === 0) return; - - // Take batch from queue - const batch = syncQueue.slice(0, SYNC_CONFIG.BATCH_SIZE); - const remaining = syncQueue.slice(SYNC_CONFIG.BATCH_SIZE); - - // Update queue (remove batch) - this.stateManager.setState({ syncQueue: remaining }); - - // Process batch with concurrency limit - await pMap( - batch, - async (operation) => { - try { - await this.processOperation(operation); - } catch (error) { - await this.handleOperationError(operation, error as Error); - } - }, - { concurrency: SYNC_CONFIG.CONCURRENCY }, - ); - - // Continue processing if there are more operations - if (remaining.length > 0) { - await this.processQueue(); - } - } - - /** - * Process a single sync operation - */ - private async processOperation(operation: SyncOperation): Promise { - const { resourceId, type, payload } = operation; - - // Mark as syncing - const { syncingIds } = this.stateManager.getState(); - syncingIds.add(resourceId); - this.stateManager.setState({ syncingIds: new Set(syncingIds) }); - - try { - let realId: string | undefined; - - switch (type) { - case 'create': { - const created = await resourceService.createResource(payload); - this.replaceTempResource(resourceId, created); - realId = created.id; - break; - } - - case 'update': { - const updated = await resourceService.updateResource(resourceId, payload); - this.updateResourceInStore(updated); - break; - } - - case 'delete': { - await resourceService.deleteResource(resourceId); - // Resource already removed from store optimistically - break; - } - - case 'move': { - await resourceService.moveResource(resourceId, payload.parentId); - // Don't update store - resource has already been removed optimistically - // and should stay removed since it moved to a different location - break; - } - } - - // Clear optimistic state on success - this.clearOptimisticState(resourceId); - - // Resolve promise for this operation (return real ID for create) - operation.resolve?.(realId); - } finally { - // Unmark as syncing - const { syncingIds: currentSyncingIds } = this.stateManager.getState(); - currentSyncingIds.delete(resourceId); - this.stateManager.setState({ syncingIds: new Set(currentSyncingIds) }); - } - } - - /** - * Handle operation error - */ - private async handleOperationError(operation: SyncOperation, error: Error): Promise { - const { resourceId, retryCount } = operation; - - if (retryCount < SYNC_CONFIG.MAX_RETRIES) { - // Retry: increment count and re-queue with delay - const delay = SYNC_CONFIG.RETRY_DELAY_MS * SYNC_CONFIG.BACKOFF_MULTIPLIER ** retryCount; - - setTimeout(() => { - const { syncQueue } = this.stateManager.getState(); - this.stateManager.setState({ - syncQueue: [ - ...syncQueue, - { - ...operation, - retryCount: retryCount + 1, - }, - ], - }); - this.debouncedSync(); - }, delay); - } else { - // Max retries reached: mark resource with error state and reject promise - this.markResourceError(resourceId, error); - operation.reject?.(error); - } - } - - /** - * Replace temp resource with real resource from server - */ - private replaceTempResource(tempId: string, realResource: ResourceItem): void { - const { resourceMap, resourceList } = this.stateManager.getState(); - - // Remove temp from map, add real - resourceMap.delete(tempId); - resourceMap.set(realResource.id, realResource); - - // Replace in list - const listIndex = resourceList.findIndex((r) => r.id === tempId); - if (listIndex >= 0) { - resourceList[listIndex] = realResource; - } - - this.stateManager.setState({ - resourceList: [...resourceList], - resourceMap: new Map(resourceMap), - }); - } - - /** - * Update resource in store with fresh data from server - */ - private updateResourceInStore(resource: ResourceItem): void { - const { resourceMap, resourceList } = this.stateManager.getState(); - - resourceMap.set(resource.id, resource); - - const listIndex = resourceList.findIndex((r) => r.id === resource.id); - if (listIndex >= 0) { - resourceList[listIndex] = resource; - } - - this.stateManager.setState({ - resourceList: [...resourceList], - resourceMap: new Map(resourceMap), - }); - } - - /** - * Clear optimistic state from resource - */ - private clearOptimisticState(resourceId: string): void { - const { resourceMap, resourceList } = this.stateManager.getState(); - const resource = resourceMap.get(resourceId); - - if (resource?._optimistic) { - const updated = { ...resource }; - delete updated._optimistic; - - resourceMap.set(resourceId, updated); - - const listIndex = resourceList.findIndex((r) => r.id === resourceId); - if (listIndex >= 0) { - resourceList[listIndex] = updated; - } - - this.stateManager.setState({ - resourceList: [...resourceList], - resourceMap: new Map(resourceMap), - }); - } - } - - /** - * Mark resource with error state - */ - private markResourceError(resourceId: string, error: Error): void { - const { resourceMap, resourceList } = this.stateManager.getState(); - const resource = resourceMap.get(resourceId); - - if (resource) { - const updated = { - ...resource, - _optimistic: { - error, - isPending: false, - lastSyncAttempt: new Date(), - retryCount: SYNC_CONFIG.MAX_RETRIES, - }, - }; - - resourceMap.set(resourceId, updated); - - const listIndex = resourceList.findIndex((r) => r.id === resourceId); - if (listIndex >= 0) { - resourceList[listIndex] = updated; - } - - this.stateManager.setState({ - resourceList: [...resourceList], - resourceMap: new Map(resourceMap), - }); - } - } -} diff --git a/src/store/file/slices/resource/utils.test.ts b/src/store/file/slices/resource/utils.test.ts new file mode 100644 index 0000000000..c43c971194 --- /dev/null +++ b/src/store/file/slices/resource/utils.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceItem } from '@/types/resource'; + +import { getResourceQueryKey, mergeServerResourcesWithOptimistic } from './utils'; + +const createResource = (overrides: Partial = {}): ResourceItem => ({ + createdAt: new Date('2026-01-01T00:00:00.000Z'), + fileType: 'text/plain', + id: 'resource-1', + name: 'Resource 1', + parentId: null, + size: 1, + sourceType: 'file', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + url: 'files/resource-1.txt', + ...overrides, +}); + +describe('mergeServerResourcesWithOptimistic', () => { + it('should preserve optimistic resources from other queries in the global resource map', () => { + const offscreenOptimistic = createResource({ + _optimistic: { + isPending: true, + queryKey: getResourceQueryKey({ parentId: 'folder-a' }), + retryCount: 0, + }, + id: 'temp-a', + name: 'Offscreen upload', + parentId: 'folder-a', + }); + const currentServerItem = createResource({ + id: 'file-b', + name: 'Visible item', + parentId: 'folder-b', + }); + + const merged = mergeServerResourcesWithOptimistic( + [currentServerItem], + new Map([[offscreenOptimistic.id, offscreenOptimistic]]), + { parentId: 'folder-b' }, + ); + + expect(merged.resourceList).toEqual([currentServerItem]); + expect(merged.resourceMap.get(offscreenOptimistic.id)).toEqual(offscreenOptimistic); + expect(merged.resourceMap.get(currentServerItem.id)).toEqual(currentServerItem); + }); +}); diff --git a/src/store/file/slices/resource/utils.ts b/src/store/file/slices/resource/utils.ts new file mode 100644 index 0000000000..039c0a3326 --- /dev/null +++ b/src/store/file/slices/resource/utils.ts @@ -0,0 +1,61 @@ +import { isEqual } from 'es-toolkit'; + +import type { ResourceItem, ResourceQueryParams } from '@/types/resource'; + +export const getResourceQueryKey = (params?: ResourceQueryParams | null) => { + if (!params) return 'resource-query:default'; + + return JSON.stringify({ + category: params.category ?? null, + libraryId: params.libraryId ?? null, + parentId: params.parentId ?? null, + q: params.q ?? null, + showFilesInKnowledgeBase: params.showFilesInKnowledgeBase ?? null, + sorter: params.sorter ?? null, + sortType: params.sortType ?? null, + }); +}; + +export const mergeServerResourcesWithOptimistic = ( + serverItems: ResourceItem[], + localResourceMap: Map, + queryParams?: ResourceQueryParams | null, +) => { + const queryKey = getResourceQueryKey(queryParams); + const serverMap = new Map(serverItems.map((item) => [item.id, item])); + + const optimisticItems = Array.from(localResourceMap.values()).filter( + (item) => item._optimistic?.queryKey === queryKey, + ); + + const optimisticById = new Map(); + const optimisticOnlyItems: ResourceItem[] = []; + + for (const item of optimisticItems) { + if (serverMap.has(item.id)) { + optimisticById.set(item.id, item); + continue; + } + + optimisticOnlyItems.push(item); + } + + const mergedList = [ + ...optimisticOnlyItems, + ...serverItems.map((item) => optimisticById.get(item.id) ?? item), + ]; + const mergedMap = new Map(localResourceMap); + + for (const item of mergedList) { + mergedMap.set(item.id, item); + } + + return { + changed: + !isEqual(mergedList, serverItems) || + optimisticOnlyItems.length > 0 || + optimisticById.size > 0, + resourceList: mergedList, + resourceMap: mergedMap, + }; +}; diff --git a/src/store/file/slices/upload/action.ts b/src/store/file/slices/upload/action.ts index 8ab82a20e7..1cb94b22a2 100644 --- a/src/store/file/slices/upload/action.ts +++ b/src/store/file/slices/upload/action.ts @@ -40,6 +40,7 @@ interface UploadWithProgressParams { * Optional source identifier for the file (e.g., 'page-editor', 'image_generation') */ source?: string; + uploadId?: string; } interface UploadWithProgressResult { @@ -89,8 +90,11 @@ export class FileUploadActionImpl { skipCheckFileType, parentId, source, + uploadId, abortController, }: UploadWithProgressParams): Promise => { + const statusId = uploadId ?? file.name; + try { const fileArrayBuffer = await file.arrayBuffer(); @@ -107,7 +111,7 @@ export class FileUploadActionImpl { if (checkStatus.isExist) { metadata = checkStatus.metadata as FileMetadata; onStatusUpdate?.({ - id: file.name, + id: statusId, type: 'updateFile', value: { status: 'processing', uploadState: { progress: 100, restTime: 0, speed: 0 } }, }); @@ -117,7 +121,7 @@ export class FileUploadActionImpl { const { data, success } = await uploadService.uploadFileToS3(file, { abortController, onNotSupported: () => { - onStatusUpdate?.({ id: file.name, type: 'removeFile' }); + onStatusUpdate?.({ id: statusId, type: 'removeFile' }); message.info({ content: t('upload.fileOnlySupportInServerMode', { cloud: LOBE_CHAT_CLOUD, @@ -129,7 +133,7 @@ export class FileUploadActionImpl { }, onProgress: (status, upload) => { onStatusUpdate?.({ - id: file.name, + id: statusId, type: 'updateFile', value: { status: status === 'success' ? 'processing' : status, uploadState: upload }, }); @@ -167,7 +171,7 @@ export class FileUploadActionImpl { ); onStatusUpdate?.({ - id: file.name, + id: statusId, type: 'updateFile', value: { fileUrl: data.url, @@ -181,7 +185,7 @@ export class FileUploadActionImpl { } catch (error) { // Handle file storage plan limit error if ((error as any)?.message?.includes('beyond the plan limit')) { - onStatusUpdate?.({ id: file.name, type: 'removeFile' }); + onStatusUpdate?.({ id: statusId, type: 'removeFile' }); notification.error({ description: t('upload.storageLimitExceeded', { ns: 'error' }), message: t('upload.uploadFailed', { ns: 'error' }), diff --git a/src/store/file/store.ts b/src/store/file/store.ts index ba5edca3b1..eb947d75c8 100644 --- a/src/store/file/store.ts +++ b/src/store/file/store.ts @@ -1,39 +1,39 @@ import { shallow } from 'zustand/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; -import { type StateCreator } from 'zustand/vanilla'; +import type { StateCreator } from 'zustand/vanilla'; import { createDevtools } from '../middleware/createDevtools'; import { expose } from '../middleware/expose'; import { flattenActions } from '../utils/flattenActions'; -import { type FilesStoreState } from './initialState'; +import type { FilesStoreState } from './initialState'; import { initialState } from './initialState'; -import { type FileAction } from './slices/chat'; +import type { FileAction } from './slices/chat'; import { createFileSlice } from './slices/chat'; -import { type FileChunkAction } from './slices/chunk'; +import type { FileChunkAction } from './slices/chunk'; import { createFileChunkSlice } from './slices/chunk'; -import { type DocumentAction } from './slices/document'; +import type { DocumentAction } from './slices/document'; import { createDocumentSlice } from './slices/document'; -import { type FileManageAction } from './slices/fileManager'; +import type { FileManageAction } from './slices/fileManager'; import { createFileManageSlice } from './slices/fileManager'; -import { type ResourceAction } from './slices/resource/action'; -import { createResourceSlice } from './slices/resource/action'; -import { type ResourceState } from './slices/resource/initialState'; -import { type TTSFileAction } from './slices/tts'; +import type { ResourceAction } from './slices/resource/action'; +import { ResourceActionImpl } from './slices/resource/action'; +import type { TTSFileAction } from './slices/tts'; import { createTTSFileSlice } from './slices/tts'; -import { type FileUploadAction } from './slices/upload/action'; +import type { FileUploadAction } from './slices/upload/action'; import { createFileUploadSlice } from './slices/upload/action'; // =============== Aggregate createStoreFn ============ // -export type FileStore = FilesStoreState & - FileAction & - DocumentAction & - TTSFileAction & - FileManageAction & - FileChunkAction & - FileUploadAction & - ResourceAction & - ResourceState; +export interface FileStore + extends + FileAction, + DocumentAction, + TTSFileAction, + FileManageAction, + FileChunkAction, + FileUploadAction, + ResourceAction, + FilesStoreState {} type FileStoreAction = FileAction & DocumentAction & @@ -44,17 +44,17 @@ type FileStoreAction = FileAction & ResourceAction; const createStore: StateCreator = ( - ...parameters: Parameters> + ...params: Parameters> ) => ({ ...initialState, ...flattenActions([ - createFileSlice(...parameters), - createDocumentSlice(...parameters), - createFileManageSlice(...parameters), - createTTSFileSlice(...parameters), - createFileChunkSlice(...parameters), - createFileUploadSlice(...parameters), - createResourceSlice(...parameters), + createFileSlice(...params), + createDocumentSlice(...params), + createFileManageSlice(...params), + createTTSFileSlice(...params), + createFileChunkSlice(...params), + createFileUploadSlice(...params), + new ResourceActionImpl(...params), ]), }); diff --git a/src/store/library/slices/content/action.test.ts b/src/store/library/slices/content/action.test.ts index 177a0aba3d..27e940ad4f 100644 --- a/src/store/library/slices/content/action.test.ts +++ b/src/store/library/slices/content/action.test.ts @@ -1,8 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { knowledgeBaseService } from '@/services/knowledgeBase'; -import * as resourceHooks from '@/store/file/slices/resource/hooks'; +import { useFileStore } from '@/store/file'; import { useKnowledgeBaseStore as useStore } from '../../store'; @@ -18,47 +17,23 @@ afterEach(() => { describe('KnowledgeBaseContentActions', () => { describe('addFilesToKnowledgeBase', () => { - it('should add files to knowledge base and refresh file list', async () => { + it('should add files to knowledge base through the file store', async () => { const { result } = renderHook(() => useStore()); const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1', 'file-2', 'file-3']; + const addResourcesToKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const addFilesSpy = vi - .spyOn(knowledgeBaseService, 'addFilesToKnowledgeBase') - .mockResolvedValue([ - { - createdAt: new Date(), - fileId: 'file-1', - knowledgeBaseId: 'kb-1', - userId: 'user-1', - }, - { - createdAt: new Date(), - fileId: 'file-2', - knowledgeBaseId: 'kb-1', - userId: 'user-1', - }, - { - createdAt: new Date(), - fileId: 'file-3', - knowledgeBaseId: 'kb-1', - userId: 'user-1', - }, - ]); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + addResourcesToKnowledgeBase, + } as any); await act(async () => { await result.current.addFilesToKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(addFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(addFilesSpy).toHaveBeenCalledTimes(1); - expect(revalidateResourcesSpy).toHaveBeenCalled(); - expect(revalidateResourcesSpy).toHaveBeenCalledTimes(1); + expect(addResourcesToKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); + expect(addResourcesToKnowledgeBase).toHaveBeenCalledTimes(1); }); it('should handle single file addition', async () => { @@ -66,28 +41,17 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1']; + const addResourcesToKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const addFilesSpy = vi - .spyOn(knowledgeBaseService, 'addFilesToKnowledgeBase') - .mockResolvedValue([ - { - createdAt: new Date(), - fileId: 'file-1', - knowledgeBaseId: 'kb-1', - userId: 'user-1', - }, - ]); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + addResourcesToKnowledgeBase, + } as any); await act(async () => { await result.current.addFilesToKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(addFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(revalidateResourcesSpy).toHaveBeenCalled(); + expect(addResourcesToKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); }); it('should handle empty file array', async () => { @@ -95,21 +59,17 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds: string[] = []; + const addResourcesToKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const addFilesSpy = vi - .spyOn(knowledgeBaseService, 'addFilesToKnowledgeBase') - .mockResolvedValue([]); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + addResourcesToKnowledgeBase, + } as any); await act(async () => { await result.current.addFilesToKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(addFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(revalidateResourcesSpy).toHaveBeenCalled(); + expect(addResourcesToKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); }); describe('error handling', () => { @@ -119,72 +79,39 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1', 'file-2']; const serviceError = new Error('Failed to add files to knowledge base'); + const addResourcesToKnowledgeBase = vi.fn().mockRejectedValue(serviceError); - vi.spyOn(knowledgeBaseService, 'addFilesToKnowledgeBase').mockRejectedValue(serviceError); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + addResourcesToKnowledgeBase, + } as any); await expect(async () => { await act(async () => { await result.current.addFilesToKnowledgeBase(knowledgeBaseId, fileIds); }); }).rejects.toThrow('Failed to add files to knowledge base'); - - expect(revalidateResourcesSpy).not.toHaveBeenCalled(); - }); - - it('should handle refresh file list errors', async () => { - const { result } = renderHook(() => useStore()); - - const knowledgeBaseId = 'kb-1'; - const fileIds = ['file-1']; - const refreshError = new Error('Failed to refresh file list'); - - vi.spyOn(knowledgeBaseService, 'addFilesToKnowledgeBase').mockResolvedValue([ - { - createdAt: new Date(), - fileId: 'file-1', - knowledgeBaseId: 'kb-1', - userId: 'user-1', - }, - ]); - - vi.spyOn(resourceHooks, 'revalidateResources').mockRejectedValue(refreshError); - - await expect(async () => { - await act(async () => { - await result.current.addFilesToKnowledgeBase(knowledgeBaseId, fileIds); - }); - }).rejects.toThrow('Failed to refresh file list'); }); }); }); describe('removeFilesFromKnowledgeBase', () => { - it('should remove files from knowledge base and refresh file list', async () => { + it('should remove files from knowledge base through the file store', async () => { const { result } = renderHook(() => useStore()); const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1', 'file-2', 'file-3']; + const removeResourcesFromKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const removeFilesSpy = vi - .spyOn(knowledgeBaseService, 'removeFilesFromKnowledgeBase') - .mockResolvedValue({} as any); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + removeResourcesFromKnowledgeBase, + } as any); await act(async () => { await result.current.removeFilesFromKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(removeFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(removeFilesSpy).toHaveBeenCalledTimes(1); - expect(revalidateResourcesSpy).toHaveBeenCalled(); - expect(revalidateResourcesSpy).toHaveBeenCalledTimes(1); + expect(removeResourcesFromKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); + expect(removeResourcesFromKnowledgeBase).toHaveBeenCalledTimes(1); }); it('should handle single file removal', async () => { @@ -192,21 +119,17 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1']; + const removeResourcesFromKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const removeFilesSpy = vi - .spyOn(knowledgeBaseService, 'removeFilesFromKnowledgeBase') - .mockResolvedValue({} as any); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + removeResourcesFromKnowledgeBase, + } as any); await act(async () => { await result.current.removeFilesFromKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(removeFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(revalidateResourcesSpy).toHaveBeenCalled(); + expect(removeResourcesFromKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); }); it('should handle empty file array', async () => { @@ -214,21 +137,17 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds: string[] = []; + const removeResourcesFromKnowledgeBase = vi.fn().mockResolvedValue(undefined); - const removeFilesSpy = vi - .spyOn(knowledgeBaseService, 'removeFilesFromKnowledgeBase') - .mockResolvedValue({} as any); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + removeResourcesFromKnowledgeBase, + } as any); await act(async () => { await result.current.removeFilesFromKnowledgeBase(knowledgeBaseId, fileIds); }); - expect(removeFilesSpy).toHaveBeenCalledWith(knowledgeBaseId, fileIds); - expect(revalidateResourcesSpy).toHaveBeenCalled(); + expect(removeResourcesFromKnowledgeBase).toHaveBeenCalledWith(knowledgeBaseId, fileIds); }); describe('error handling', () => { @@ -238,40 +157,17 @@ describe('KnowledgeBaseContentActions', () => { const knowledgeBaseId = 'kb-1'; const fileIds = ['file-1', 'file-2']; const serviceError = new Error('Failed to remove files from knowledge base'); + const removeResourcesFromKnowledgeBase = vi.fn().mockRejectedValue(serviceError); - vi.spyOn(knowledgeBaseService, 'removeFilesFromKnowledgeBase').mockRejectedValue( - serviceError, - ); - - const revalidateResourcesSpy = vi - .spyOn(resourceHooks, 'revalidateResources') - .mockResolvedValue(undefined); + vi.spyOn(useFileStore, 'getState').mockReturnValue({ + removeResourcesFromKnowledgeBase, + } as any); await expect(async () => { await act(async () => { await result.current.removeFilesFromKnowledgeBase(knowledgeBaseId, fileIds); }); }).rejects.toThrow('Failed to remove files from knowledge base'); - - expect(revalidateResourcesSpy).not.toHaveBeenCalled(); - }); - - it('should handle refresh file list errors', async () => { - const { result } = renderHook(() => useStore()); - - const knowledgeBaseId = 'kb-1'; - const fileIds = ['file-1']; - const refreshError = new Error('Failed to refresh file list'); - - vi.spyOn(knowledgeBaseService, 'removeFilesFromKnowledgeBase').mockResolvedValue({} as any); - - vi.spyOn(resourceHooks, 'revalidateResources').mockRejectedValue(refreshError); - - await expect(async () => { - await act(async () => { - await result.current.removeFilesFromKnowledgeBase(knowledgeBaseId, fileIds); - }); - }).rejects.toThrow('Failed to refresh file list'); }); }); }); diff --git a/src/store/library/slices/content/action.ts b/src/store/library/slices/content/action.ts index e657b55e89..cc702e255d 100644 --- a/src/store/library/slices/content/action.ts +++ b/src/store/library/slices/content/action.ts @@ -1,5 +1,4 @@ -import { knowledgeBaseService } from '@/services/knowledgeBase'; -import { revalidateResources } from '@/store/file/slices/resource/hooks'; +import { useFileStore } from '@/store/file'; import { type KnowledgeBaseStore } from '@/store/library/store'; import { type StoreSetter } from '@/store/types'; @@ -15,17 +14,13 @@ export class KnowledgeBaseContentActionImpl { } addFilesToKnowledgeBase = async (knowledgeBaseId: string, ids: string[]): Promise => { - await knowledgeBaseService.addFilesToKnowledgeBase(knowledgeBaseId, ids); - - // Revalidate resource list to show updated KB associations - await revalidateResources(); + const fileStore = useFileStore.getState(); + await fileStore.addResourcesToKnowledgeBase(knowledgeBaseId, ids); }; removeFilesFromKnowledgeBase = async (knowledgeBaseId: string, ids: string[]): Promise => { - await knowledgeBaseService.removeFilesFromKnowledgeBase(knowledgeBaseId, ids); - - // Revalidate resource list to show updated KB associations - await revalidateResources(); + const fileStore = useFileStore.getState(); + await fileStore.removeResourcesFromKnowledgeBase(knowledgeBaseId, ids); }; } diff --git a/src/store/utils/optimisticEngine.test.ts b/src/store/utils/optimisticEngine.test.ts new file mode 100644 index 0000000000..cf50966025 --- /dev/null +++ b/src/store/utils/optimisticEngine.test.ts @@ -0,0 +1,389 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore } from 'zustand/vanilla'; + +import { + extractAffectedPaths, + hasPathConflict, + OptimisticEngine, + type OptimisticMutationSnapshot, +} from './optimisticEngine'; + +interface TestState { + count: number; + nested: { + value: number; + }; + other: number; +} + +const createTestStore = (initialState: TestState) => createStore()(() => initialState); + +const createDeferred = () => { + let reject!: (reason?: unknown) => void; + let resolve!: (value: T) => void; + + const promise = new Promise((res, rej) => { + reject = rej; + resolve = res; + }); + + return { promise, reject, resolve }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('optimisticEngine helpers', () => { + it('should extract affected paths from immer patches', () => { + expect( + extractAffectedPaths([ + { op: 'replace', path: ['nested', 'value'], value: 1 }, + { op: 'replace', path: ['count'], value: 1 }, + ]), + ).toEqual(['nested.value', 'count']); + }); + + it('should detect conflicting and non-conflicting paths', () => { + expect(hasPathConflict(['store:count'], ['store:count.value'])).toBe(true); + expect(hasPathConflict(['store:count'], ['store:other'])).toBe(false); + }); +}); + +describe('OptimisticEngine', () => { + it('should keep optimistic patches after a successful mutation', async () => { + const snapshots: OptimisticMutationSnapshot[][] = []; + const onSuccess = vi.fn(); + const transactionOnSuccess = vi.fn(); + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store, { + onMutationSuccess: onSuccess, + onQueueChange: (nextSnapshots) => { + snapshots.push(nextSnapshots); + }, + }); + const tx = engine.createTransaction('increment'); + + tx.set((draft) => { + draft.count += 1; + }); + tx.onSuccess = transactionOnSuccess; + tx.mutation = async () => 'ok'; + + await expect(tx.commit()).resolves.toBe('ok'); + expect(store.getState().count).toBe(1); + expect(transactionOnSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(snapshots.at(-1)?.[0]?.status).toBe('success'); + }); + + it('should retry a mutation before succeeding', async () => { + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store, { maxRetries: 1 }); + const tx = engine.createTransaction('increment'); + let attempts = 0; + + tx.set((draft) => { + draft.count += 1; + }); + tx.mutation = async () => { + attempts += 1; + + if (attempts === 1) { + throw new Error('retry'); + } + + return 'retried'; + }; + + await expect(tx.commit()).resolves.toBe('retried'); + expect(attempts).toBe(2); + expect(store.getState().count).toBe(1); + }); + + it('should rollback optimistic patches when the mutation fails', async () => { + const onError = vi.fn(); + const transactionOnError = vi.fn(); + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store, { onMutationError: onError }); + const tx = engine.createTransaction('increment'); + + tx.set((draft) => { + draft.count += 1; + }); + tx.onError = transactionOnError; + tx.mutation = async () => { + throw new Error('mutation failed'); + }; + + await expect(tx.commit()).rejects.toThrow('mutation failed'); + expect(store.getState().count).toBe(0); + expect(transactionOnError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('should serialize conflicting mutations on the same path', async () => { + const deferred = createDeferred(); + const calls: string[] = []; + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store); + + const tx1 = engine.createTransaction('first'); + tx1.set((draft) => { + draft.count += 1; + }); + tx1.mutation = async () => { + calls.push('first-start'); + const result = await deferred.promise; + calls.push('first-end'); + + return result; + }; + + const tx2 = engine.createTransaction('second'); + tx2.set((draft) => { + draft.count += 1; + }); + tx2.mutation = async () => { + calls.push('second-start'); + return 'second'; + }; + + const firstPromise = tx1.commit(); + const secondPromise = tx2.commit(); + + await Promise.resolve(); + expect(calls).toEqual(['first-start']); + + deferred.resolve('first'); + + await expect(firstPromise).resolves.toBe('first'); + await expect(secondPromise).resolves.toBe('second'); + expect(calls).toEqual(['first-start', 'first-end', 'second-start']); + expect(store.getState().count).toBe(2); + }); + + it('should rollback the failed mutation and rebase other inflight mutations', async () => { + const deferred = createDeferred(); + const errors: unknown[] = []; + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store, { + onMutationError: (snapshot, error) => { + errors.push({ error, snapshot }); + }, + }); + + const tx1 = engine.createTransaction('delayed'); + tx1.set((draft) => { + draft.count += 1; + }); + tx1.mutation = async () => deferred.promise; + + const tx2 = engine.createTransaction('fail'); + tx2.set((draft) => { + draft.other += 1; + }); + tx2.mutation = async () => { + throw new Error('boom'); + }; + + const tx1Promise = tx1.commit(); + await expect(tx2.commit()).rejects.toThrow('boom'); + expect(store.getState()).toEqual({ count: 1, nested: { value: 0 }, other: 0 }); + + deferred.resolve('late-success'); + + await expect(tx1Promise).resolves.toBe('late-success'); + expect(store.getState()).toEqual({ count: 1, nested: { value: 0 }, other: 0 }); + expect(errors).toHaveLength(1); + }); + + it('should wait in flush until pending work is finished', async () => { + const deferred = createDeferred(); + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store); + const tx = engine.createTransaction('increment'); + + tx.set((draft) => { + draft.count += 1; + }); + tx.mutation = async () => deferred.promise; + + const commitPromise = tx.commit(); + let flushed = false; + const flushPromise = engine.flush().then(() => { + flushed = true; + }); + + await Promise.resolve(); + expect(flushed).toBe(false); + + deferred.resolve('done'); + + await commitPromise; + await flushPromise; + expect(flushed).toBe(true); + }); + + it('should resolve flush immediately when the queue is idle', async () => { + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store); + + await expect(engine.flush()).resolves.toBeUndefined(); + }); + + it('should support external stores and deferred flush', async () => { + const defaultStore = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const externalStore = createTestStore({ count: 10, nested: { value: 1 }, other: 20 }); + const engine = new OptimisticEngine(defaultStore); + const tx = engine.createTransaction('multi-store'); + + tx.set((draft) => { + draft.count += 1; + }); + tx.set( + externalStore, + (draft) => { + draft.other += 1; + }, + { flush: false }, + ); + tx.mutation = async () => 'done'; + + expect(externalStore.getState().other).toBe(20); + await expect(tx.commit()).resolves.toBe('done'); + expect(defaultStore.getState().count).toBe(1); + expect(externalStore.getState().other).toBe(21); + }); + + it('should rebase rollback correctly across different stores', async () => { + const deferred = createDeferred(); + const defaultStore = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const externalStore = createTestStore({ count: 10, nested: { value: 1 }, other: 20 }); + const engine = new OptimisticEngine(defaultStore); + + const tx1 = engine.createTransaction('external'); + tx1.set(externalStore, (draft) => { + draft.other += 1; + }); + tx1.mutation = async () => deferred.promise; + + const tx2 = engine.createTransaction('default-fail'); + tx2.set((draft) => { + draft.count += 1; + }); + tx2.mutation = async () => { + throw new Error('boom'); + }; + + const tx1Promise = tx1.commit(); + await expect(tx2.commit()).rejects.toThrow('boom'); + + expect(defaultStore.getState()).toEqual({ count: 0, nested: { value: 0 }, other: 0 }); + expect(externalStore.getState()).toEqual({ count: 10, nested: { value: 1 }, other: 21 }); + + deferred.resolve('external'); + + await expect(tx1Promise).resolves.toBe('external'); + }); + + it('should support external stores without explicitly passing options', async () => { + const defaultStore = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const externalStore = createTestStore({ count: 10, nested: { value: 1 }, other: 20 }); + const engine = new OptimisticEngine(defaultStore); + const tx = engine.createTransaction('external-no-options'); + + tx.set(externalStore, (draft) => { + draft.count += 1; + }); + tx.mutation = async () => 'done'; + + await expect(tx.commit()).resolves.toBe('done'); + expect(externalStore.getState().count).toBe(11); + }); + + it('should merge multiple patch records for the same store', async () => { + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store); + const tx = engine.createTransaction('merge-records'); + + tx.set((draft) => { + draft.count += 1; + }); + tx.set((draft) => { + draft.other += 1; + }); + tx.mutation = async () => 'done'; + + await expect(tx.commit()).resolves.toBe('done'); + expect(store.getState()).toEqual({ count: 1, nested: { value: 0 }, other: 1 }); + }); + + it('should trim history according to maxHistory', async () => { + let latestSnapshots: OptimisticMutationSnapshot[] = []; + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store, { + maxHistory: 1, + onQueueChange: (snapshots) => { + latestSnapshots = snapshots; + }, + }); + + for (const name of ['first', 'second']) { + const tx = engine.createTransaction(name); + tx.set((draft) => { + draft.count += 1; + }); + tx.mutation = async () => name; + await tx.commit(); + } + + expect(latestSnapshots).toHaveLength(1); + expect(latestSnapshots[0].actionName).toBe('second'); + }); + + it('should allow no-op patches while still running the mutation', async () => { + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engine = new OptimisticEngine(store); + const tx = engine.createTransaction('noop'); + + tx.set(() => {}); + tx.mutation = async () => 'noop'; + + await expect(tx.commit()).resolves.toBe('noop'); + expect(store.getState().count).toBe(0); + }); + + it('should throw on invalid transaction usage', async () => { + const store = createTestStore({ count: 0, nested: { value: 0 }, other: 0 }); + const engineWithoutDefaultStore = new OptimisticEngine(); + const missingDefaultStoreTx = engineWithoutDefaultStore.createTransaction('missing-default'); + + expect(() => + missingDefaultStoreTx.set((draft) => { + draft.count += 1; + }), + ).toThrow('no default store set'); + + const missingMutationTx = new OptimisticEngine(store).createTransaction('missing-mutation'); + missingMutationTx.set((draft) => { + draft.count += 1; + }); + await expect(async () => missingMutationTx.commit()).rejects.toThrow('missing remote mutation'); + + const committedTx = new OptimisticEngine(store).createTransaction('committed'); + committedTx.set((draft) => { + draft.count += 1; + }); + committedTx.mutation = async () => 'done'; + await committedTx.commit(); + + expect(() => + committedTx.set((draft) => { + draft.count += 1; + }), + ).toThrow('cannot call set() after commit()'); + + await expect(async () => committedTx.commit()).rejects.toThrow('transaction already committed'); + }); +}); diff --git a/src/store/utils/optimisticEngine.ts b/src/store/utils/optimisticEngine.ts new file mode 100644 index 0000000000..f19d85b9d7 --- /dev/null +++ b/src/store/utils/optimisticEngine.ts @@ -0,0 +1,497 @@ +import type { Draft, Patch } from 'immer'; +import { applyPatches, produceWithPatches } from 'immer'; +import type { StoreApi } from 'zustand'; + +type StoreState = object; +type AnyStore = StoreHandle; +type Recipe = (draft: Draft) => void; +type RemoteFn = () => Promise; + +export type OptimisticMutationStatus = + | 'failed' + | 'inflight' + | 'pending' + | 'rolled-back' + | 'success'; + +export type StoreHandle = Pick< + StoreApi, + 'getState' | 'setState' +>; + +export interface OptimisticMutationSnapshot { + actionName?: string; + affectedPaths: string[]; + id: string; + maxRetries: number; + retryCount: number; + status: OptimisticMutationStatus; + timestamp: Date; +} + +export interface OptimisticEngineOptions { + maxHistory?: number; + maxRetries?: number; + onMutationError?: (snapshot: OptimisticMutationSnapshot, error: unknown) => void; + onMutationSuccess?: (snapshot: OptimisticMutationSnapshot) => void; + onQueueChange?: (snapshots: OptimisticMutationSnapshot[]) => void; +} + +interface SetOptions { + flush?: boolean; +} + +interface StorePatchEntry { + inversePatches: Patch[]; + patches: Patch[]; +} + +interface QueuedMutation { + actionName?: string; + affectedPaths: string[]; + id: string; + maxRetries: number; + onError?: (error: unknown) => void | Promise; + onSuccess?: (result: unknown) => void | Promise; + reject?: (reason?: unknown) => void; + remoteFn: RemoteFn; + resolve?: (value: unknown) => void; + retryCount: number; + status: OptimisticMutationStatus; + storePatches: Map; + timestamp: number; +} + +interface Mutation extends Omit { + onSuccess?: (result: T) => void | Promise; + remoteFn: RemoteFn; + resolve?: (value: T) => void; +} + +interface SetRecord { + flushed: boolean; + inversePatches: Patch[]; + patches: Patch[]; + store: AnyStore; +} + +function asAnyStore(store: StoreHandle): AnyStore { + return store as unknown as AnyStore; +} + +const storeIdMap = new WeakMap(); +let storeIdCounter = 0; +let mutationIdCounter = 0; + +function getStoreId(store: AnyStore): string { + let id = storeIdMap.get(store); + if (!id) { + id = `optimistic-store-${++storeIdCounter}`; + storeIdMap.set(store, id); + } + + return id; +} + +export function extractAffectedPaths(patches: Patch[]): string[] { + const paths = new Set(); + + for (const patch of patches) { + const entityPath = patch.path.slice(0, Math.min(patch.path.length, 2)).join('.'); + paths.add(entityPath); + } + + return Array.from(paths); +} + +function extractScopedAffectedPaths(store: AnyStore, patches: Patch[]): string[] { + const storeId = getStoreId(store); + return extractAffectedPaths(patches).map((path) => `${storeId}:${path}`); +} + +export function hasPathConflict(pathsA: string[], pathsB: string[]): boolean { + for (const pathA of pathsA) { + for (const pathB of pathsB) { + if (pathA === pathB || pathA.startsWith(`${pathB}.`) || pathB.startsWith(`${pathA}.`)) { + return true; + } + } + } + + return false; +} + +function toSnapshot(mutation: QueuedMutation): OptimisticMutationSnapshot { + return { + actionName: mutation.actionName, + affectedPaths: mutation.affectedPaths, + id: mutation.id, + maxRetries: mutation.maxRetries, + retryCount: mutation.retryCount, + status: mutation.status, + timestamp: new Date(mutation.timestamp), + }; +} + +class MutationQueue { + private history: OptimisticMutationSnapshot[] = []; + private idleResolvers = new Set<() => void>(); + private inflightIds = new Set(); + private readonly maxHistory: number; + private readonly options: Required; + private queue: QueuedMutation[] = []; + + constructor(options: OptimisticEngineOptions = {}) { + this.maxHistory = options.maxHistory ?? 20; + this.options = { + maxHistory: this.maxHistory, + maxRetries: options.maxRetries ?? 0, + onMutationError: options.onMutationError ?? (() => {}), + onMutationSuccess: options.onMutationSuccess ?? (() => {}), + onQueueChange: options.onQueueChange ?? (() => {}), + }; + } + + private addToHistory(snapshot: OptimisticMutationSnapshot) { + this.history.unshift(snapshot); + if (this.history.length > this.maxHistory) { + this.history = this.history.slice(0, this.maxHistory); + } + } + + private getInflightMutations(): QueuedMutation[] { + return this.queue.filter((mutation) => this.inflightIds.has(mutation.id)); + } + + private hasPendingMutations() { + return this.queue.some( + (mutation) => mutation.status === 'pending' || mutation.status === 'inflight', + ); + } + + private hasInflightConflict(candidate: QueuedMutation): boolean { + for (const inflight of this.getInflightMutations()) { + if (hasPathConflict(candidate.affectedPaths, inflight.affectedPaths)) { + return true; + } + } + + return false; + } + + private notify() { + this.options.onQueueChange([...this.queue.map(toSnapshot), ...this.history]); + + if (!this.hasPendingMutations()) { + for (const resolve of this.idleResolvers) { + resolve(); + } + this.idleResolvers.clear(); + } + } + + private processNext() { + const pending = this.queue + .filter((mutation) => mutation.status === 'pending' && !this.inflightIds.has(mutation.id)) + .sort((a, b) => a.timestamp - b.timestamp); + + for (const mutation of pending) { + if (this.hasInflightConflict(mutation)) continue; + void this.executeMutation(mutation); + } + } + + private async settleFailure(mutation: QueuedMutation, error: unknown) { + mutation.status = 'failed'; + this.inflightIds.delete(mutation.id); + + this.rollback(mutation); + await mutation.onError?.(error); + + const snapshot: OptimisticMutationSnapshot = { + ...toSnapshot(mutation), + status: 'rolled-back', + }; + + this.addToHistory(snapshot); + this.queue = this.queue.filter((item) => item.id !== mutation.id); + this.notify(); + this.options.onMutationError(snapshot, error); + mutation.reject?.(error); + this.processNext(); + } + + enqueue(mutation: Mutation): Promise { + const { onSuccess, remoteFn, resolve: _resolve, ...rest } = mutation; + const queuedMutation: QueuedMutation = { + ...rest, + onSuccess: onSuccess + ? async (result) => { + await onSuccess(result as T); + } + : undefined, + remoteFn: async () => remoteFn(), + }; + + return new Promise((resolve, reject) => { + queuedMutation.resolve = (value) => resolve(value as T); + queuedMutation.reject = reject; + + this.queue.push(queuedMutation); + this.notify(); + this.processNext(); + }); + } + + async flush(): Promise { + this.processNext(); + + if (!this.hasPendingMutations()) return; + + await new Promise((resolve) => { + this.idleResolvers.add(resolve); + }); + } + + private async executeMutation(mutation: QueuedMutation): Promise { + this.inflightIds.add(mutation.id); + mutation.status = 'inflight'; + this.notify(); + + try { + const result = await mutation.remoteFn(); + + await mutation.onSuccess?.(result); + + mutation.status = 'success'; + this.inflightIds.delete(mutation.id); + this.addToHistory(toSnapshot(mutation)); + this.queue = this.queue.filter((item) => item.id !== mutation.id); + this.notify(); + this.options.onMutationSuccess(toSnapshot(mutation)); + mutation.resolve?.(result); + this.processNext(); + } catch (error) { + mutation.retryCount += 1; + + if (mutation.retryCount <= mutation.maxRetries) { + mutation.status = 'pending'; + this.inflightIds.delete(mutation.id); + this.notify(); + this.processNext(); + return; + } + + await this.settleFailure(mutation, error); + } + } + + private rollback(failedMutation: QueuedMutation) { + const allStores = new Set(); + + for (const store of failedMutation.storePatches.keys()) { + allStores.add(store); + } + + const remaining = this.queue + .filter((mutation) => mutation.id !== failedMutation.id && mutation.status !== 'failed') + .sort((a, b) => b.timestamp - a.timestamp); + + for (const mutation of remaining) { + for (const store of mutation.storePatches.keys()) { + allStores.add(store); + } + } + + for (const store of allStores) { + let nextState = store.getState(); + + for (const mutation of remaining) { + const entry = mutation.storePatches.get(store); + if (!entry) continue; + + nextState = applyPatches(nextState, entry.inversePatches); + } + + const failedEntry = failedMutation.storePatches.get(store); + if (failedEntry) { + nextState = applyPatches(nextState, failedEntry.inversePatches); + } + + for (const mutation of [...remaining].reverse()) { + const entry = mutation.storePatches.get(store); + if (!entry) continue; + + nextState = applyPatches(nextState, entry.patches); + } + + store.setState(nextState); + } + } +} + +class Transaction> { + private committed = false; + private readonly defaultStore: AnyStore | null; + private mutationFn: RemoteFn | null = null; + private mutationErrorHandler?: (error: unknown) => void | Promise; + private mutationSuccessHandler?: (result: unknown) => void | Promise; + private readonly name: string; + private readonly enqueueFn: (mutation: Mutation) => Promise; + private readonly maxRetries: number; + private records: SetRecord[] = []; + private workingStates = new Map(); + + constructor( + name: string, + enqueueFn: (mutation: Mutation) => Promise, + maxRetries: number, + defaultStore?: StoreHandle, + ) { + this.defaultStore = defaultStore ? asAnyStore(defaultStore) : null; + this.enqueueFn = enqueueFn; + this.maxRetries = maxRetries; + this.name = name; + } + + set(recipe: Recipe): void; + set(store: StoreHandle, recipe: Recipe, options?: SetOptions): void; + set( + storeOrRecipe: StoreHandle | Recipe, + recipeOrUndefined?: Recipe, + maybeOptions?: SetOptions, + ): void { + let store: AnyStore; + let recipe: Recipe; + let options: SetOptions; + + if (typeof storeOrRecipe === 'function') { + if (!this.defaultStore) { + throw new Error(`[OptimisticEngine] "${this.name}": no default store set`); + } + + store = this.defaultStore; + recipe = storeOrRecipe as unknown as Recipe; + options = {}; + } else { + store = asAnyStore(storeOrRecipe); + recipe = recipeOrUndefined as unknown as Recipe; + options = maybeOptions ?? {}; + } + + if (this.committed) { + throw new Error(`[OptimisticEngine] "${this.name}": cannot call set() after commit()`); + } + + const shouldFlush = options.flush ?? true; + const baseState = this.workingStates.get(store) ?? store.getState(); + const [nextState, patches, inversePatches] = produceWithPatches(baseState, recipe); + + if (patches.length === 0) return; + + if (shouldFlush) { + store.setState(nextState); + this.workingStates.delete(store); + } else { + this.workingStates.set(store, nextState); + } + + this.records.push({ + flushed: shouldFlush, + inversePatches, + patches, + store, + }); + } + + set mutation(fn: RemoteFn) { + this.mutationFn = fn; + } + + set onError(handler: (error: unknown) => void | Promise) { + this.mutationErrorHandler = handler; + } + + set onSuccess(handler: (result: unknown) => void | Promise) { + this.mutationSuccessHandler = handler; + } + + commit(): Promise { + if (this.committed) { + throw new Error(`[OptimisticEngine] "${this.name}": transaction already committed`); + } + + if (!this.mutationFn) { + throw new Error(`[OptimisticEngine] "${this.name}": missing remote mutation`); + } + + this.committed = true; + + for (const record of this.records) { + if (record.flushed) continue; + + const nextState = applyPatches(record.store.getState(), record.patches); + record.store.setState(nextState); + record.flushed = true; + } + + this.workingStates.clear(); + + const storePatches = new Map(); + for (const record of this.records) { + const existing = storePatches.get(record.store); + if (existing) { + existing.patches.push(...record.patches); + existing.inversePatches = [...record.inversePatches, ...existing.inversePatches]; + } else { + storePatches.set(record.store, { + inversePatches: [...record.inversePatches], + patches: [...record.patches], + }); + } + } + + const affectedPaths = Array.from(storePatches.entries()).flatMap(([store, entry]) => + extractScopedAffectedPaths(store, entry.patches), + ); + + return this.enqueueFn({ + actionName: this.name, + affectedPaths, + id: `optimistic-mutation-${++mutationIdCounter}-${Date.now()}`, + maxRetries: this.maxRetries, + onError: this.mutationErrorHandler, + onSuccess: this.mutationSuccessHandler as ((result: T) => void | Promise) | undefined, + remoteFn: this.mutationFn as RemoteFn, + retryCount: 0, + status: 'pending', + storePatches, + timestamp: Date.now(), + }); + } +} + +export class OptimisticEngine> { + private readonly defaultStore?: StoreHandle; + private readonly maxRetries: number; + private readonly queue: MutationQueue; + + constructor(defaultStore?: StoreHandle, options?: OptimisticEngineOptions) { + this.defaultStore = defaultStore; + this.maxRetries = options?.maxRetries ?? 0; + this.queue = new MutationQueue(options); + } + + createTransaction(name: string): Transaction { + return new Transaction( + name, + (mutation: Mutation) => this.queue.enqueue(mutation), + this.maxRetries, + this.defaultStore, + ); + } + + async flush(): Promise { + await this.queue.flush(); + } +} diff --git a/src/types/resource.ts b/src/types/resource.ts index 96043e5583..624e4d920a 100644 --- a/src/types/resource.ts +++ b/src/types/resource.ts @@ -10,6 +10,7 @@ export interface ResourceItem { error?: Error; isPending: boolean; lastSyncAttempt?: Date; + queryKey?: string; retryCount: number; }; @@ -54,23 +55,6 @@ export interface ResourceItem { url?: string; } -/** - * Sync operation queued for background processing - */ -export interface SyncOperation { - id: string; - payload: any; - reject?: (reason?: any) => void; - // Promise resolver for async operations - resolve?: (value?: any) => void; - // Operation ID (sync-{resourceId}-{timestamp}) - resourceId: string; - retryCount: number; - timestamp: Date; - // Resource ID (temp or real) - type: 'create' | 'update' | 'delete' | 'move'; -} - /** * Query parameters for fetching resources */ diff --git a/tests/setup.ts b/tests/setup.ts index 42e1d2d1b8..194ab20fa0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,7 +5,7 @@ import 'fake-indexeddb/auto'; import { theme } from 'antd'; import i18n from 'i18next'; -import { enableMapSet } from 'immer'; +import { enableMapSet, enablePatches } from 'immer'; import React from 'react'; import { vi } from 'vitest'; @@ -16,6 +16,7 @@ import home from '@/locales/default/home'; import oauth from '@/locales/default/oauth'; // Enable Immer MapSet plugin so store code using Map/Set in produce() works in tests +enablePatches(); enableMapSet(); // Global mock for @lobehub/analytics/react to avoid AnalyticsProvider dependency