{
ItemContent={MasonryItemWrapper}
columnCount={columnCount}
context={masonryContext}
- data={data || []}
+ data={data}
style={{
gap: '16px',
overflow: 'hidden',
@@ -147,4 +197,6 @@ const MasonryView = memo(() => {
);
});
+MasonryView.displayName = 'MasonryView';
+
export default MasonryView;
diff --git a/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx b/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx
index a1231255f3..b4b8327673 100644
--- a/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx
+++ b/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx
@@ -5,7 +5,7 @@ import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FolderTree, { type FolderTreeItem } from '@/features/ResourceManager/components/FolderTree';
-import { clearTreeFolderCache } from '@/features/ResourceManager/components/Tree';
+import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy';
import { fileService } from '@/services/file';
import { useFileStore } from '@/store/file';
@@ -28,10 +28,7 @@ const MoveToFolderModal = memo
(
const [loadedFolders, setLoadedFolders] = useState>(new Set());
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
- const [moveResource, createFolder] = useFileStore((s) => [
- s.moveResource,
- s.createFolder,
- ]);
+ const [moveResource, createFolder] = useFileStore((s) => [s.moveResource, s.createFolder]);
// Sort items: folders only
const sortItems = useCallback((items: FolderTreeItem[]): FolderTreeItem[] => {
diff --git a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx
index 43e38e5e7b..dcad1dd855 100644
--- a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx
+++ b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx
@@ -25,13 +25,12 @@ export type MultiSelectActionType =
| 'removeFromKnowledgeBase';
interface BatchActionsDropdownProps {
- disabled?: boolean;
onActionClick: (type: MultiSelectActionType) => Promise;
selectCount: number;
}
const BatchActionsDropdown = memo(
- ({ selectCount, onActionClick, disabled }) => {
+ ({ selectCount, onActionClick }) => {
const { t } = useTranslation(['components', 'common', 'file', 'knowledgeBase']);
const { modal, message } = App.useApp();
@@ -54,7 +53,7 @@ const BatchActionsDropdown = memo(
danger: true,
icon: ,
key: 'deleteLibrary',
- label: t('delete', { ns: 'common' }),
+ label: t('header.actions.deleteLibrary', { ns: 'file' }),
onClick: async () => {
modal.confirm({
okButtonProps: {
@@ -74,6 +73,7 @@ const BatchActionsDropdown = memo(
const availableKnowledgeBases = (knowledgeBases || []).filter((kb) => kb.id !== libraryId);
const addToKnowledgeBaseSubmenu: DropdownItem[] = availableKnowledgeBases.map((kb) => ({
+ disabled: selectCount === 0,
icon: ,
key: `add-to-kb-${kb.id}`,
label: {kb.name},
@@ -95,6 +95,7 @@ const BatchActionsDropdown = memo(
if (libraryId) {
items.push({
+ disabled: selectCount === 0,
icon: ,
key: 'removeFromKnowledgeBase',
label: t('FileManager.actions.removeFromKnowledgeBase'),
@@ -117,6 +118,7 @@ const BatchActionsDropdown = memo(
if (availableKnowledgeBases.length > 0) {
items.push({
children: addToKnowledgeBaseSubmenu as any,
+ disabled: selectCount === 0,
icon: ,
key: 'addToOtherKnowledgeBase',
label: t('FileManager.actions.addToOtherKnowledgeBase'),
@@ -125,6 +127,7 @@ const BatchActionsDropdown = memo(
} else if (availableKnowledgeBases.length > 0) {
items.push({
children: addToKnowledgeBaseSubmenu as any,
+ disabled: selectCount === 0,
icon: ,
key: 'addToKnowledgeBase',
label: t('FileManager.actions.addToKnowledgeBase'),
@@ -133,6 +136,7 @@ const BatchActionsDropdown = memo(
items.push(
{
+ disabled: selectCount === 0,
icon: ,
key: 'batchChunking',
label: t('FileManager.actions.batchChunking'),
@@ -145,6 +149,7 @@ const BatchActionsDropdown = memo(
},
{
danger: true,
+ disabled: selectCount === 0,
icon: ,
key: 'delete',
label: t('delete', { ns: 'common' }),
@@ -177,9 +182,8 @@ const BatchActionsDropdown = memo(
]);
return (
-
+
diff --git a/src/features/ResourceManager/components/Explorer/index.tsx b/src/features/ResourceManager/components/Explorer/index.tsx
index f6b3a4320c..ccd33331c5 100644
--- a/src/features/ResourceManager/components/Explorer/index.tsx
+++ b/src/features/ResourceManager/components/Explorer/index.tsx
@@ -12,11 +12,8 @@ import { useFetchResources, useResourceStore } from '@/store/file/slices/resourc
import EmptyPlaceholder from './EmptyPlaceholder';
import Header from './Header';
import ListView from './ListView';
-import ListViewSkeleton from './ListView/Skeleton';
import MasonryView from './MasonryView';
-import MasonryViewSkeleton from './MasonryView/Skeleton';
import { useCheckTaskStatus } from './useCheckTaskStatus';
-import { useMasonryColumnCount } from './useMasonryColumnCount';
import { useResourceExplorer } from './useResourceExplorer';
/**
@@ -32,27 +29,16 @@ const ResourceExplorer = memo(() => {
useResourceManagerUrlSync();
// Get state from Resource Manager store
- const [
- libraryId,
- category,
- viewMode,
- isTransitioning,
- isMasonryReady,
- searchQuery,
- setSelectedFileIds,
- sorter,
- sortType,
- ] = useResourceManagerStore((s) => [
- s.libraryId,
- s.category,
- s.viewMode,
- s.isTransitioning,
- s.isMasonryReady,
- s.searchQuery,
- s.setSelectedFileIds,
- s.sorter,
- s.sortType,
- ]);
+ const [libraryId, category, viewMode, searchQuery, setSelectedFileIds, sorter, sortType] =
+ useResourceManagerStore((s) => [
+ s.libraryId,
+ s.category,
+ s.viewMode,
+ s.searchQuery,
+ s.setSelectedFileIds,
+ s.sorter,
+ s.sortType,
+ ]);
// Get folder path for empty state check
const { currentFolderSlug } = useFolderPath();
@@ -77,19 +63,7 @@ const ResourceExplorer = memo(() => {
const { isLoading, isValidating } = useFetchResources(queryParams);
// Get resource data from store (updated by SWR hook)
- const { resourceList, queryParams: currentQueryParams } = useResourceStore();
-
- // Check if we're navigating to a different view (different query params)
- const isNavigating = useMemo(() => {
- if (!currentQueryParams || !queryParams) return false;
-
- return (
- currentQueryParams.libraryId !== queryParams.libraryId ||
- currentQueryParams.parentId !== queryParams.parentId ||
- currentQueryParams.category !== queryParams.category ||
- currentQueryParams.q !== queryParams.q
- );
- }, [currentQueryParams, queryParams]);
+ const { resourceList } = useResourceStore();
// Map ResourceItem[] to FileListItem[] for compatibility
// TODO: Eventually update all consumers to use ResourceItem directly
@@ -119,19 +93,6 @@ const ResourceExplorer = memo(() => {
setSelectedFileIds([]);
}, [category, libraryId, searchQuery, setSelectedFileIds]);
- // Computed values
- const columnCount = useMasonryColumnCount();
-
- // Show skeleton when:
- // 1. Initial load with no data (isLoading && no data)
- // 2. Navigating to different folder/category (isNavigating && isValidating)
- // 3. View mode transitions
- const showSkeleton =
- (isLoading && (!data || data.length >= 5)) ||
- (isNavigating && isValidating) ||
- (viewMode === 'list' && isTransitioning) ||
- (viewMode === 'masonry' && (isTransitioning || !isMasonryReady));
-
const showEmptyStatus = !isLoading && !isValidating && data?.length === 0 && !currentFolderSlug;
return (
@@ -139,12 +100,6 @@ const ResourceExplorer = memo(() => {
{showEmptyStatus ? (
- ) : showSkeleton ? (
- viewMode === 'list' ? (
-
- ) : (
-
- )
) : viewMode === 'list' ? (
) : (
diff --git a/src/features/ResourceManager/components/Header/AddButton.tsx b/src/features/ResourceManager/components/Header/AddButton.tsx
index 8b85628b2a..b7773c66e3 100644
--- a/src/features/ResourceManager/components/Header/AddButton.tsx
+++ b/src/features/ResourceManager/components/Header/AddButton.tsx
@@ -22,7 +22,6 @@ const AddButton = () => {
const { t } = useTranslation('file');
const pushDockFileList = useFileStore((s) => s.pushDockFileList);
const uploadFolderWithStructure = useFileStore((s) => s.uploadFolderWithStructure);
- const createResource = useFileStore((s) => s.createResource);
const createResourceAndSync = useFileStore((s) => s.createResourceAndSync);
// TODO: Migrate Notion import to use createResource
@@ -39,9 +38,9 @@ const AddButton = () => {
]);
const handleOpenPageEditor = useCallback(async () => {
- // Create a new page with optimistic update - instant UI feedback
+ // Create a new page and wait for server sync - ensures page editor can load the document
const untitledTitle = t('pageList.untitled');
- const tempId = await createResource({
+ const realId = await createResourceAndSync({
content: '',
fileType: 'custom/document',
knowledgeBaseId: libraryId,
@@ -50,10 +49,10 @@ const AddButton = () => {
title: untitledTitle,
});
- // Switch to page view mode immediately (temp ID works)
- setCurrentViewItemId(tempId);
+ // Switch to page view mode with real ID
+ setCurrentViewItemId(realId);
setMode('page');
- }, [createResource, currentFolderId, libraryId, setCurrentViewItemId, setMode, t]);
+ }, [createResourceAndSync, currentFolderId, libraryId, setCurrentViewItemId, setMode, t]);
const handleCreateFolder = useCallback(async () => {
// Create folder and wait for sync to complete before triggering rename
diff --git a/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx
new file mode 100644
index 0000000000..d099a727e2
--- /dev/null
+++ b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx
@@ -0,0 +1,382 @@
+'use client';
+
+import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons';
+import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui';
+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 React, { memo, useCallback, useMemo, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import {
+ getTransparentDragImage,
+ useDragActive,
+ useDragState,
+} from '@/app/[variants]/(main)/resource/features/DndContextWrapper';
+import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
+import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
+import FileIcon from '@/components/FileIcon';
+import { useFileStore } from '@/store/file';
+
+import { useFileItemDropdown } from '../Explorer/ItemDropdown/useFileItemDropdown';
+import { styles } from './styles';
+import { clearTreeFolderCache } from './treeState';
+import type { TreeItem } from './types';
+
+interface HierarchyNodeProps {
+ expandedFolders: Set;
+ folderChildrenCache: Map;
+ item: TreeItem;
+ level?: number;
+ loadingFolders: Set;
+ onLoadFolder: (_: string) => Promise;
+ onToggleFolder: (_: string) => void;
+ selectedKey: string | null;
+ updateKey?: number;
+}
+
+// Row component for folder / file tree (virtualized by flattening visible nodes)
+export const HierarchyNode = memo(
+ ({
+ item,
+ level = 0,
+ expandedFolders,
+ loadingFolders,
+ onToggleFolder,
+ onLoadFolder,
+ selectedKey,
+ folderChildrenCache,
+ }) => {
+ const navigate = useNavigate();
+ const { currentFolderSlug } = useFolderPath();
+ const { message } = App.useApp();
+
+ const [setMode, setCurrentViewItemId, libraryId] = useResourceManagerStore((s) => [
+ s.setMode,
+ s.setCurrentViewItemId,
+ s.libraryId,
+ ]);
+
+ const renameFolder = useFileStore((s) => s.renameFolder);
+
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [renamingValue, setRenamingValue] = useState(item.name);
+ const inputRef = useRef(null);
+
+ // Memoize computed values that don't change frequently
+ const { itemKey } = useMemo(
+ () => ({
+ itemKey: item.slug || item.id,
+ }),
+ [item.slug, item.id],
+ );
+
+ const handleRenameStart = useCallback(() => {
+ setIsRenaming(true);
+ setRenamingValue(item.name);
+ // Focus input after render
+ setTimeout(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, 0);
+ }, [item.name]);
+
+ const handleRenameConfirm = useCallback(async () => {
+ if (!renamingValue.trim()) {
+ message.error('Folder name cannot be empty');
+ return;
+ }
+
+ if (renamingValue.trim() === item.name) {
+ setIsRenaming(false);
+ return;
+ }
+
+ try {
+ await renameFolder(item.id, renamingValue.trim());
+ if (libraryId) {
+ await clearTreeFolderCache(libraryId);
+ }
+ message.success('Renamed successfully');
+ setIsRenaming(false);
+ } catch (error) {
+ console.error('Rename error:', error);
+ message.error('Rename failed');
+ }
+ }, [item.id, item.name, libraryId, renamingValue, renameFolder, message]);
+
+ const handleRenameCancel = useCallback(() => {
+ setIsRenaming(false);
+ setRenamingValue(item.name);
+ }, [item.name]);
+
+ const { menuItems } = useFileItemDropdown({
+ fileType: item.fileType,
+ filename: item.name,
+ id: item.id,
+ libraryId,
+ onRenameStart: item.isFolder ? handleRenameStart : undefined,
+ sourceType: item.sourceType,
+ url: item.url,
+ });
+
+ const isDragActive = useDragActive();
+ const { setCurrentDrag } = useDragState();
+ const [isDragging, setIsDragging] = useState(false);
+ const [isOver, setIsOver] = useState(false);
+
+ // Memoize drag data to prevent recreation
+ const dragData = useMemo(
+ () => ({
+ fileType: item.fileType,
+ isFolder: item.isFolder,
+ name: item.name,
+ sourceType: item.sourceType,
+ }),
+ [item.fileType, item.isFolder, item.name, item.sourceType],
+ );
+
+ // Native HTML5 drag event handlers
+ const handleDragStart = useCallback(
+ (e: React.DragEvent) => {
+ setIsDragging(true);
+ setCurrentDrag({
+ data: dragData,
+ id: item.id,
+ type: item.isFolder ? 'folder' : 'file',
+ });
+
+ // Set drag image to be transparent (we use custom overlay)
+ const img = getTransparentDragImage();
+ if (img && e.dataTransfer) {
+ e.dataTransfer.setDragImage(img, 0, 0);
+ }
+ if (e.dataTransfer) {
+ e.dataTransfer.effectAllowed = 'move';
+ }
+ },
+ [dragData, item.id, item.isFolder, setCurrentDrag],
+ );
+
+ const handleDragEnd = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ if (!item.isFolder || !isDragActive) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ setIsOver(true);
+ },
+ [item.isFolder, isDragActive],
+ );
+
+ const handleDragLeave = useCallback(() => {
+ setIsOver(false);
+ }, []);
+
+ const handleDrop = useCallback(() => {
+ // Clear the highlight after drop
+ setIsOver(false);
+ }, []);
+
+ const handleItemClick = useCallback(() => {
+ // Open file modal using slug-based routing
+ const currentPath = currentFolderSlug
+ ? `/resource/library/${libraryId}/${currentFolderSlug}`
+ : `/resource/library/${libraryId}`;
+
+ setCurrentViewItemId(itemKey);
+ navigate(`${currentPath}?file=${itemKey}`);
+
+ if (itemKey.startsWith('doc')) {
+ setMode('page');
+ } else {
+ // Set mode to 'file' immediately to prevent flickering to list view
+ setMode('editor');
+ }
+ }, [itemKey, currentFolderSlug, libraryId, navigate, setMode, setCurrentViewItemId]);
+
+ const handleFolderClick = useCallback(
+ (folderId: string, folderSlug?: string | null) => {
+ const navKey = folderSlug || folderId;
+ navigate(`/resource/library/${libraryId}/${navKey}`);
+
+ setMode('explorer');
+ },
+ [libraryId, navigate],
+ );
+
+ if (item.isFolder) {
+ const isExpanded = expandedFolders.has(itemKey);
+ const isActive = selectedKey === itemKey;
+ const isLoading = loadingFolders.has(itemKey);
+
+ const handleToggle = async () => {
+ // Toggle folder expansion
+ onToggleFolder(itemKey);
+
+ // Only load if not already cached
+ if (!isExpanded && !folderChildrenCache.has(itemKey)) {
+ await onLoadFolder(itemKey);
+ }
+ };
+
+ return (
+
+ handleFolderClick(item.id, item.slug)}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ showContextMenu(menuItems());
+ }}
+ onDragEnd={handleDragEnd}
+ onDragLeave={handleDragLeave}
+ onDragOver={handleDragOver}
+ onDragStart={handleDragStart}
+ onDrop={handleDrop}
+ paddingInline={4}
+ style={{
+ paddingInlineStart: level * 12 + 4,
+ }}
+ variant={isActive ? 'filled' : 'borderless'}
+ >
+ {isLoading ? (
+
+ ) : (
+
+ {
+ e.stopPropagation();
+ handleToggle();
+ }}
+ size={'small'}
+ style={{ width: 20 }}
+ />
+
+ )}
+
+
+ {isRenaming ? (
+ setRenamingValue(e.target.value)}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleRenameConfirm();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ handleRenameCancel();
+ }
+ }}
+ onPointerDown={(e) => e.stopPropagation()}
+ ref={inputRef}
+ size="small"
+ style={{ flex: 1 }}
+ value={renamingValue}
+ />
+ ) : (
+
+ {item.name}
+
+ )}
+
+
+
+ );
+ }
+
+ // Render as file
+ const isActive = selectedKey === itemKey;
+ return (
+
+ {
+ e.preventDefault();
+ showContextMenu(menuItems());
+ }}
+ onDragEnd={handleDragEnd}
+ onDragStart={handleDragStart}
+ paddingInline={4}
+ style={{
+ paddingInlineStart: level * 12 + 4,
+ }}
+ variant={isActive ? 'filled' : 'borderless'}
+ >
+
+
+ {item.sourceType === 'document' ? (
+
+ ) : (
+
+ )}
+
+ {item.name}
+
+
+
+
+ );
+ },
+);
+
+HierarchyNode.displayName = 'HierarchyNode';
diff --git a/src/features/ResourceManager/components/Tree/TreeSkeleton.tsx b/src/features/ResourceManager/components/LibraryHierarchy/TreeSkeleton.tsx
similarity index 100%
rename from src/features/ResourceManager/components/Tree/TreeSkeleton.tsx
rename to src/features/ResourceManager/components/LibraryHierarchy/TreeSkeleton.tsx
diff --git a/src/features/ResourceManager/components/LibraryHierarchy/index.tsx b/src/features/ResourceManager/components/LibraryHierarchy/index.tsx
new file mode 100644
index 0000000000..95d4c27391
--- /dev/null
+++ b/src/features/ResourceManager/components/LibraryHierarchy/index.tsx
@@ -0,0 +1,396 @@
+'use client';
+
+import { Flexbox } from '@lobehub/ui';
+import { memo, useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
+import { VList } from 'virtua';
+
+import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
+import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
+import { fileService } from '@/services/file';
+import { useFileStore } from '@/store/file';
+import type { ResourceQueryParams } from '@/types/resource';
+
+import { HierarchyNode } from './HierarchyNode';
+import TreeSkeleton from './TreeSkeleton';
+import {
+ TREE_REFRESH_EVENT,
+ getTreeState,
+ resourceItemToTreeItem,
+ sortTreeItems,
+} from './treeState';
+import type { TreeItem } from './types';
+
+// Export for external use
+export { clearTreeFolderCache } from './treeState';
+
+/**
+ * As a sidebar along with the Explorer
+ */
+const LibraryHierarchy = memo(() => {
+ const { currentFolderSlug } = useFolderPath();
+
+ const [useFetchKnowledgeItems, useFetchFolderBreadcrumb, useFetchKnowledgeItem] = useFileStore(
+ (s) => [s.useFetchKnowledgeItems, s.useFetchFolderBreadcrumb, s.useFetchKnowledgeItem],
+ );
+
+ const [resourceList, resourceQueryParams] = useFileStore((s) => [s.resourceList, s.queryParams]);
+
+ const [libraryId, currentViewItemId] = useResourceManagerStore((s) => [
+ s.libraryId,
+ s.currentViewItemId,
+ ]);
+
+ // Force re-render when tree state changes
+ const [updateKey, forceUpdate] = useReducer((x) => x + 1, 0);
+
+ // Get the persisted state for this knowledge base
+ const state = useMemo(() => getTreeState(libraryId || ''), [libraryId]);
+ const { expandedFolders, folderChildrenCache, loadingFolders } = state;
+
+ // Fetch breadcrumb for current folder
+ const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug);
+
+ // Fetch current file when viewing a file
+ const { data: currentFile } = useFetchKnowledgeItem(currentViewItemId);
+
+ // Track parent folder key for file selection - stored in a ref to avoid hook order issues
+ const parentFolderKeyRef = useRef(null);
+
+ // Fetch root level data using SWR
+ const { data: rootData, isLoading } = useFetchKnowledgeItems({
+ knowledgeBaseId: libraryId,
+ parentId: null,
+ showFilesInKnowledgeBase: false,
+ });
+
+ const isExplorerCacheActiveForTree = useMemo(() => {
+ if (!libraryId) return false;
+ if (!resourceQueryParams) return false;
+
+ // We intentionally ignore search per requirement: tree always shows full hierarchy
+ if (resourceQueryParams.q) return false;
+
+ return resourceQueryParams.libraryId === libraryId;
+ }, [libraryId, resourceQueryParams]);
+
+ const explorerParentKey = useMemo(() => {
+ if (!isExplorerCacheActiveForTree) return null;
+ return (resourceQueryParams as ResourceQueryParams).parentId ?? null;
+ }, [isExplorerCacheActiveForTree, resourceQueryParams]);
+
+ const explorerChildren = useMemo(() => {
+ if (!isExplorerCacheActiveForTree) return [];
+ return sortTreeItems(resourceList.map(resourceItemToTreeItem));
+ }, [isExplorerCacheActiveForTree, resourceList]);
+
+ const isSameTreeItems = useCallback((a: TreeItem[] | undefined, b: TreeItem[]) => {
+ if (!a) return false;
+ if (a.length !== b.length) return false;
+ // Compare minimal stable identity for change detection
+ let i = 0;
+ for (const item of a) {
+ if (item.id !== b[i]?.id) return false;
+ i += 1;
+ }
+ return true;
+ }, []);
+
+ // Convert root data to tree items
+ const items: TreeItem[] = useMemo(() => {
+ // If Explorer has loaded root for this library, use its cache to ensure identical state
+ if (isExplorerCacheActiveForTree && explorerParentKey === null) return explorerChildren;
+ if (!rootData) return [];
+
+ const mappedItems: TreeItem[] = rootData.map((item) => ({
+ fileType: item.fileType,
+ id: item.id,
+ isFolder: item.fileType === 'custom/folder',
+ name: item.name,
+ slug: item.slug,
+ sourceType: item.sourceType,
+ url: item.url,
+ }));
+
+ return sortTreeItems(mappedItems);
+ }, [explorerChildren, explorerParentKey, rootData, updateKey]);
+
+ // Hydrate tree cache for the folder Explorer has loaded (non-root only).
+ // This ensures the tree and explorer render identical children for that folder.
+ useEffect(() => {
+ if (!isExplorerCacheActiveForTree) return;
+ if (!explorerParentKey) return; // root handled via `items` memo above
+
+ const existing = state.folderChildrenCache.get(explorerParentKey);
+ if (isSameTreeItems(existing, explorerChildren)) return;
+
+ state.folderChildrenCache.set(explorerParentKey, explorerChildren);
+ state.loadedFolders.add(explorerParentKey);
+ forceUpdate();
+ // NOTE: folderChildrenCache / loadedFolders are mutated in-place
+ }, [
+ explorerChildren,
+ explorerParentKey,
+ isExplorerCacheActiveForTree,
+ isSameTreeItems,
+ state,
+ forceUpdate,
+ ]);
+
+ const visibleNodes = useMemo(() => {
+ interface VisibleNode {
+ item: TreeItem;
+ key: string;
+ level: number;
+ }
+
+ const result: VisibleNode[] = [];
+
+ const walk = (nodes: TreeItem[], level: number) => {
+ for (const node of nodes) {
+ const key = node.slug || node.id;
+
+ result.push({ item: node, key, level });
+
+ if (!node.isFolder) continue;
+ if (!expandedFolders.has(key)) continue;
+
+ const children = folderChildrenCache.get(key);
+ if (!children || children.length === 0) continue;
+
+ walk(children, level + 1);
+ }
+ };
+
+ walk(items, 0);
+
+ return result;
+ // NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute
+ }, [items, expandedFolders, folderChildrenCache, updateKey]);
+
+ const handleLoadFolder = useCallback(
+ async (folderId: string) => {
+ // Set loading state
+ state.loadingFolders.add(folderId);
+ forceUpdate();
+
+ try {
+ // Prefer Explorer's cache when it matches this folder (keeps tree + explorer identical)
+ if (isExplorerCacheActiveForTree && explorerParentKey === folderId) {
+ state.folderChildrenCache.set(folderId, explorerChildren);
+ state.loadedFolders.add(folderId);
+ return;
+ }
+
+ // Use SWR mutate to trigger a fetch that will be cached and shared with FileExplorer
+ const { mutate: swrMutate } = await import('swr');
+ const response = await swrMutate(
+ [
+ 'useFetchKnowledgeItems',
+ {
+ knowledgeBaseId: libraryId,
+ parentId: folderId,
+ showFilesInKnowledgeBase: false,
+ },
+ ],
+ () =>
+ fileService.getKnowledgeItems({
+ knowledgeBaseId: libraryId,
+ parentId: folderId,
+ showFilesInKnowledgeBase: false,
+ }),
+ {
+ revalidate: false, // Don't revalidate immediately after mutation
+ },
+ );
+
+ if (!response || !response.items) {
+ console.error('Failed to load folder contents: no data returned');
+ return;
+ }
+
+ const childItems: TreeItem[] = response.items.map((item) => ({
+ fileType: item.fileType,
+ id: item.id,
+ isFolder: item.fileType === 'custom/folder',
+ name: item.name,
+ slug: item.slug,
+ sourceType: item.sourceType,
+ url: item.url,
+ }));
+
+ // Sort children: folders first, then files
+ const sortedChildren = sortTreeItems(childItems);
+
+ // Store children in cache
+ state.folderChildrenCache.set(folderId, sortedChildren);
+ state.loadedFolders.add(folderId);
+ } catch (error) {
+ console.error('Failed to load folder contents:', error);
+ } finally {
+ // Clear loading state
+ state.loadingFolders.delete(folderId);
+ // Trigger re-render
+ forceUpdate();
+ }
+ },
+ [
+ explorerChildren,
+ explorerParentKey,
+ forceUpdate,
+ isExplorerCacheActiveForTree,
+ libraryId,
+ state,
+ ],
+ );
+
+ const handleToggleFolder = useCallback(
+ (folderId: string) => {
+ if (state.expandedFolders.has(folderId)) {
+ state.expandedFolders.delete(folderId);
+ } else {
+ state.expandedFolders.add(folderId);
+ }
+ // Trigger re-render
+ forceUpdate();
+ },
+ [state, forceUpdate],
+ );
+
+ // Reset parent folder key when switching libraries
+ useEffect(() => {
+ parentFolderKeyRef.current = null;
+ }, [libraryId]);
+
+ // Listen for external tree refresh events (triggered when cache is cleared)
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const handleTreeRefresh = (event: Event) => {
+ const detail = (event as CustomEvent<{ knowledgeBaseId?: string }>).detail;
+ if (detail?.knowledgeBaseId && libraryId && detail.knowledgeBaseId !== libraryId) return;
+ forceUpdate();
+ };
+
+ window.addEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
+ return () => {
+ window.removeEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
+ };
+ }, [libraryId, forceUpdate]);
+
+ // Auto-expand folders when navigating to a folder in Explorer
+ useEffect(() => {
+ if (!folderBreadcrumb || folderBreadcrumb.length === 0) return;
+
+ let hasChanges = false;
+
+ // Expand all folders in the breadcrumb path
+ for (const crumb of folderBreadcrumb) {
+ const key = crumb.slug || crumb.id;
+ if (!state.expandedFolders.has(key)) {
+ state.expandedFolders.add(key);
+ hasChanges = true;
+ }
+
+ // Load folder contents if not already loaded
+ if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
+ handleLoadFolder(key);
+ }
+ }
+
+ if (hasChanges) {
+ forceUpdate();
+ }
+ }, [folderBreadcrumb, state, forceUpdate, handleLoadFolder]);
+
+ // Auto-expand parent folder when viewing a file
+ useEffect(() => {
+ if (!currentFile || !currentViewItemId) {
+ parentFolderKeyRef.current = null;
+ return;
+ }
+
+ // If the file has a parent folder, expand the path to it
+ if (currentFile.parentId) {
+ // Fetch the parent folder's breadcrumb to get the full path
+ const fetchParentPath = async () => {
+ try {
+ const parentBreadcrumb = await fileService.getFolderBreadcrumb(currentFile.parentId!);
+
+ if (!parentBreadcrumb || parentBreadcrumb.length === 0) return;
+
+ let hasChanges = false;
+
+ // The last item in breadcrumb is the immediate parent folder
+ const parentFolder = parentBreadcrumb.at(-1)!;
+ const parentKey = parentFolder.slug || parentFolder.id;
+ parentFolderKeyRef.current = parentKey;
+
+ // Expand all folders in the parent's breadcrumb path
+ for (const crumb of parentBreadcrumb) {
+ const key = crumb.slug || crumb.id;
+ if (!state.expandedFolders.has(key)) {
+ state.expandedFolders.add(key);
+ hasChanges = true;
+ }
+
+ // Load folder contents if not already loaded
+ if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
+ handleLoadFolder(key);
+ }
+ }
+
+ if (hasChanges) {
+ forceUpdate();
+ }
+ } catch (error) {
+ console.error('Failed to fetch parent folder breadcrumb:', error);
+ }
+ };
+
+ fetchParentPath();
+ } else {
+ parentFolderKeyRef.current = null;
+ }
+ }, [currentFile, currentViewItemId, state, forceUpdate, handleLoadFolder]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ // Determine which item should be highlighted
+ // If viewing a file, highlight its parent folder
+ // Otherwise, highlight the current folder
+ const selectedKey =
+ currentViewItemId && parentFolderKeyRef.current
+ ? parentFolderKeyRef.current
+ : currentFolderSlug;
+
+ return (
+
+
+ {visibleNodes.map(({ item, key, level }) => (
+
+
+
+ ))}
+
+
+ );
+});
+
+LibraryHierarchy.displayName = 'FileTree';
+
+export default LibraryHierarchy;
diff --git a/src/features/ResourceManager/components/LibraryHierarchy/styles.ts b/src/features/ResourceManager/components/LibraryHierarchy/styles.ts
new file mode 100644
index 0000000000..0b1faa6065
--- /dev/null
+++ b/src/features/ResourceManager/components/LibraryHierarchy/styles.ts
@@ -0,0 +1,19 @@
+import { createStaticStyles } from 'antd-style';
+
+export const styles = createStaticStyles(({ css, cssVar }) => ({
+ dragging: css`
+ will-change: transform;
+ opacity: 0.5;
+ `,
+ fileItemDragOver: css`
+ color: ${cssVar.colorBgElevated} !important;
+ background-color: ${cssVar.colorText} !important;
+
+ * {
+ color: ${cssVar.colorBgElevated} !important;
+ }
+ `,
+ treeItem: css`
+ cursor: pointer;
+ `,
+}));
diff --git a/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts b/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts
new file mode 100644
index 0000000000..b883f17c6c
--- /dev/null
+++ b/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts
@@ -0,0 +1,178 @@
+import { fileService } from '@/services/file';
+import { useFileStore } from '@/store/file';
+import type { ResourceItem } from '@/types/resource';
+
+import type { TreeItem } from './types';
+
+export const sortTreeItems = (items: T[]): T[] => {
+ return [...items].sort((a, b) => {
+ // Folders first
+ if (a.isFolder && !b.isFolder) return -1;
+ if (!a.isFolder && b.isFolder) return 1;
+ // Then alphabetically by name
+ return a.name.localeCompare(b.name);
+ });
+};
+
+export const resourceItemToTreeItem = (item: ResourceItem): TreeItem => {
+ return {
+ fileType: item.fileType,
+ id: item.id,
+ isFolder: item.fileType === 'custom/folder',
+ name: item.name,
+ slug: item.slug,
+ sourceType: item.sourceType,
+ url: item.url || '',
+ };
+};
+
+// Module-level state to persist expansion across re-renders
+const treeState = new Map<
+ string,
+ {
+ expandedFolders: Set;
+ folderChildrenCache: Map;
+ loadedFolders: Set;
+ loadingFolders: Set;
+ }
+>();
+
+export const TREE_REFRESH_EVENT = 'resource-tree-refresh';
+
+export const emitTreeRefresh = (knowledgeBaseId: string) => {
+ if (typeof window === 'undefined') return;
+ window.dispatchEvent(
+ new CustomEvent(TREE_REFRESH_EVENT, {
+ detail: { knowledgeBaseId },
+ }),
+ );
+};
+
+export const getTreeState = (knowledgeBaseId: string) => {
+ if (!treeState.has(knowledgeBaseId)) {
+ treeState.set(knowledgeBaseId, {
+ expandedFolders: new Set(),
+ folderChildrenCache: new Map(),
+ loadedFolders: new Set(),
+ loadingFolders: new Set(),
+ });
+ }
+ return treeState.get(knowledgeBaseId)!;
+};
+
+/**
+ * Clear and reload all expanded folders
+ * This should be called along with file store's refreshFileList()
+ * Simpler approach: reload all expanded folders to avoid ID vs slug issues
+ */
+export const clearTreeFolderCache = async (knowledgeBaseId: string) => {
+ const state = treeState.get(knowledgeBaseId);
+ if (!state) return;
+
+ const { resourceList } = useFileStore.getState();
+
+ const resolveParentId = (key: string | null | undefined) => {
+ if (!key) return null;
+ // Prefer id match
+ const byId = resourceList.find(
+ (item) => item.knowledgeBaseId === knowledgeBaseId && item.id === key,
+ );
+ if (byId) return byId.id;
+ // Fallback to slug match
+ const bySlug = resourceList.find(
+ (item) => item.knowledgeBaseId === knowledgeBaseId && item.slug === key,
+ );
+ return bySlug?.id ?? key;
+ };
+
+ const buildChildrenFromStore = (parentKey: string | null) => {
+ const parentId = resolveParentId(parentKey);
+ const items = resourceList
+ .filter(
+ (item) =>
+ item.knowledgeBaseId === knowledgeBaseId &&
+ (item.parentId ?? null) === (parentId ?? null),
+ )
+ .map(resourceItemToTreeItem);
+
+ return sortTreeItems(items);
+ };
+
+ // Get list of all currently expanded folders before clearing
+ const expandedFoldersList = Array.from(state.expandedFolders);
+
+ // Clear all caches
+ state.folderChildrenCache.clear();
+ state.loadedFolders.clear();
+
+ // Reload each expanded folder
+ for (const folderKey of expandedFoldersList) {
+ // Prefer local store (explorer data) to avoid stale remote state
+ const localChildren = buildChildrenFromStore(folderKey);
+ if (localChildren.length > 0) {
+ state.folderChildrenCache.set(folderKey, localChildren);
+ state.loadedFolders.add(folderKey);
+ continue;
+ }
+
+ // Fallback to remote fetch if store has no data (e.g., initial load)
+ try {
+ const response = await fileService.getKnowledgeItems({
+ knowledgeBaseId,
+ parentId: folderKey,
+ showFilesInKnowledgeBase: false,
+ });
+
+ if (response?.items) {
+ const childItems = response.items.map((item) => ({
+ fileType: item.fileType,
+ id: item.id,
+ isFolder: item.fileType === 'custom/folder',
+ name: item.name,
+ slug: item.slug,
+ sourceType: item.sourceType,
+ url: item.url,
+ }));
+
+ // Sort children: folders first, then files
+ const sortedChildren = childItems.sort((a, b) => {
+ if (a.isFolder && !b.isFolder) return -1;
+ if (!a.isFolder && b.isFolder) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ state.folderChildrenCache.set(folderKey, sortedChildren);
+ state.loadedFolders.add(folderKey);
+ }
+ } catch (error) {
+ console.error(`Failed to reload folder ${folderKey}:`, error);
+ }
+ }
+
+ // Revalidate SWR caches for root and expanded folders to keep list and tree in sync
+ try {
+ const { mutate } = await import('swr');
+ const revalidateFolder = (parentId: string | null) =>
+ mutate(
+ [
+ 'useFetchKnowledgeItems',
+ {
+ knowledgeBaseId,
+ parentId,
+ showFilesInKnowledgeBase: false,
+ },
+ ],
+ undefined,
+ { revalidate: true },
+ );
+
+ await Promise.all([
+ revalidateFolder(null),
+ ...expandedFoldersList.map((folderKey) => revalidateFolder(folderKey)),
+ ]);
+ } catch (error) {
+ console.error('Failed to revalidate tree SWR cache:', error);
+ }
+
+ emitTreeRefresh(knowledgeBaseId);
+};
diff --git a/src/features/ResourceManager/components/LibraryHierarchy/types.ts b/src/features/ResourceManager/components/LibraryHierarchy/types.ts
new file mode 100644
index 0000000000..fedc7da99a
--- /dev/null
+++ b/src/features/ResourceManager/components/LibraryHierarchy/types.ts
@@ -0,0 +1,10 @@
+export interface TreeItem {
+ children?: TreeItem[];
+ fileType: string;
+ id: string;
+ isFolder: boolean;
+ name: string;
+ slug?: string | null;
+ sourceType?: string;
+ url: string;
+}
diff --git a/src/features/ResourceManager/components/Tree/index.tsx b/src/features/ResourceManager/components/Tree/index.tsx
deleted file mode 100644
index 644a4ecf4a..0000000000
--- a/src/features/ResourceManager/components/Tree/index.tsx
+++ /dev/null
@@ -1,883 +0,0 @@
-'use client';
-
-import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons';
-import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui';
-import { App, Input } from 'antd';
-import { createStaticStyles, cx } from 'antd-style';
-import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react';
-import * as motion from 'motion/react-m';
-import React, { memo, useCallback, useMemo, useReducer, useRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { VList } from 'virtua';
-
-import {
- getTransparentDragImage,
- useDragActive,
- useDragState,
-} from '@/app/[variants]/(main)/resource/features/DndContextWrapper';
-import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath';
-import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store';
-import FileIcon from '@/components/FileIcon';
-import { fileService } from '@/services/file';
-import { useFileStore } from '@/store/file';
-import type { ResourceItem } from '@/types/resource';
-
-import { useFileItemDropdown } from '../Explorer/ItemDropdown/useFileItemDropdown';
-import TreeSkeleton from './TreeSkeleton';
-
-// Module-level state to persist expansion across re-renders
-const treeState = new Map<
- string,
- {
- expandedFolders: Set;
- folderChildrenCache: Map;
- loadedFolders: Set;
- loadingFolders: Set;
- }
->();
-
-const TREE_REFRESH_EVENT = 'resource-tree-refresh';
-
-const emitTreeRefresh = (knowledgeBaseId: string) => {
- if (typeof window === 'undefined') return;
- window.dispatchEvent(
- new CustomEvent(TREE_REFRESH_EVENT, {
- detail: { knowledgeBaseId },
- }),
- );
-};
-
-const getTreeState = (knowledgeBaseId: string) => {
- if (!treeState.has(knowledgeBaseId)) {
- treeState.set(knowledgeBaseId, {
- expandedFolders: new Set(),
- folderChildrenCache: new Map(),
- loadedFolders: new Set(),
- loadingFolders: new Set(),
- });
- }
- return treeState.get(knowledgeBaseId)!;
-};
-
-/**
- * Clear and reload all expanded folders
- * This should be called along with file store's refreshFileList()
- * Simpler approach: reload all expanded folders to avoid ID vs slug issues
- */
-export const clearTreeFolderCache = async (knowledgeBaseId: string) => {
- const state = treeState.get(knowledgeBaseId);
- if (!state) return;
-
- const { resourceList } = useFileStore.getState();
-
- const resolveParentId = (key: string | null | undefined) => {
- if (!key) return null;
- // Prefer id match
- const byId = resourceList.find(
- (item) => item.knowledgeBaseId === knowledgeBaseId && item.id === key,
- );
- if (byId) return byId.id;
- // Fallback to slug match
- const bySlug = resourceList.find(
- (item) => item.knowledgeBaseId === knowledgeBaseId && item.slug === key,
- );
- return bySlug?.id ?? key;
- };
-
- const buildChildrenFromStore = (parentKey: string | null) => {
- const parentId = resolveParentId(parentKey);
- return resourceList
- .filter(
- (item) =>
- item.knowledgeBaseId === knowledgeBaseId &&
- (item.parentId ?? null) === (parentId ?? null),
- )
- .map((item) => item)
- .map((item) => ({
- fileType: item.fileType,
- id: item.id,
- isFolder: item.fileType === 'custom/folder',
- name: item.name,
- slug: item.slug,
- sourceType: item.sourceType,
- url: item.url || '',
- }))
- .sort((a, b) => {
- if (a.isFolder && !b.isFolder) return -1;
- if (!a.isFolder && b.isFolder) return 1;
- return a.name.localeCompare(b.name);
- });
- };
-
- // Get list of all currently expanded folders before clearing
- const expandedFoldersList = Array.from(state.expandedFolders);
-
- // Clear all caches
- state.folderChildrenCache.clear();
- state.loadedFolders.clear();
-
- // Reload each expanded folder
- for (const folderKey of expandedFoldersList) {
- // Prefer local store (explorer data) to avoid stale remote state
- const localChildren = buildChildrenFromStore(folderKey);
- if (localChildren.length > 0) {
- state.folderChildrenCache.set(folderKey, localChildren);
- state.loadedFolders.add(folderKey);
- continue;
- }
-
- // Fallback to remote fetch if store has no data (e.g., initial load)
- try {
- const response = await fileService.getKnowledgeItems({
- knowledgeBaseId,
- parentId: folderKey,
- showFilesInKnowledgeBase: false,
- });
-
- if (response?.items) {
- const childItems = response.items.map((item) => ({
- fileType: item.fileType,
- id: item.id,
- isFolder: item.fileType === 'custom/folder',
- name: item.name,
- slug: item.slug,
- sourceType: item.sourceType,
- url: item.url,
- }));
-
- // Sort children: folders first, then files
- const sortedChildren = childItems.sort((a, b) => {
- if (a.isFolder && !b.isFolder) return -1;
- if (!a.isFolder && b.isFolder) return 1;
- return a.name.localeCompare(b.name);
- });
-
- state.folderChildrenCache.set(folderKey, sortedChildren);
- state.loadedFolders.add(folderKey);
- }
- } catch (error) {
- console.error(`Failed to reload folder ${folderKey}:`, error);
- }
- }
-
- // Revalidate SWR caches for root and expanded folders to keep list and tree in sync
- try {
- const { mutate } = await import('swr');
- const revalidateFolder = (parentId: string | null) =>
- mutate(
- [
- 'useFetchKnowledgeItems',
- {
- knowledgeBaseId,
- parentId,
- showFilesInKnowledgeBase: false,
- },
- ],
- undefined,
- { revalidate: true },
- );
-
- await Promise.all([
- revalidateFolder(null),
- ...expandedFoldersList.map((folderKey) => revalidateFolder(folderKey)),
- ]);
- } catch (error) {
- console.error('Failed to revalidate tree SWR cache:', error);
- }
-
- emitTreeRefresh(knowledgeBaseId);
-};
-
-const styles = createStaticStyles(({ css, cssVar }) => ({
- dragging: css`
- will-change: transform;
- opacity: 0.5;
- `,
- fileItemDragOver: css`
- color: ${cssVar.colorBgElevated} !important;
- background-color: ${cssVar.colorText} !important;
-
- * {
- color: ${cssVar.colorBgElevated} !important;
- }
- `,
- treeItem: css`
- cursor: pointer;
- `,
-}));
-
-interface TreeItem {
- children?: TreeItem[];
- fileType: string;
- id: string;
- isFolder: boolean;
- name: string;
- slug?: string | null;
- sourceType?: string;
- url: string;
-}
-
-// Row component for folder / file tree (virtualized by flattening visible nodes)
-const FileTreeRow = memo<{
- expandedFolders: Set;
- folderChildrenCache: Map;
- item: TreeItem;
- level?: number;
- loadingFolders: Set;
- onLoadFolder: (_: string) => Promise;
- onToggleFolder: (_: string) => void;
- selectedKey: string | null;
- updateKey?: number;
-}>(
- ({
- item,
- level = 0,
- expandedFolders,
- loadingFolders,
- onToggleFolder,
- onLoadFolder,
- selectedKey,
-
- folderChildrenCache,
- }) => {
- const navigate = useNavigate();
- const { currentFolderSlug } = useFolderPath();
- const { message } = App.useApp();
-
- const [setMode, setCurrentViewItemId, libraryId] = useResourceManagerStore((s) => [
- s.setMode,
- s.setCurrentViewItemId,
- s.libraryId,
- ]);
-
- const renameFolder = useFileStore((s) => s.renameFolder);
-
- const [isRenaming, setIsRenaming] = useState(false);
- const [renamingValue, setRenamingValue] = useState(item.name);
- const inputRef = useRef(null);
-
- // Memoize computed values that don't change frequently
- const { itemKey } = useMemo(
- () => ({
- itemKey: item.slug || item.id,
- }),
- [item.slug, item.id],
- );
-
- const handleRenameStart = useCallback(() => {
- setIsRenaming(true);
- setRenamingValue(item.name);
- // Focus input after render
- setTimeout(() => {
- inputRef.current?.focus();
- inputRef.current?.select();
- }, 0);
- }, [item.name]);
-
- const handleRenameConfirm = useCallback(async () => {
- if (!renamingValue.trim()) {
- message.error('Folder name cannot be empty');
- return;
- }
-
- if (renamingValue.trim() === item.name) {
- setIsRenaming(false);
- return;
- }
-
- try {
- await renameFolder(item.id, renamingValue.trim());
- if (libraryId) {
- await clearTreeFolderCache(libraryId);
- }
- message.success('Renamed successfully');
- setIsRenaming(false);
- } catch (error) {
- console.error('Rename error:', error);
- message.error('Rename failed');
- }
- }, [item.id, item.name, libraryId, renamingValue, renameFolder, message]);
-
- const handleRenameCancel = useCallback(() => {
- setIsRenaming(false);
- setRenamingValue(item.name);
- }, [item.name]);
-
- const { menuItems } = useFileItemDropdown({
- fileType: item.fileType,
- filename: item.name,
- id: item.id,
- libraryId,
- onRenameStart: item.isFolder ? handleRenameStart : undefined,
- sourceType: item.sourceType,
- url: item.url,
- });
-
- const isDragActive = useDragActive();
- const { setCurrentDrag } = useDragState();
- const [isDragging, setIsDragging] = useState(false);
- const [isOver, setIsOver] = useState(false);
-
- // Memoize drag data to prevent recreation
- const dragData = useMemo(
- () => ({
- fileType: item.fileType,
- isFolder: item.isFolder,
- name: item.name,
- sourceType: item.sourceType,
- }),
- [item.fileType, item.isFolder, item.name, item.sourceType],
- );
-
- // Native HTML5 drag event handlers
- const handleDragStart = useCallback(
- (e: React.DragEvent) => {
- setIsDragging(true);
- setCurrentDrag({
- data: dragData,
- id: item.id,
- type: item.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';
- },
- [dragData, item.id, item.isFolder, setCurrentDrag],
- );
-
- const handleDragEnd = useCallback(() => {
- setIsDragging(false);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => {
- if (!item.isFolder || !isDragActive) return;
-
- e.preventDefault();
- e.stopPropagation();
- setIsOver(true);
- },
- [item.isFolder, isDragActive],
- );
-
- const handleDragLeave = useCallback(() => {
- setIsOver(false);
- }, []);
-
- const handleDrop = useCallback(() => {
- // Clear the highlight after drop
- setIsOver(false);
- }, []);
-
- const handleItemClick = useCallback(() => {
- // Open file modal using slug-based routing
- const currentPath = currentFolderSlug
- ? `/resource/library/${libraryId}/${currentFolderSlug}`
- : `/resource/library/${libraryId}`;
-
- setCurrentViewItemId(itemKey);
- navigate(`${currentPath}?file=${itemKey}`);
-
- if (itemKey.startsWith('doc')) {
- setMode('page');
- } else {
- // Set mode to 'file' immediately to prevent flickering to list view
- setMode('editor');
- }
- }, [itemKey, currentFolderSlug, libraryId, navigate, setMode, setCurrentViewItemId]);
-
- const handleFolderClick = useCallback(
- (folderId: string, folderSlug?: string | null) => {
- const navKey = folderSlug || folderId;
- navigate(`/resource/library/${libraryId}/${navKey}`);
-
- setMode('explorer');
- },
- [libraryId, navigate],
- );
-
- if (item.isFolder) {
- const isExpanded = expandedFolders.has(itemKey);
- const isActive = selectedKey === itemKey;
- const isLoading = loadingFolders.has(itemKey);
-
- const handleToggle = async () => {
- // Toggle folder expansion
- onToggleFolder(itemKey);
-
- // Only load if not already cached
- if (!isExpanded && !folderChildrenCache.has(itemKey)) {
- await onLoadFolder(itemKey);
- }
- };
-
- return (
-
- handleFolderClick(item.id, item.slug)}
- onContextMenu={(e) => {
- e.preventDefault();
- showContextMenu(menuItems());
- }}
- onDragEnd={handleDragEnd}
- onDragLeave={handleDragLeave}
- onDragOver={handleDragOver}
- onDragStart={handleDragStart}
- onDrop={handleDrop}
- paddingInline={4}
- style={{
- paddingInlineStart: level * 12 + 4,
- }}
- variant={isActive ? 'filled' : 'borderless'}
- >
- {isLoading ? (
-
- ) : (
-
- {
- e.stopPropagation();
- handleToggle();
- }}
- size={'small'}
- style={{ width: 20 }}
- />
-
- )}
-
-
- {isRenaming ? (
- setRenamingValue(e.target.value)}
- onClick={(e) => e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleRenameConfirm();
- } else if (e.key === 'Escape') {
- e.preventDefault();
- handleRenameCancel();
- }
- }}
- onPointerDown={(e) => e.stopPropagation()}
- ref={inputRef}
- size="small"
- style={{ flex: 1 }}
- value={renamingValue}
- />
- ) : (
-
- {item.name}
-
- )}
-
-
-
- );
- }
-
- // Render as file
- const isActive = selectedKey === itemKey;
- return (
-
- {
- e.preventDefault();
- showContextMenu(menuItems());
- }}
- onDragEnd={handleDragEnd}
- onDragStart={handleDragStart}
- paddingInline={4}
- style={{
- paddingInlineStart: level * 12 + 4,
- }}
- variant={isActive ? 'filled' : 'borderless'}
- >
-
-
- {item.sourceType === 'document' ? (
-
- ) : (
-
- )}
-
- {item.name}
-
-
-
-
- );
- },
-);
-
-FileTreeRow.displayName = 'FileTreeRow';
-
-/**
- * As a sidebar along with the Explorer to work
- */
-const FileTree = memo(() => {
- const { currentFolderSlug } = useFolderPath();
-
- const [useFetchKnowledgeItems, useFetchFolderBreadcrumb, useFetchKnowledgeItem] = useFileStore(
- (s) => [s.useFetchKnowledgeItems, s.useFetchFolderBreadcrumb, s.useFetchKnowledgeItem],
- );
-
- const [libraryId, currentViewItemId] = useResourceManagerStore((s) => [
- s.libraryId,
- s.currentViewItemId,
- ]);
-
- // Force re-render when tree state changes
- const [updateKey, forceUpdate] = useReducer((x) => x + 1, 0);
-
- // Get the persisted state for this knowledge base
- const state = React.useMemo(() => getTreeState(libraryId || ''), [libraryId]);
- const { expandedFolders, folderChildrenCache, loadingFolders } = state;
-
- // Fetch breadcrumb for current folder
- const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug);
-
- // Fetch current file when viewing a file
- const { data: currentFile } = useFetchKnowledgeItem(currentViewItemId);
-
- // Track parent folder key for file selection - stored in a ref to avoid hook order issues
- const parentFolderKeyRef = React.useRef(null);
-
- // Fetch root level data using SWR
- const { data: rootData, isLoading } = useFetchKnowledgeItems({
- knowledgeBaseId: libraryId,
- parentId: null,
- showFilesInKnowledgeBase: false,
- });
-
- // Sort items: folders first, then files
- const sortItems = useCallback((items: T[]): T[] => {
- return [...items].sort((a, b) => {
- // Folders first
- if (a.isFolder && !b.isFolder) return -1;
- if (!a.isFolder && b.isFolder) return 1;
- // Then alphabetically by name
- return a.name.localeCompare(b.name);
- });
- }, []);
-
- // Convert root data to tree items
- const items: TreeItem[] = React.useMemo(() => {
- if (!rootData) return [];
-
- const mappedItems: TreeItem[] = rootData.map((item) => ({
- fileType: item.fileType,
- id: item.id,
- isFolder: item.fileType === 'custom/folder',
- name: item.name,
- slug: item.slug,
- sourceType: item.sourceType,
- url: item.url,
- }));
-
- return sortItems(mappedItems);
- }, [rootData, sortItems, updateKey]);
-
- const visibleNodes = React.useMemo(() => {
- interface VisibleNode {
- item: TreeItem;
- key: string;
- level: number;
- }
-
- const result: VisibleNode[] = [];
-
- const walk = (nodes: TreeItem[], level: number) => {
- for (const node of nodes) {
- const key = node.slug || node.id;
-
- result.push({ item: node, key, level });
-
- if (!node.isFolder) continue;
- if (!expandedFolders.has(key)) continue;
-
- const children = folderChildrenCache.get(key);
- if (!children || children.length === 0) continue;
-
- walk(children, level + 1);
- }
- };
-
- walk(items, 0);
-
- return result;
- // NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute
- }, [items, expandedFolders, folderChildrenCache, updateKey]);
-
- const handleLoadFolder = useCallback(
- async (folderId: string) => {
- // Set loading state
- state.loadingFolders.add(folderId);
- forceUpdate();
-
- try {
- // Use SWR mutate to trigger a fetch that will be cached and shared with FileExplorer
- const { mutate: swrMutate } = await import('swr');
- const response = await swrMutate(
- [
- 'useFetchKnowledgeItems',
- {
- knowledgeBaseId: libraryId,
- parentId: folderId,
- showFilesInKnowledgeBase: false,
- },
- ],
- () =>
- fileService.getKnowledgeItems({
- knowledgeBaseId: libraryId,
- parentId: folderId,
- showFilesInKnowledgeBase: false,
- }),
- {
- revalidate: false, // Don't revalidate immediately after mutation
- },
- );
-
- if (!response || !response.items) {
- console.error('Failed to load folder contents: no data returned');
- return;
- }
-
- const childItems: TreeItem[] = response.items.map((item) => ({
- fileType: item.fileType,
- id: item.id,
- isFolder: item.fileType === 'custom/folder',
- name: item.name,
- slug: item.slug,
- sourceType: item.sourceType,
- url: item.url,
- }));
-
- // Sort children: folders first, then files
- const sortedChildren = sortItems(childItems);
-
- // Store children in cache
- state.folderChildrenCache.set(folderId, sortedChildren);
- state.loadedFolders.add(folderId);
- } catch (error) {
- console.error('Failed to load folder contents:', error);
- } finally {
- // Clear loading state
- state.loadingFolders.delete(folderId);
- // Trigger re-render
- forceUpdate();
- }
- },
- [libraryId, sortItems, state, forceUpdate],
- );
-
- const handleToggleFolder = useCallback(
- (folderId: string) => {
- if (state.expandedFolders.has(folderId)) {
- state.expandedFolders.delete(folderId);
- } else {
- state.expandedFolders.add(folderId);
- }
- // Trigger re-render
- forceUpdate();
- },
- [state, forceUpdate],
- );
-
- // Reset parent folder key when switching libraries
- React.useEffect(() => {
- parentFolderKeyRef.current = null;
- }, [libraryId]);
-
- // Listen for external tree refresh events (triggered when cache is cleared)
- React.useEffect(() => {
- if (typeof window === 'undefined') return;
-
- const handleTreeRefresh = (event: Event) => {
- const detail = (event as CustomEvent<{ knowledgeBaseId?: string }>).detail;
- if (detail?.knowledgeBaseId && libraryId && detail.knowledgeBaseId !== libraryId) return;
- forceUpdate();
- };
-
- window.addEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
- return () => {
- window.removeEventListener(TREE_REFRESH_EVENT, handleTreeRefresh);
- };
- }, [libraryId, forceUpdate]);
-
- // Auto-expand folders when navigating to a folder in Explorer
- React.useEffect(() => {
- if (!folderBreadcrumb || folderBreadcrumb.length === 0) return;
-
- let hasChanges = false;
-
- // Expand all folders in the breadcrumb path
- for (const crumb of folderBreadcrumb) {
- const key = crumb.slug || crumb.id;
- if (!state.expandedFolders.has(key)) {
- state.expandedFolders.add(key);
- hasChanges = true;
- }
-
- // Load folder contents if not already loaded
- if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
- handleLoadFolder(key);
- }
- }
-
- if (hasChanges) {
- forceUpdate();
- }
- }, [folderBreadcrumb, state, forceUpdate, handleLoadFolder]);
-
- // Auto-expand parent folder when viewing a file
- React.useEffect(() => {
- if (!currentFile || !currentViewItemId) {
- parentFolderKeyRef.current = null;
- return;
- }
-
- // If the file has a parent folder, expand the path to it
- if (currentFile.parentId) {
- // Fetch the parent folder's breadcrumb to get the full path
- const fetchParentPath = async () => {
- try {
- const parentBreadcrumb = await fileService.getFolderBreadcrumb(currentFile.parentId!);
-
- if (!parentBreadcrumb || parentBreadcrumb.length === 0) return;
-
- let hasChanges = false;
-
- // The last item in breadcrumb is the immediate parent folder
- const parentFolder = parentBreadcrumb.at(-1)!;
- const parentKey = parentFolder.slug || parentFolder.id;
- parentFolderKeyRef.current = parentKey;
-
- // Expand all folders in the parent's breadcrumb path
- for (const crumb of parentBreadcrumb) {
- const key = crumb.slug || crumb.id;
- if (!state.expandedFolders.has(key)) {
- state.expandedFolders.add(key);
- hasChanges = true;
- }
-
- // Load folder contents if not already loaded
- if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) {
- handleLoadFolder(key);
- }
- }
-
- if (hasChanges) {
- forceUpdate();
- }
- } catch (error) {
- console.error('Failed to fetch parent folder breadcrumb:', error);
- }
- };
-
- fetchParentPath();
- } else {
- parentFolderKeyRef.current = null;
- }
- }, [currentFile, currentViewItemId, state, forceUpdate, handleLoadFolder]);
-
- if (isLoading) {
- return ;
- }
-
- // Determine which item should be highlighted
- // If viewing a file, highlight its parent folder
- // Otherwise, highlight the current folder
- const selectedKey =
- currentViewItemId && parentFolderKeyRef.current
- ? parentFolderKeyRef.current
- : currentFolderSlug;
-
- return (
-
-
- {visibleNodes.map(({ item, key, level }) => (
-
-
-
- ))}
-
-
- );
-});
-
-FileTree.displayName = 'FileTree';
-
-export default FileTree;
diff --git a/src/features/ResourceManager/index.tsx b/src/features/ResourceManager/index.tsx
index bef8634060..81c2b80772 100644
--- a/src/features/ResourceManager/index.tsx
+++ b/src/features/ResourceManager/index.tsx
@@ -1,5 +1,6 @@
'use client';
+import { BRANDING_NAME } from '@lobechat/business-const';
import { Flexbox } from '@lobehub/ui';
import { createStyles, cssVar } from 'antd-style';
import dynamic from 'next/dynamic';
@@ -91,6 +92,8 @@ const ResourceManager = memo(() => {
prev.delete('file');
return prev;
});
+ // Reset document title to default
+ document.title = BRANDING_NAME;
};
return (
diff --git a/src/locales/default/auth.ts b/src/locales/default/auth.ts
index e0b72a63af..4943fe0fb3 100644
--- a/src/locales/default/auth.ts
+++ b/src/locales/default/auth.ts
@@ -194,7 +194,7 @@ export default {
'profile.usernameRule': 'Username can only contain letters, numbers, or underscores',
'profile.usernameUpdateFailed': 'Failed to update username, please try again later',
'signin.subtitle': 'Sign up or log in to your {{appName}} account',
- 'signin.title': 'For Agents collaboration',
+ 'signin.title': 'Where Agents Collaborate',
'signout': 'Log Out',
'signup': 'Sign Up',
'stats.aiheatmaps': 'Activity Index',
diff --git a/src/locales/default/file.ts b/src/locales/default/file.ts
index 79a963c8ff..665358c813 100644
--- a/src/locales/default/file.ts
+++ b/src/locales/default/file.ts
@@ -20,6 +20,7 @@ export default {
'header.actions.connect': 'Connect...',
'header.actions.createFolderError': 'Failed to create folder',
'header.actions.creatingFolder': 'Creating folder...',
+ 'header.actions.deleteLibrary': 'Delete Library',
'header.actions.gitignore.apply': 'Apply Rules',
'header.actions.gitignore.cancel': 'Ignore Rules',
'header.actions.gitignore.content':
@@ -118,6 +119,7 @@ export default {
'preview.downloadFile': 'Download File',
'preview.unsupportedFileAndContact':
'This file format is not currently supported for online preview. If you have a request for previewing, feel free to <1>contact us1>.',
+ 'resource': 'Resource',
'searchFilePlaceholder': 'Search Files',
'searchPagePlaceholder': 'Search Pages',
'tab.all': 'All',
diff --git a/src/locales/default/metadata.ts b/src/locales/default/metadata.ts
index d6948d58a5..1c39098373 100644
--- a/src/locales/default/metadata.ts
+++ b/src/locales/default/metadata.ts
@@ -3,7 +3,7 @@ export default {
'changelog.title': 'Changelog',
'chat.description':
'{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.',
- 'chat.title': '{{appName}} · For Collaborative Agents',
+ 'chat.title': '{{appName}} · Where Agents Collaborate',
'discover.assistants.description':
'Content, Q&A, images, video, voice, workflows—browse and add Agents from the Community.',
'discover.assistants.title': 'Agent Community',
@@ -30,5 +30,5 @@ export default {
'plugins.title': 'Skill Community',
'welcome.description':
'{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.',
- 'welcome.title': 'Welcome to {{appName}} · For Collaborative Agents',
+ 'welcome.title': 'Welcome to {{appName}} · Where Agents Collaborate',
};
diff --git a/src/server/services/document/index.ts b/src/server/services/document/index.ts
index 701ffc9182..e33774cc8b 100644
--- a/src/server/services/document/index.ts
+++ b/src/server/services/document/index.ts
@@ -86,6 +86,7 @@ export class DocumentService {
fileId,
fileType,
filename: title,
+ knowledgeBaseId, // Set knowledge_base_id column for all document types
metadata: finalMetadata,
pages: undefined,
parentId,
diff --git a/src/store/file/slices/fileManager/action.ts b/src/store/file/slices/fileManager/action.ts
index 1b7482bf96..a95ff1b3a5 100644
--- a/src/store/file/slices/fileManager/action.ts
+++ b/src/store/file/slices/fileManager/action.ts
@@ -307,6 +307,11 @@ export const createFileManageSlice: StateCreator<
revalidate: true,
},
);
+
+ // Also revalidate the ResourceManager resource list cache (SWR_RESOURCES)
+ // so uploaded files appear immediately in the Explorer without a full refresh.
+ const { revalidateResources } = await import('../resource/hooks');
+ await revalidateResources();
},
removeAllFiles: async () => {
await fileService.removeAllFiles();
@@ -543,10 +548,13 @@ export const createFileManageSlice: StateCreator<
}),
useFetchKnowledgeItem: (id) =>
- useClientDataSWR(!id ? null : ['useFetchKnowledgeItem', id], async () => {
- const response = await serverFileService.getKnowledgeItem(id!);
- return response ?? undefined;
- }),
+ useClientDataSWR(
+ !id ? null : ['useFetchKnowledgeItem', id],
+ async () => {
+ const response = await serverFileService.getKnowledgeItem(id!);
+ return response ?? undefined;
+ },
+ ),
useFetchKnowledgeItems: (params) =>
useClientDataSWR([FETCH_ALL_KNOWLEDGE_KEY, params], async () => {