feat(resource): add select all hint and improve resource explorer selection (#13134)

*  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

* ♻️ refactor resource explorer list view

* refactor: engine

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: checkpoint current workspace updates

* ♻️ 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 <tukon479@gmail.com>
This commit is contained in:
Innei 2026-03-28 11:51:23 +08:00 committed by GitHub
parent f4c4ba7db5
commit 26449e522a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 6089 additions and 3101 deletions

View file

@ -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": "عرض الشبكة",

View file

@ -129,6 +129,7 @@
"title": "الموارد",
"toggleLeftPanel": "إظهار/إخفاء اللوحة الجانبية",
"uploadDock.body.collapse": "طي",
"uploadDock.header.cancelAll": "إلغاء الكل",
"uploadDock.body.item.cancel": "إلغاء",
"uploadDock.body.item.cancelled": "تم الإلغاء",
"uploadDock.body.item.done": "تم التحميل",

View file

@ -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": "Изглед мрежа",

View file

@ -129,6 +129,7 @@
"title": "Ресурси",
"toggleLeftPanel": "Показване/Скриване на ляв панел",
"uploadDock.body.collapse": "Свий",
"uploadDock.header.cancelAll": "Отказ на всички",
"uploadDock.body.item.cancel": "Отказ",
"uploadDock.body.item.cancelled": "Отказано",
"uploadDock.body.item.done": "Качено",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "نمای جدولی",

View file

@ -129,6 +129,7 @@
"title": "منابع",
"toggleLeftPanel": "نمایش/مخفی کردن پنل کناری",
"uploadDock.body.collapse": "جمع کردن",
"uploadDock.header.cancelAll": "لغو همه",
"uploadDock.body.item.cancel": "لغو",
"uploadDock.body.item.cancelled": "لغو شد",
"uploadDock.body.item.done": "بارگذاری شد",

View file

@ -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",

View file

@ -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é",

View file

@ -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",

View file

@ -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",

View file

@ -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": "グリッド表示",

View file

@ -129,6 +129,7 @@
"title": "リソース",
"toggleLeftPanel": "左パネルの表示/非表示を切り替え",
"uploadDock.body.collapse": "折りたたむ",
"uploadDock.header.cancelAll": "すべてキャンセル",
"uploadDock.body.item.cancel": "キャンセル",
"uploadDock.body.item.cancelled": "キャンセルされました",
"uploadDock.body.item.done": "アップロード完了",

View file

@ -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": "그리드 보기",

View file

@ -129,6 +129,7 @@
"title": "리소스",
"toggleLeftPanel": "왼쪽 패널 표시/숨기기",
"uploadDock.body.collapse": "접기",
"uploadDock.header.cancelAll": "모두 취소",
"uploadDock.body.item.cancel": "취소",
"uploadDock.body.item.cancelled": "취소됨",
"uploadDock.body.item.done": "업로드 완료",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Сетка",

View file

@ -129,6 +129,7 @@
"title": "Ресурсы",
"toggleLeftPanel": "Показать/Скрыть левую панель",
"uploadDock.body.collapse": "Свернуть",
"uploadDock.header.cancelAll": "Отменить все",
"uploadDock.body.item.cancel": "Отменить",
"uploadDock.body.item.cancelled": "Отменено",
"uploadDock.body.item.done": "Загружено",

View file

@ -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ü",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "网格视图",

View file

@ -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": "上传出错",

View file

@ -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": "網格檢視",

View file

@ -129,6 +129,7 @@
"title": "資源",
"toggleLeftPanel": "顯示/隱藏左側面板",
"uploadDock.body.collapse": "收起",
"uploadDock.header.cancelAll": "全部取消",
"uploadDock.body.item.cancel": "取消",
"uploadDock.body.item.cancelled": "已取消",
"uploadDock.body.item.done": "已上傳",

View file

@ -11,8 +11,10 @@ export interface KnowledgeItem {
chunkTaskId?: string | null;
content?: string | null;
createdAt: Date;
documentId?: string | null;
editorData?: Record<string, any> | null;
embeddingTaskId?: string | null;
fileId?: string | null;
fileType: string;
id: string;
metadata?: Record<string, any> | 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,

View file

@ -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;

View file

@ -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<AuthThemeLiteProps>(({ children, globalCDN }) => {
<App style={{ height: '100%' }}>
<AntdStaticMethods />
<ConfigProvider
motion={motion}
motion={m}
config={{
aAs: Link,
imgAs: Image,

View file

@ -1,4 +1,4 @@
import { AnimatePresence, m as motion } from 'motion/react';
import { AnimatePresence, m } from 'motion/react';
import { type CSSProperties, type ReactNode } from 'react';
import { memo } from 'react';
@ -25,7 +25,7 @@ const AnimatedCollapsed = memo<AnimatedCollapsedProps>(
return (
<AnimatePresence initial={false}>
{open && (
<motion.div
<m.div
animate="open"
exit="collapsed"
initial="collapsed"
@ -50,7 +50,7 @@ const AnimatedCollapsed = memo<AnimatedCollapsedProps>(
}}
>
{children}
</motion.div>
</m.div>
)}
</AnimatePresence>
);

View file

@ -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<GroundingSearch>(
<AnimatePresence initial={false}>
{showDetail && (
<motion.div
<m.div
animate="open"
exit="collapsed"
initial="collapsed"
@ -292,7 +292,7 @@ const SearchGrounding = memo<GroundingSearch>(
</div>
)}
</Flexbox>
</motion.div>
</m.div>
)}
</AnimatePresence>
</Flexbox>

View file

@ -11,91 +11,96 @@ interface CreateFormProps {
fileIds: string[];
knowledgeBaseId?: string;
onClose?: () => void;
resolveFileIds?: () => Promise<string[]>;
selectedCount?: number;
}
const SelectForm = memo<CreateFormProps>(({ onClose, knowledgeBaseId, fileIds }) => {
const { t } = useTranslation('knowledgeBase');
const [loading, setLoading] = useState(false);
const SelectForm = memo<CreateFormProps>(
({ 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: (
<Trans
i18nKey={'addToKnowledgeBase.addSuccess'}
ns={'knowledgeBase'}
components={[
<span key="0" />,
<Link key="1" to={`/knowledge/library/${values.id}`} />,
]}
/>
),
});
onClose?.();
} catch (e) {
console.error(e);
setLoading(false);
}
};
return (
<Form
gap={16}
itemsType={'flat'}
layout={'vertical'}
footer={
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
{t('addToKnowledgeBase.confirm')}
</Button>
}
items={[
{
children: (
<Block horizontal align={'center'} gap={8} padding={16} variant={'filled'}>
<MaterialFileTypeIcon filename={''} size={32} />
{t('addToKnowledgeBase.totalFiles', { count: fileIds.length })}
</Block>
),
noStyle: true,
},
{
children: (
<Select
autoFocus
loading={isLoading}
placeholder={t('addToKnowledgeBase.id.placeholder')}
options={(data || [])
.filter((item) => item.id !== knowledgeBaseId)
.map((item) => ({
label: (
<Flexbox horizontal gap={8}>
<RepoIcon />
{item.name}
</Flexbox>
),
value: item.id,
}))}
try {
const effectiveFileIds = resolveFileIds ? await resolveFileIds() : fileIds;
await addFilesToKnowledgeBase(values.id, effectiveFileIds);
setLoading(false);
message.success({
content: (
<Trans
i18nKey={'addToKnowledgeBase.addSuccess'}
ns={'knowledgeBase'}
components={[
<span key="0" />,
<Link key="1" to={`/knowledge/library/${values.id}`} />,
]}
/>
),
label: t('addToKnowledgeBase.id.title'),
name: 'id',
rules: [{ message: t('addToKnowledgeBase.id.required'), required: true }],
},
]}
onFinish={onFinish}
/>
);
});
});
onClose?.();
} catch (e) {
console.error(e);
setLoading(false);
}
};
return (
<Form
gap={16}
itemsType={'flat'}
layout={'vertical'}
footer={
<Button block htmlType={'submit'} loading={loading} type={'primary'}>
{t('addToKnowledgeBase.confirm')}
</Button>
}
items={[
{
children: (
<Block horizontal align={'center'} gap={8} padding={16} variant={'filled'}>
<MaterialFileTypeIcon filename={''} size={32} />
{t('addToKnowledgeBase.totalFiles', { count: selectedCount ?? fileIds.length })}
</Block>
),
noStyle: true,
},
{
children: (
<Select
autoFocus
loading={isLoading}
placeholder={t('addToKnowledgeBase.id.placeholder')}
options={(data || [])
.filter((item) => item.id !== knowledgeBaseId)
.map((item) => ({
label: (
<Flexbox horizontal gap={8}>
<RepoIcon />
{item.name}
</Flexbox>
),
value: item.id,
}))}
/>
),
label: t('addToKnowledgeBase.id.title'),
name: 'id',
rules: [{ message: t('addToKnowledgeBase.id.required'), required: true }],
},
]}
onFinish={onFinish}
/>
);
},
);
export default SelectForm;

View file

@ -9,28 +9,46 @@ interface AddFilesToKnowledgeBaseModalProps {
fileIds: string[];
knowledgeBaseId?: string;
onClose?: () => void;
resolveFileIds?: () => Promise<string[]>;
selectedCount?: number;
}
interface ModalContentProps {
fileIds: string[];
knowledgeBaseId?: string;
resolveFileIds?: () => Promise<string[]>;
selectedCount?: number;
}
const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId }) => {
const { t } = useTranslation('knowledgeBase');
const { close } = useModalContext();
return (
<>
<Flexbox horizontal gap={8} paddingBlock={16} paddingInline={16} style={{ paddingBottom: 0 }}>
<Icon icon={BookUp2Icon} />
{t('addToKnowledgeBase.title')}
</Flexbox>
<Flexbox padding={16} style={{ paddingTop: 0 }}>
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={close} />
</Flexbox>
</>
);
});
const ModalContent = memo<ModalContentProps>(
({ fileIds, knowledgeBaseId, resolveFileIds, selectedCount }) => {
const { t } = useTranslation('knowledgeBase');
const { close } = useModalContext();
return (
<>
<Flexbox
horizontal
gap={8}
paddingBlock={16}
paddingInline={16}
style={{ paddingBottom: 0 }}
>
<Icon icon={BookUp2Icon} />
{t('addToKnowledgeBase.title')}
</Flexbox>
<Flexbox padding={16} style={{ paddingTop: 0 }}>
<SelectForm
fileIds={fileIds}
knowledgeBaseId={knowledgeBaseId}
resolveFileIds={resolveFileIds}
selectedCount={selectedCount}
onClose={close}
/>
</Flexbox>
</>
);
},
);
ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent';
@ -40,7 +58,12 @@ export const useAddFilesToKnowledgeBaseModal = () => {
afterClose: params?.onClose,
children: (
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
<ModalContent fileIds={params?.fileIds || []} knowledgeBaseId={params?.knowledgeBaseId} />
<ModalContent
fileIds={params?.fileIds || []}
knowledgeBaseId={params?.knowledgeBaseId}
resolveFileIds={params?.resolveFileIds}
selectedCount={params?.selectedCount}
/>
</Suspense>
),
footer: null,

View file

@ -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 (
<Flexbox gap={8}>
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
initial={{ height: 0, opacity: 0 }}
style={{ overflow: 'hidden' }}
@ -77,7 +77,7 @@ const ErrorDetails = memo<{
</div>
)}
</Flexbox>
</motion.div>
</m.div>
</Flexbox>
);
});

View file

@ -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<MCPConfigFormProps>(({ configSchema, identifier, onCa
};
return (
<motion.div
<m.div
animate={{ y: 0 }}
className={styles.container}
initial={{ y: 8 }}
transition={{ delay: 0.1, duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 4 }}
transition={{ delay: 0.15, duration: 0.2 }}
@ -88,9 +88,9 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
{t('mcpInstall.configurationDescription')}
</span>
</Flexbox>
</motion.div>
</m.div>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 4 }}
transition={{ delay: 0.2, duration: 0.2 }}
@ -127,9 +127,9 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
}))}
onFinish={handleSubmit}
/>
</motion.div>
</m.div>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
className={styles.footer}
initial={{ opacity: 0, y: 4 }}
@ -141,8 +141,8 @@ const MCPConfigForm = memo<MCPConfigFormProps>(({ configSchema, identifier, onCa
<Button loading={loading} size="small" type="primary" onClick={() => form.submit()}>
{t('mcpInstall.continueInstall')}
</Button>
</motion.div>
</motion.div>
</m.div>
</m.div>
);
});

View file

@ -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<MCPDependenciesGuideProps>(
};
return (
<motion.div
<m.div
animate={{ y: 0 }}
className={styles.container}
initial={{ y: 8 }}
transition={{ delay: 0.1, duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 4 }}
style={{ marginBottom: 8 }}
@ -115,9 +115,9 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
{t('mcpInstall.dependenciesDescription')}
</Text>
</Flexbox>
</motion.div>
</m.div>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 4 }}
transition={{ delay: 0.2, duration: 0.2 }}
@ -185,9 +185,9 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
</Card>
))}
</Flexbox>
</motion.div>
</m.div>
<motion.div
<m.div
animate={{ opacity: 1, y: 0 }}
className={styles.footer}
initial={{ opacity: 0, y: 4 }}
@ -206,8 +206,8 @@ const MCPDependenciesGuide = memo<MCPDependenciesGuideProps>(
</Button>
</Space>
</Flexbox>
</motion.div>
</motion.div>
</m.div>
</m.div>
);
},
);

View file

@ -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 && (
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
@ -54,11 +54,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
</Text>
)}
</Flexbox>
</motion.div>
</m.div>
)}
{hasError && errorInfo && (
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
@ -71,11 +71,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
<Flexbox paddingBlock={8}>
<InstallError errorInfo={errorInfo} identifier={identifier} />
</Flexbox>
</motion.div>
</m.div>
)}
{needsDependencies && installProgress?.systemDependencies && (
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
@ -91,11 +91,11 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
systemDependencies={installProgress.systemDependencies}
/>
</Flexbox>
</motion.div>
</m.div>
)}
{needsConfig && installProgress && (
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
@ -106,7 +106,7 @@ const MCPInstallProgress = memo<{ identifier: string }>(({ identifier }) => {
}}
>
<MCPConfigForm configSchema={installProgress.configSchema} identifier={identifier} />
</motion.div>
</m.div>
)}
</>
);

View file

@ -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<NavPanelDraggableProps>(({ activeContent }
<div className={draggableStyles.inner}>
{shouldUseMotion ? (
<AnimatePresence custom={motionDirection} initial={false} mode="sync">
<motion.div
<m.div
animate="animate"
className={draggableStyles.layer}
custom={motionDirection}
@ -238,7 +238,7 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
variants={motionVariants}
>
<ExitingFrozenContent>{activeContent.node}</ExitingFrozenContent>
</motion.div>
</m.div>
</AnimatePresence>
) : (
<div className={draggableStyles.layer} key={activeContent.key}>

View file

@ -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<Parameters<typeof pageAgentRuntime.setEditor>[0]>;
export interface StoreUpdaterProps extends Partial<PublicState> {
pageId?: string;
}
@ -37,6 +39,7 @@ const StoreUpdater = memo<StoreUpdaterProps>(
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<StoreUpdaterProps>(
// 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(() => {

View file

@ -100,11 +100,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
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<PageExplorerPlaceholderProps>(
event: React.ChangeEvent<HTMLInputElement>,
) => {
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) => {

View file

@ -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 ? (
<Flexbox horizontal align={'center'} gap={8} style={{ marginLeft: 0 }}>
{libraryId ? (
<ActionIcon
@ -79,7 +88,12 @@ const Header = memo(() => {
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 },
),
});
}}
/>

View file

@ -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,

View file

@ -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) => (
<Flexbox
horizontal
align={'center'}
gap={8}
paddingInline={8}
onClick={stopPropagation}
onPointerDown={stopPropagation}
>
{!isFolder &&
!isPage &&
(isCreatingFileParseTask || isNull(chunkingStatus) || !chunkingStatus ? (
<div
className={isCreatingFileParseTask ? undefined : styles.hover}
title={t(
isSupportedForChunking
? 'FileManager.actions.chunkingTooltip'
: 'FileManager.actions.chunkingUnsupported',
)}
>
<Button
disabled={!isSupportedForChunking}
icon={FileBoxIcon}
loading={isCreatingFileParseTask}
size={'small'}
type={'text'}
onClick={() => {
parseFiles([id]);
}}
>
{t(
isCreatingFileParseTask
? 'FileManager.actions.createChunkingTask'
: 'FileManager.actions.chunking',
)}
</Button>
</div>
) : (
<div style={{ cursor: 'default' }}>
<ChunksBadge
chunkCount={chunkCount}
chunkingError={chunkingError}
chunkingStatus={chunkingStatus as any}
embeddingError={embeddingError}
embeddingStatus={embeddingStatus as any}
finishEmbedding={finishEmbedding}
id={id}
/>
</div>
))}
<DropdownMenu className={styles.hover} items={menuItems} />
</Flexbox>
);
export default FileListItemActions;

View file

@ -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) => (
<Flexbox horizontal align={'center'} className={styles.nameContainer}>
<Flexbox
align={'center'}
justify={'center'}
style={{ fontSize: 24, marginInline: 8, width: 24 }}
>
{isFolder ? (
<Icon icon={FolderIcon} size={24} />
) : isPage ? (
emoji ? (
<span style={{ fontSize: 24 }}>{emoji}</span>
) : (
<Center height={24} width={24}>
<Icon icon={FileText} size={24} />
</Center>
)
) : (
<FileIcon fileName={name} fileType={fileType} size={24} />
)}
</Flexbox>
{isRenaming && isFolder ? (
<Input
ref={inputRef}
size="small"
style={{ flex: 1, maxWidth: 400 }}
value={renamingValue}
onBlur={onRenameConfirm}
onChange={(e) => 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();
}
}}
/>
) : (
<TruncatedFileName className={styles.name} name={name || fallbackName} />
)}
</Flexbox>
);
export default FileListItemName;

View file

@ -0,0 +1,2 @@
export const FILE_DATE_WIDTH = 160;
export const FILE_SIZE_WIDTH = 140;

View file

@ -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<FileListItemProps>(
({
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<any>(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 (
<ContextMenuTrigger items={menuItems}>
return (
<ContextMenuTrigger items={menuItems}>
<Flexbox
horizontal
align={'center'}
data-drop-target-id={id}
data-is-folder={String(isFolder)}
data-row-index={index}
draggable={!!resourceManagerState.libraryId}
height={48}
paddingInline={8}
className={cx(
styles.container,
'file-list-item-group',
index % 2 === 0 && styles.evenRow,
isSelected && styles.selected,
isDragging && styles.dragging,
isOver && styles.dragOver,
)}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
userSelect: 'none',
}}
onClick={handleItemClick}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
>
<Center
height={40}
style={{ paddingInline: 4 }}
onClick={handleCheckboxClick}
onPointerDown={handleCheckboxPointerDown}
>
<Checkbox checked={isSelected} />
</Center>
<Flexbox
horizontal
align={'center'}
data-drop-target-id={id}
data-is-folder={String(isFolder)}
data-row-index={index}
draggable={!!resourceManagerState.libraryId}
height={48}
paddingInline={8}
className={cx(
styles.container,
'file-list-item-group',
index % 2 === 0 && styles.evenRow,
selected && styles.selected,
isDragging && styles.dragging,
isOver && styles.dragOver,
)}
className={styles.item}
distribution={'space-between'}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
userSelect: 'none',
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 8,
width: columnWidths.name,
}}
onClick={handleItemClick}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
onDrop={handleDrop}
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
>
<Center
height={40}
style={{ paddingInline: 4 }}
onClick={(e) => {
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();
}
}}
>
<Checkbox checked={selected} />
</Center>
<Flexbox
horizontal
align={'center'}
className={styles.item}
distribution={'space-between'}
style={{
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 8,
width: columnWidths.name,
}}
>
<Flexbox horizontal align={'center'} className={styles.nameContainer}>
<Flexbox
align={'center'}
justify={'center'}
style={{ fontSize: 24, marginInline: 8, width: 24 }}
>
{isFolder ? (
<Icon icon={FolderIcon} size={24} />
) : isPage ? (
emoji ? (
<span style={{ fontSize: 24 }}>{emoji}</span>
) : (
<Center height={24} width={24}>
<Icon icon={FileText} size={24} />
</Center>
)
) : (
<FileIcon fileName={name} fileType={fileType} size={24} />
)}
</Flexbox>
{isRenaming && isFolder ? (
<Input
ref={inputRef}
size="small"
style={{ flex: 1, maxWidth: 400 }}
value={renamingValue}
onBlur={handleRenameConfirm}
onChange={(e) => 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();
}
}}
/>
) : (
<TruncatedFileName
className={styles.name}
name={name || t('file:pageList.untitled')}
/>
)}
</Flexbox>
<Flexbox
horizontal
align={'center'}
gap={8}
paddingInline={8}
onClick={stopPropagation}
onPointerDown={stopPropagation}
>
{!isFolder &&
!isPage &&
(fileStoreState.isCreatingFileParseTask ||
isNull(chunkingStatus) ||
!chunkingStatus ? (
<div
className={fileStoreState.isCreatingFileParseTask ? undefined : styles.hover}
title={t(
isSupportedForChunking
? 'FileManager.actions.chunkingTooltip'
: 'FileManager.actions.chunkingUnsupported',
)}
>
<Button
disabled={!isSupportedForChunking}
icon={FileBoxIcon}
loading={fileStoreState.isCreatingFileParseTask}
size={'small'}
type={'text'}
onClick={() => {
fileStoreState.parseFiles([id]);
}}
>
{t(
fileStoreState.isCreatingFileParseTask
? 'FileManager.actions.createChunkingTask'
: 'FileManager.actions.chunking',
)}
</Button>
</div>
) : (
<div style={{ cursor: 'default' }}>
<ChunksBadge
chunkCount={chunkCount}
chunkingError={chunkingError}
chunkingStatus={chunkingStatus}
embeddingError={embeddingError}
embeddingStatus={embeddingStatus}
finishEmbedding={finishEmbedding}
id={id}
/>
</div>
))}
<DropdownMenu className={styles.hover} items={menuItems} />
</Flexbox>
</Flexbox>
{!isDragging && (
<>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.date}>
{displayTime}
</Flexbox>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.size}>
{isFolder || isPage ? '-' : formatSize(size)}
</Flexbox>
</>
)}
<FileListItemName
emoji={emoji}
fallbackName={t('file:pageList.untitled')}
fileType={fileType}
inputRef={inputRef}
isFolder={isFolder}
isPage={isPage}
isRenaming={isRenaming}
name={name}
renamingValue={renamingValue}
onRenameCancel={handleRenameCancel}
onRenameConfirm={handleRenameConfirm}
onRenamingValueChange={setRenamingValue}
/>
<FileListItemActions
chunkCount={chunkCount}
chunkingError={chunkingError}
chunkingStatus={chunkingStatus}
embeddingError={embeddingError}
embeddingStatus={embeddingStatus}
finishEmbedding={finishEmbedding}
id={id}
isCreatingFileParseTask={fileStoreState.isCreatingFileParseTask}
isFolder={isFolder}
isPage={isPage}
isSupportedForChunking={isSupportedForChunking}
menuItems={menuItems}
parseFiles={fileStoreState.parseFiles}
t={t}
/>
</Flexbox>
</ContextMenuTrigger>
);
},
// 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 && (
<>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.date}>
{displayTime}
</Flexbox>
<Flexbox className={styles.item} style={{ flexShrink: 0 }} width={columnWidths.size}>
{isFolder || isPage ? '-' : formatSize(size)}
</Flexbox>
</>
)}
</Flexbox>
</ContextMenuTrigger>
);
};
FileListItem.displayName = 'FileListItem';
export default FileListItem;
export default memo(FileListItem);

View file

@ -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};
}
}
`,
}));

View file

@ -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,
};
};

View file

@ -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]);

View file

@ -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<void>;
setPendingRenameItemId: (id: string | null) => void;
updateResource: (id: string, payload: { name: string }) => Promise<unknown>;
}
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<any>(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,
};
};

View file

@ -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<VirtuosoHandle | null>;
}
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 (
<div
data-drop-target-id={currentFolderId || undefined}
data-is-folder="true"
ref={containerRef}
className={cx(
localStyles.container,
'list-view-drop-zone',
styles.dropZone,
isDropZoneActive && styles.dropZoneActive,
)}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
</div>
);
};
export default ListViewDropZone;

View file

@ -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 (
<>
<Flexbox
horizontal
align={'center'}
className={styles.header}
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 12,
}}
>
<Center height={40} style={{ paddingInline: 4 }}>
<Checkbox checked={allSelected} indeterminate={indeterminate} onChange={handleSelectAll} />
</Center>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 20,
paddingInlineEnd: 16,
position: 'relative',
width: columnWidths.name,
}}
>
{selectedCount > 0 || selectAllState === 'all'
? t(selectedLabelKey, {
count: selectedCount,
ns: 'components',
})
: t('FileManager.title.title')}
<ColumnResizeHandle
column="name"
currentWidth={columnWidths.name}
maxWidth={1200}
minWidth={200}
onResize={(width) => updateColumnWidth('name', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.date}
>
{t('FileManager.title.createdAt')}
<ColumnResizeHandle
column="date"
currentWidth={columnWidths.date}
maxWidth={300}
minWidth={120}
onResize={(width) => updateColumnWidth('date', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.size}
>
{t('FileManager.title.size')}
<ColumnResizeHandle
column="size"
currentWidth={columnWidths.size}
maxWidth={200}
minWidth={80}
onResize={(width) => updateColumnWidth('size', width)}
/>
</Flexbox>
</Flexbox>
<ListViewSelectAllHint
dataLength={data.length}
selectAllState={selectAllState}
selectedCount={selectedCount}
showSelectAllHint={showSelectAllHint}
total={total}
onSelectAllResources={handleSelectAllResources}
/>
</>
);
};
export default ListViewHeader;

View file

@ -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 (
<Flexbox horizontal align={'center'} className={styles.selectAllHint} gap={6} wrap={'wrap'}>
<span>
{t(
selectAllState === 'all'
? total
? isAllResultsSelected
? 'FileManager.total.allSelectedCount'
: 'FileManager.total.selectedCount'
: 'FileManager.total.allSelectedFallback'
: 'FileManager.total.loadedSelectedCount',
{
count: selectedCount,
},
)}
</span>
{selectAllState !== 'all' && (
<Button size={'small'} type={'link'} onClick={onSelectAllResources}>
{total && total > dataLength
? t('FileManager.total.selectAll', {
count: total,
})
: t('FileManager.total.selectAllFallback')}
</Button>
)}
</Flexbox>
);
};
export default ListViewSelectAllHint;

View file

@ -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?: {

View file

@ -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<VirtuosoHandle | null>;
}
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<FileListItem[]>(data);
const lastSelectedIndexRef = useRef<number | null>(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 (
<Virtuoso
components={{ Footer }}
data={data}
defaultItemHeight={48}
endReached={handleEndReached}
increaseViewportBy={{ bottom: 800, top: 1200 }}
initialItemCount={30}
overscan={48 * 5}
ref={virtuosoRef}
style={{ height: 'calc(100vh - 100px)' }}
itemContent={useCallback(
(index: number, item: FileListItem) => {
if (!item) return null;
return (
<FileListItemComponent
columnWidths={columnWidths}
index={index}
key={item.id}
selected={isExplorerItemSelected({
id: item.id,
selectAllState,
selectedIds: selectedFileIds,
})}
onSelectedChange={handleSelectionChange}
{...item}
/>
);
},
[columnWidths, handleSelectionChange, selectAllState, selectedFileIds],
)}
/>
);
};
export default VirtualizedFileList;

View file

@ -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<VirtuosoHandle>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const isDragActive = useDragActive();
const [isDropZoneActive, setIsDropZoneActive] = useState(false);
const [isAnyRowHovered, setIsAnyRowHovered] = useState(false);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(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<FileListItemType>((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<FileListItemType[]>(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<HTMLDivElement>) => {
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 <ListViewSkeleton columnWidths={columnWidths} />;
// 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 <div aria-hidden style={{ height: 96 }} />;
return null;
}, [columnWidths, dataLength, hasMore, isLoadingMore]);
const { columnWidths, currentFolderId, data, hasMore, showSkeleton } = useExplorerListData({
isLoading,
isValidating,
queryParams,
});
if (showSkeleton) return <ListViewSkeleton columnWidths={columnWidths} />;
return (
<Flexbox height={'100%'}>
<div className={styles.scrollContainer}>
<Flexbox
horizontal
align={'center'}
className={styles.header}
paddingInline={8}
style={{
borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 12,
}}
>
<Center height={40} style={{ paddingInline: 4 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={handleSelectAll}
/>
</Center>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{
flexShrink: 0,
maxWidth: columnWidths.name,
minWidth: columnWidths.name,
paddingInline: 20,
paddingInlineEnd: 16,
position: 'relative',
width: columnWidths.name,
}}
>
{selectFileIds.length > 0
? t('FileManager.total.selectedCount', {
count: selectFileIds.length,
ns: 'components',
})
: t('FileManager.title.title')}
<ColumnResizeHandle
column="name"
currentWidth={columnWidths.name}
maxWidth={1200}
minWidth={200}
onResize={(width) => updateColumnWidth('name', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.date}
>
{t('FileManager.title.createdAt')}
<ColumnResizeHandle
column="date"
currentWidth={columnWidths.date}
maxWidth={300}
minWidth={120}
onResize={(width) => updateColumnWidth('date', width)}
/>
</Flexbox>
<Flexbox
className={styles.headerItem}
justify={'center'}
style={{ flexShrink: 0, paddingInlineEnd: 16, position: 'relative' }}
width={columnWidths.size}
>
{t('FileManager.title.size')}
<ColumnResizeHandle
column="size"
currentWidth={columnWidths.size}
maxWidth={200}
minWidth={80}
onResize={(width) => updateColumnWidth('size', width)}
/>
</Flexbox>
</Flexbox>
<div
data-drop-target-id={currentFolderId || undefined}
data-is-folder="true"
ref={containerRef}
style={{ overflow: 'hidden', position: 'relative' }}
className={cx(
styles.dropZone,
isDropZoneActive && styles.dropZoneActive,
isAnyRowHovered && 'any-row-hovered',
)}
onDragLeave={handleDropZoneDragLeave}
onDrop={handleDropZoneDrop}
onDragOver={(e) => {
handleDropZoneDragOver(e);
handleDragMove(e);
}}
>
<Virtuoso
components={{ Footer }}
<ListViewHeader columnWidths={columnWidths} data={data} hasMore={hasMore} />
<ListViewDropZone currentFolderId={currentFolderId} virtuosoRef={virtuosoRef}>
<VirtualizedFileList
columnWidths={columnWidths}
data={data}
defaultItemHeight={48}
endReached={handleEndReached}
increaseViewportBy={{ bottom: 800, top: 1200 }}
initialItemCount={30}
overscan={48 * 5}
ref={virtuosoRef}
style={{ height: 'calc(100vh - 100px)' }}
itemContent={(index, item) => {
if (!item) return null;
return (
<FileListItem
columnWidths={columnWidths}
index={index}
isAnyRowHovered={isAnyRowHovered}
key={item.id}
pendingRenameItemId={pendingRenameItemId}
selected={selectFileIds.includes(item.id)}
onHoverChange={setIsAnyRowHovered}
onSelectedChange={handleSelectionChange}
{...item}
/>
);
}}
hasMore={hasMore}
virtuosoRef={virtuosoRef}
/>
</div>
</ListViewDropZone>
</div>
</Flexbox>
);
});
};
export default ListView;

View file

@ -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};
`,
}));

View file

@ -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<VirtuosoHandle | null>) => {
const isDragActive = useDragActive();
const [isDropZoneActive, setIsDropZoneActive] = useState(false);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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,
};
};

View file

@ -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 <ListViewSkeleton columnWidths={columnWidths} />;
if (hasMore === false && dataLength > 0) return <div aria-hidden style={{ height: 96 }} />;
return null;
}, [columnWidths, dataLength, hasMore, isLoadingMore]);
return {
Footer,
handleEndReached,
};
};

View file

@ -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<FileListItem>((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,
};
};

View file

@ -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<MasonryItemWrapperProps>(({ data: item, context
<div style={{ padding: '8px 4px' }}>
<MasonryFileItem
knowledgeBaseId={context.knowledgeBaseId}
selected={context.selectFileIds.includes(item.id)}
onSelectedChange={(id, checked) => {
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}
/>
</div>

View file

@ -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<MasonryFileItemProps>(
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
const isDragActive = useDragActive();
const { setCurrentDrag } = useDragState();
const setCurrentDrag = useSetCurrentDrag();
const [isDragging, setIsDragging] = useState(false);
const [isOver, setIsOver] = useState(false);

View file

@ -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}
>
<div style={{ paddingBlockEnd: 24, paddingBlockStart: 12, paddingInline: 24 }}>
<Flexbox horizontal align={'center'} className={styles.toolbar} gap={8}>
<Checkbox checked={allSelected} indeterminate={indeterminate} onChange={handleSelectAll} />
<span>
{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',
})}
</span>
</Flexbox>
{showSelectAllHint && (
<Flexbox
horizontal
align={'center'}
className={styles.selectAllHint}
gap={6}
paddingInline={4}
wrap={'wrap'}
>
<span>
{t(
selectAllState === 'all'
? total
? isAllResultsSelected
? 'FileManager.total.allSelectedCount'
: 'FileManager.total.selectedCount'
: 'FileManager.total.allSelectedFallback'
: 'FileManager.total.loadedSelectedCount',
{
count: selectedCount,
ns: 'components',
},
)}
</span>
{selectAllState !== 'all' && (
<Button size={'small'} type={'link'} onClick={handleSelectAllResources}>
{total && total > dataLength
? t('FileManager.total.selectAll', {
count: total,
ns: 'components',
})
: t('FileManager.total.selectAllFallback', {
ns: 'components',
})}
</Button>
)}
</Flexbox>
)}
<VirtuosoMasonry
ItemContent={MasonryItemWrapper}
columnCount={columnCount}

View file

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import type { ViewMode } from '@/routes/(main)/resource/features/store/initialState';
interface UseMasonryViewStateOptions {
dataLength: number;
isLoading: boolean;
isNavigating: boolean;
isValidating: boolean;
viewMode: ViewMode;
}
export const useMasonryViewState = ({
dataLength,
isLoading,
isNavigating,
isValidating,
viewMode: _viewMode,
}: UseMasonryViewStateOptions) => {
const showSkeleton = useMemo(
() => (isLoading && dataLength === 0) || (isNavigating && isValidating),
[dataLength, isLoading, isNavigating, isValidating],
);
const isMasonryReady = !showSkeleton;
return {
isMasonryReady,
showSkeleton,
};
};

View file

@ -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(() => {
<FileListItemComponent
columnWidths={columnWidths}
index={index}
isAnyRowHovered={false}
key={item.id}
selected={selectedFileIds.includes(item.id)}
onHoverChange={() => {}}
onSelectedChange={(id, checked) => {
if (checked) {
setSelectedFileIds((prev) => [...prev, id]);

View file

@ -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<BatchActionsDropdownProps>(({ 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<DropdownItem[]>(() => {
const items: DropdownItem[] = [];
@ -70,7 +69,7 @@ const BatchActionsDropdown = memo<BatchActionsDropdownProps>(({ 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<BatchActionsDropdownProps>(({ selectCount, onA
label: <span style={{ marginLeft: 8 }}>{kb.name}</span>,
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<BatchActionsDropdownProps>(({ selectCount, onA
}, [
libraryId,
selectCount,
selectedFileIds,
selectAllState,
onActionClick,
addFilesToKnowledgeBase,
resolveSelectedResourceIds,
t,
modal,
message,

View file

@ -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'],
});
});
});

View file

@ -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,
};
};

View file

@ -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<number | null>(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,
};
};

View file

@ -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: [],
});
});
});

View file

@ -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]);
};

View file

@ -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 (
<Flexbox height={'100%'}>
<Header />
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{showEmptyStatus ? (
<EmptyPlaceholder />
) : viewMode === 'list' ? (
<ListView />
) : (
<MasonryView />
)}
<SearchResultsOverlay />
</div>
</Flexbox>
<KnowledgeBaseListProvider>
<Flexbox height={'100%'}>
<Header />
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{showEmptyStatus ? (
<EmptyPlaceholder />
) : viewMode === 'list' ? (
<ListView isLoading={isLoading} isValidating={isValidating} queryParams={queryParams} />
) : (
<MasonryView
isLoading={isLoading}
isValidating={isValidating}
queryParams={queryParams}
/>
)}
<SearchResultsOverlay />
</div>
</Flexbox>
</KnowledgeBaseListProvider>
);
});

View file

@ -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]);
};

View file

@ -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,
};
};

View file

@ -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<FolderTreeItemProps>(
style={{ paddingInlineStart: level * 16 + 8 }}
onClick={handleClick}
>
<motion.div
<m.div
animate={{ rotate: isExpanded ? 0 : -90 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
@ -101,7 +101,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
handleToggle();
}}
/>
</motion.div>
</m.div>
<Flexbox
horizontal
align={'center'}
@ -126,7 +126,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
</Flexbox>
{isExpanded && item.children && item.children.length > 0 && (
<motion.div
<m.div
animate={{ height: 'auto', opacity: 1 }}
initial={{ height: 0, opacity: 0 }}
style={{ overflow: 'hidden' }}
@ -147,7 +147,7 @@ export const FolderTreeItemComponent = memo<FolderTreeItemProps>(
/>
))}
</Flexbox>
</motion.div>
</m.div>
)}
</Flexbox>
);

View file

@ -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,
});

View file

@ -12,7 +12,7 @@ interface UseNotionImportOptions {
createDocument: DocumentAction['createDocument'];
currentFolderId?: string | null;
libraryId?: string | null;
refetchResources: () => Promise<void>;
refetchResources?: () => Promise<void>;
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');

View file

@ -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<KnowledgeBaseItem[] | null>(null);
export const KnowledgeBaseListProvider = memo<PropsWithChildren>(({ children }) => {
const useFetchKnowledgeBaseList = useKnowledgeBaseStore((s) => s.useFetchKnowledgeBaseList);
const { data } = useFetchKnowledgeBaseList();
const knowledgeBases = useMemo(() => data ?? [], [data]);
return (
<KnowledgeBaseListContext value={knowledgeBases}>
{children}
</KnowledgeBaseListContext>
);
});
KnowledgeBaseListProvider.displayName = 'KnowledgeBaseListProvider';
export const useKnowledgeBaseListContext = () => {
const context = use(KnowledgeBaseListContext);
if (!context) {
throw new Error('useKnowledgeBaseListContext must be used within KnowledgeBaseListProvider');
}
return context;
};

View file

@ -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<HierarchyNodeProps>(
});
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<HierarchyNodeProps>(
{isLoading ? (
<ActionIcon spin icon={LoadingOutlined as any} size={'small'} style={{ width: 20 }} />
) : (
<motion.div
<m.div
animate={{ rotate: isExpanded ? 0 : -90 }}
initial={false}
transition={{ duration: 0.2, ease: 'easeInOut' }}
@ -278,7 +278,7 @@ export const HierarchyNode = memo<HierarchyNodeProps>(
handleToggle();
}}
/>
</motion.div>
</m.div>
)}
<Flexbox
horizontal

View file

@ -10,6 +10,7 @@ import { fileService } from '@/services/file';
import { useFileStore } from '@/store/file';
import { type ResourceQueryParams } from '@/types/resource';
import { KnowledgeBaseListProvider } from '../KnowledgeBaseListProvider';
import { HierarchyNode } from './HierarchyNode';
import TreeSkeleton from './TreeSkeleton';
import {
@ -368,28 +369,30 @@ const LibraryHierarchy = memo(() => {
: currentFolderSlug;
return (
<Flexbox paddingInline={4} style={{ height: '100%' }}>
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
style={{ height: '100%' }}
>
{visibleNodes.map(({ item, key, level }) => (
<div key={key} style={{ paddingBottom: 2 }}>
<HierarchyNode
expandedFolders={expandedFolders}
folderChildrenCache={folderChildrenCache}
item={item}
level={level}
loadingFolders={loadingFolders}
selectedKey={selectedKey}
updateKey={updateKey}
onLoadFolder={handleLoadFolder}
onToggleFolder={handleToggleFolder}
/>
</div>
))}
</VList>
</Flexbox>
<KnowledgeBaseListProvider>
<Flexbox paddingInline={4} style={{ height: '100%' }}>
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
style={{ height: '100%' }}
>
{visibleNodes.map(({ item, key, level }) => (
<div key={key} style={{ paddingBottom: 2 }}>
<HierarchyNode
expandedFolders={expandedFolders}
folderChildrenCache={folderChildrenCache}
item={item}
level={level}
loadingFolders={loadingFolders}
selectedKey={selectedKey}
updateKey={updateKey}
onLoadFolder={handleLoadFolder}
onToggleFolder={handleToggleFolder}
/>
</div>
))}
</VList>
</Flexbox>
</KnowledgeBaseListProvider>
);
});

View file

@ -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;
}}
>
<Flexbox horizontal align={'center'} className={styles.title} gap={16}>
{icon}
{t(`uploadDock.uploadStatus.${overviewUploadingStatus}`)} ·{' '}
{t('uploadDock.totalCount', { count })}
</Flexbox>
{!isUploading && (
<ActionIcon
icon={XIcon}
onClick={() => {
setShow(false);
dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' });
}}
/>
)}
<Flexbox
horizontal
align={'center'}
gap={12}
onClick={(e) => {
e.stopPropagation();
}}
>
{hasCancellableUploads && (
<Text
style={{ cursor: 'pointer', flexShrink: 0, fontSize: 13 }}
type={'secondary'}
onClick={cancelAllActiveUploads}
>
{t('uploadDock.header.cancelAll')}
</Text>
)}
{!isUploading && (
<ActionIcon
icon={XIcon}
onClick={() => {
setShow(false);
dispatchDockFileList({ ids: fileList.map((item) => item.id), type: 'removeFiles' });
}}
/>
)}
</Flexbox>
</Flexbox>
<AnimatePresence mode="wait">
{expand ? (
<motion.div
<m.div
animate={{ height: 400, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
@ -148,7 +170,6 @@ const UploadDock = memo(() => {
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<Flexbox
justify={'space-between'}
style={{
background: cssVar.colorBgContainer,
borderBottomLeftRadius: 8,
@ -156,27 +177,34 @@ const UploadDock = memo(() => {
height: 400,
}}
>
<Flexbox gap={8} paddingBlock={8} style={{ overflowY: 'scroll' }}>
<Flexbox
flex={1}
gap={8}
paddingBlock={8}
style={{ minHeight: 0, overflowY: 'scroll' }}
>
{fileList.map((item) => (
<Item key={item.id} {...item} />
))}
</Flexbox>
<Center style={{ height: 40, minHeight: 40 }}>
<Text
style={{ cursor: 'pointer' }}
type={'secondary'}
onClick={() => {
setExpand(false);
}}
>
{t('uploadDock.body.collapse')}
</Text>
</Center>
{isUploading && (
<Center style={{ flexShrink: 0, height: 40, minHeight: 40 }}>
<Text
style={{ cursor: 'pointer' }}
type={'secondary'}
onClick={() => {
setExpand(false);
}}
>
{t('uploadDock.body.collapse')}
</Text>
</Center>
)}
</Flexbox>
</motion.div>
</m.div>
) : (
overviewUploadingStatus !== 'pending' && (
<motion.div
<m.div
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
initial={{ opacity: 0, scaleY: 0 }}
@ -196,7 +224,7 @@ const UploadDock = memo(() => {
insetInlineEnd: `${100 - totalUploadingProgress}%`,
}}
/>
</motion.div>
</m.div>
)
)}
</AnimatePresence>

View file

@ -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,
]);

View file

@ -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

View file

@ -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<AppThemeProps>(
<AntdStaticMethods />
<ConfigProvider
locale={uiLocale}
motion={motion}
motion={m}
resources={uiResources}
config={{
aAs: Link,

View file

@ -17,6 +17,8 @@ export default {
'You are about to delete this file. Once deleted, it 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.confirmDeleteAllFiles':
'You are about to delete all results in the current view. Once deleted, they cannot be recovered. Please confirm your action.',
'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':
@ -57,7 +59,12 @@ export default {
'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',

View file

@ -139,6 +139,7 @@ export default {
'title': 'Resources',
'toggleLeftPanel': 'Show/Hide Left Panel',
'uploadDock.body.collapse': 'Collapse',
'uploadDock.header.cancelAll': 'Cancel all',
'uploadDock.body.item.cancel': 'Cancel',
'uploadDock.body.item.cancelled': 'Cancelled',
'uploadDock.body.item.done': 'Uploaded',

View file

@ -1,7 +1,7 @@
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { HashIcon, MessageSquareDashed } from 'lucide-react';
import { AnimatePresence, m as motion } from 'motion/react';
import { AnimatePresence, m } from 'motion/react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@ -124,7 +124,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
<span className={styles.dotContainer} style={{ width: hasUnread ? 18 : 0 }}>
<AnimatePresence mode="popLayout">
{hasUnread && (
<motion.div
<m.div
className={styles.neonDotWrapper}
initial={{ scale: 0, opacity: 0 }}
animate={{
@ -136,7 +136,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
opacity: 0,
}}
>
<motion.span
<m.span
initial={false}
animate={{
scale: [1, 1.3, 1],
@ -160,7 +160,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
ease: 'easeInOut',
}}
/>
</motion.div>
</m.div>
)}
</AnimatePresence>
</span>

View file

@ -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 = () => {
</div>
<AnimatePresence mode="popLayout">
{showSuggestQuestions && (
<motion.div
<m.div
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 8 }}
initial={{ opacity: 0, scale: 0.98, y: 8 }}
@ -141,7 +141,7 @@ const InputArea = () => {
<SuggestQuestions mode={inputActiveMode} />
<CommunityRecommend mode={inputActiveMode} />
</Flexbox>
</motion.div>
</m.div>
)}
</AnimatePresence>
</Flexbox>

View file

@ -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<DragState | null>(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<PropsWithChildren>(({ children }) => {
const { message } = App.useApp();
const [currentDrag, setCurrentDrag] = useState<DragState | null>(null);
const overlayRef = useRef<HTMLDivElement>(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<PropsWithChildren>(({ 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<PropsWithChildren>(({ 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<PropsWithChildren>(({ children }) => {
return (
<DragActiveContext value={currentDrag !== null}>
<DragStateContext value={{ currentDrag, setCurrentDrag }}>
{children}
{typeof document !== 'undefined' &&
createPortal(
currentDrag ? (
<div
ref={overlayRef}
style={{
alignItems: 'center',
background: cssVar.colorBgElevated,
border: `1px solid ${cssVar.colorPrimaryBorder}`,
borderRadius: cssVar.borderRadiusLG,
boxShadow: cssVar.boxShadow,
display: 'flex',
gap: 12,
height: 44,
left: '-999px',
maxWidth: 320,
minWidth: 200,
padding: '0 12px',
pointerEvents: 'none',
position: 'fixed',
top: '-999px',
transform: 'translate3d(0, 0, 0)',
willChange: 'transform',
zIndex: 9999,
}}
>
<CurrentDragContext value={currentDrag}>
<SetCurrentDragContext value={setCurrentDrag}>
{children}
{typeof document !== 'undefined' &&
createPortal(
currentDrag ? (
<div
ref={overlayRef}
style={{
alignItems: 'center',
color: cssVar.colorPrimary,
background: cssVar.colorBgElevated,
border: `1px solid ${cssVar.colorPrimaryBorder}`,
borderRadius: cssVar.borderRadiusLG,
boxShadow: cssVar.boxShadow,
display: 'flex',
flexShrink: 0,
justifyContent: 'center',
gap: 12,
height: 44,
left: '-999px',
maxWidth: 320,
minWidth: 200,
padding: '0 12px',
pointerEvents: 'none',
position: 'fixed',
top: '-999px',
transform: 'translate3d(0, 0, 0)',
willChange: 'transform',
zIndex: 9999,
}}
>
{currentDrag.data.fileType === 'custom/folder' ? (
<Icon icon={FolderIcon} size={20} />
) : currentDrag.data.fileType === 'custom/document' ? (
<Icon icon={FileText} size={20} />
) : (
<FileIcon
fileName={currentDrag.data.name}
fileType={currentDrag.data.fileType}
size={20}
/>
)}
</div>
<span
style={{
color: cssVar.colorText,
flex: 1,
fontSize: cssVar.fontSize,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{currentDrag.data.name}
</span>
{selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && (
<div
style={{
alignItems: 'center',
background: cssVar.colorPrimary,
borderRadius: cssVar.borderRadiusSM,
color: cssVar.colorTextLightSolid,
color: cssVar.colorPrimary,
display: 'flex',
flexShrink: 0,
fontSize: 12,
fontWeight: 600,
height: 22,
justifyContent: 'center',
minWidth: 22,
padding: '0 6px',
}}
>
{selectedFileIds.length}
{currentDrag.data.fileType === 'custom/folder' ? (
<Icon icon={FolderIcon} size={20} />
) : currentDrag.data.fileType === 'custom/document' ? (
<Icon icon={FileText} size={20} />
) : (
<FileIcon
fileName={currentDrag.data.name}
fileType={currentDrag.data.fileType}
size={20}
/>
)}
</div>
)}
</div>
) : null,
document.body,
)}
</DragStateContext>
<span
style={{
color: cssVar.colorText,
flex: 1,
fontSize: cssVar.fontSize,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{currentDrag.data.name}
</span>
{selectedFileIds.includes(currentDrag.id) && selectedFileIds.length > 1 && (
<div
style={{
alignItems: 'center',
background: cssVar.colorPrimary,
borderRadius: cssVar.borderRadiusSM,
color: cssVar.colorTextLightSolid,
display: 'flex',
flexShrink: 0,
fontSize: 12,
fontWeight: 600,
height: 22,
justifyContent: 'center',
minWidth: 22,
padding: '0 6px',
}}
>
{selectedFileIds.length}
</div>
)}
</div>
) : null,
appElement ?? document.body,
)}
</SetCurrentDragContext>
</CurrentDragContext>
</DragActiveContext>
);
});

View file

@ -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;
};

Some files were not shown because too many files have changed in this diff Show more