mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-24 23:19:52 +08:00
fix(knowledge): sidebar tree — select never reloads, expand only via chevron
- Root-list loading no longer depends on currentFolderId, so selecting a folder (click or route change) never rebuilds this or any other space's tree; switching the active space leaves background trees untouched. - Clicking a folder row (or a space row) only selects/navigates; expanding and collapsing is exclusively the chevron's job. - Deep links (direct URL into a nested folder) still auto-expand the ancestor chain, fetching only levels not already cached, gated on the root list being genuinely loaded (fixes the reload race that left the tree half-expanded).
This commit is contained in:
@@ -60,6 +60,19 @@ function collectExpandedIds(nodes: TreeNode[], acc: Set<number>): Set<number> {
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Find a node anywhere in the tree by id, searching into loaded (but possibly
|
||||
* collapsed) children too — a node's `children` array persists after collapse. */
|
||||
function findNode(nodes: TreeNode[], id: number): TreeNode | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (Array.isArray(n.children)) {
|
||||
const found = findNode(n.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── Single node row ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TreeNodeRowProps {
|
||||
@@ -166,6 +179,11 @@ export function KnowledgeFolderTree({
|
||||
}: KnowledgeFolderTreeProps) {
|
||||
const [roots, setRoots] = useState<TreeNode[]>([]);
|
||||
const [rootLoading, setRootLoading] = useState(false);
|
||||
// True once the root list for the current (knowledgeId, fileStatus) has
|
||||
// finished loading. Gates the deep-link effect below — unlike rootLoading
|
||||
// (whose initial `false` would let that effect run against an empty tree
|
||||
// on mount), this only flips true after real data is in.
|
||||
const [rootsReady, setRootsReady] = useState(false);
|
||||
|
||||
// Mirror the latest tree into a ref so refreshTree can read it without
|
||||
// becoming a new function on every state change.
|
||||
@@ -175,59 +193,31 @@ export function KnowledgeFolderTree({
|
||||
}, [roots]);
|
||||
|
||||
// Load root folders on mount or when knowledgeId / fileStatus changes.
|
||||
// If a folder is currently selected (currentFolderId set), also fetch its
|
||||
// ancestor chain and pre-expand every ancestor so the selected folder is
|
||||
// visible without the user having to re-expand the tree manually after
|
||||
// collapse → expand of the parent space.
|
||||
// Deliberately independent of currentFolderId: selecting a folder (click or
|
||||
// route change) must never rebuild the tree. The deep-link effect below
|
||||
// handles the one case where the selected folder isn't in the tree yet.
|
||||
useEffect(() => {
|
||||
if (!knowledgeId) return;
|
||||
let cancelled = false;
|
||||
setRootLoading(true);
|
||||
setRootsReady(false);
|
||||
(async () => {
|
||||
try {
|
||||
const { items } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: null, file_status: fileStatus,
|
||||
});
|
||||
if (cancelled) return;
|
||||
let tree = mapToTree(items);
|
||||
|
||||
if (currentFolderId) {
|
||||
try {
|
||||
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
|
||||
if (!cancelled && parentPath?.length > 0) {
|
||||
const ancestorIds = new Set(parentPath.map(p => Number(p.id)));
|
||||
// Walk the tree; for each ancestor, fetch its children
|
||||
// and recurse so deeper ancestors also get expanded.
|
||||
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
return Promise.all(nodes.map(async (n) => {
|
||||
if (!ancestorIds.has(n.id)) return n;
|
||||
try {
|
||||
const { items: kids } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
|
||||
});
|
||||
const children = await expandChain(mapToTree(kids));
|
||||
return { ...n, expanded: true, loading: false, children };
|
||||
} catch {
|
||||
return { ...n, expanded: true, loading: false, children: [] };
|
||||
}
|
||||
}));
|
||||
};
|
||||
tree = await expandChain(tree);
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with collapsed tree
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setRoots(tree);
|
||||
if (!cancelled) setRoots(mapToTree(items));
|
||||
} catch {
|
||||
if (!cancelled) setRoots([]);
|
||||
} finally {
|
||||
if (!cancelled) setRootLoading(false);
|
||||
if (!cancelled) {
|
||||
setRootLoading(false);
|
||||
setRootsReady(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [knowledgeId, fileStatus, currentFolderId]);
|
||||
}, [knowledgeId, fileStatus]);
|
||||
|
||||
/** Immutably update a node anywhere in the tree by id. */
|
||||
const updateNode = useCallback((
|
||||
@@ -244,12 +234,14 @@ export function KnowledgeFolderTree({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback((node: TreeNode) => {
|
||||
// Toggle collapse if already expanded
|
||||
if (node.expanded) {
|
||||
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Expand a node without ever collapsing it. If its children were loaded
|
||||
* before (even while collapsed) this is a pure state toggle — no request;
|
||||
* only a never-loaded node fetches its own children (one level, same as
|
||||
* the expand arrow). Shared by the arrow and by folder-row clicks.
|
||||
*/
|
||||
const ensureExpanded = useCallback((node: TreeNode) => {
|
||||
if (node.expanded) return;
|
||||
|
||||
// If children already loaded, just toggle open
|
||||
if (Array.isArray(node.children)) {
|
||||
@@ -282,10 +274,84 @@ export function KnowledgeFolderTree({
|
||||
});
|
||||
}, [knowledgeId, fileStatus, updateNode]);
|
||||
|
||||
const handleExpand = useCallback((node: TreeNode) => {
|
||||
// Arrow keeps toggle semantics: collapse if already expanded.
|
||||
if (node.expanded) {
|
||||
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
|
||||
return;
|
||||
}
|
||||
ensureExpanded(node);
|
||||
}, [ensureExpanded, updateNode]);
|
||||
|
||||
// Clicking a folder row only selects it (route + highlight) — it never
|
||||
// expands/collapses children; that is exclusively the arrow's job. No tree
|
||||
// reload either: the row is already rendered, so its data is already local,
|
||||
// and the highlight follows the currentFolderId prop on re-render.
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
onSelectFolder({ id: String(node.id), name: node.name });
|
||||
}, [onSelectFolder]);
|
||||
|
||||
// Deep-link catch-up: when currentFolderId points at a folder that is NOT in
|
||||
// the local tree (direct URL visit, breadcrumb jump into a never-expanded
|
||||
// branch), fetch its ancestor chain and expand just the missing levels.
|
||||
// Folder-row clicks never enter here — a clickable row is already in the tree,
|
||||
// so findNode succeeds and this effect exits with zero requests.
|
||||
const handledDeepLinkRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
// Wait for the root list to be genuinely loaded — rootLoading's initial
|
||||
// `false` on mount would otherwise let this run against an empty tree.
|
||||
if (!knowledgeId || !currentFolderId || !rootsReady) return;
|
||||
if (findNode(rootsRef.current, Number(currentFolderId))) return;
|
||||
// One attempt per folder id — if the chain fetch fails (or the id is
|
||||
// stale/deleted), don't refetch on every roots change.
|
||||
if (handledDeepLinkRef.current === currentFolderId) return;
|
||||
handledDeepLinkRef.current = currentFolderId;
|
||||
|
||||
let cancelled = false;
|
||||
let completed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
|
||||
if (cancelled || !parentPath?.length) return;
|
||||
const ancestorIds = new Set(parentPath.map((p) => Number(p.id)));
|
||||
// Walk the current tree along the ancestor chain, reusing children
|
||||
// that are already loaded and fetching only the missing levels.
|
||||
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||
return Promise.all(nodes.map(async (n) => {
|
||||
if (!ancestorIds.has(n.id)) return n;
|
||||
try {
|
||||
let children = n.children;
|
||||
if (!Array.isArray(children)) {
|
||||
const { items } = await listKnowledgeFolders({
|
||||
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
|
||||
});
|
||||
children = mapToTree(items);
|
||||
}
|
||||
return { ...n, expanded: true, loading: false, children: await expandChain(children) };
|
||||
} catch {
|
||||
return { ...n, expanded: true, loading: false, children: n.children ?? [] };
|
||||
}
|
||||
}));
|
||||
};
|
||||
const fresh = await expandChain(rootsRef.current);
|
||||
if (!cancelled) setRoots(fresh);
|
||||
} catch {
|
||||
// ignore — leave the tree as is; the user can expand manually.
|
||||
} finally {
|
||||
completed = true;
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// A run cancelled mid-flight (deps changed, StrictMode double-mount)
|
||||
// didn't actually expand anything — release the once-per-id mark so
|
||||
// the next run for this folder id can try again.
|
||||
if (!completed && handledDeepLinkRef.current === currentFolderId) {
|
||||
handledDeepLinkRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [knowledgeId, fileStatus, currentFolderId, rootsReady]);
|
||||
|
||||
// Re-fetch a freshly-loaded subtree, re-expanding nodes that were open before.
|
||||
const rebuildWithExpansion = useCallback(async (
|
||||
nodes: TreeNode[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge";
|
||||
import {
|
||||
@@ -63,6 +63,9 @@ export default function KnowledgeSpaceItem({
|
||||
}: KnowledgeSpaceItemProps) {
|
||||
const localize = useLocalize();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Initial expansion mirrors the mount-time active state (deep link / reload
|
||||
// restores the tree). After mount, clicking the space row only selects it —
|
||||
// expansion is exclusively the chevron's job (same rule as folder rows).
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
// Right-click context menu mirrors the "..." action menu, positioned at the cursor.
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
@@ -75,11 +78,6 @@ export default function KnowledgeSpaceItem({
|
||||
const treeEnabled =
|
||||
bsConfig?.knowledge_space?.tree_structured_directory_display ?? true;
|
||||
|
||||
// Auto-expand when this space becomes active
|
||||
useEffect(() => {
|
||||
if (isActive) setExpanded(true);
|
||||
}, [isActive]);
|
||||
|
||||
// Only highlight the space row when this space is active AND no folder
|
||||
// inside it is selected — folders take over the active styling once chosen
|
||||
// so only one row in the tree appears active at a time.
|
||||
|
||||
Reference in New Issue
Block a user