{breadCrumbItems?.map((item: { isLast: any; title: string; href: string | UrlObject; }, index: number) => (
@@ -37,7 +37,7 @@ const Breadcrumb = ({ path }:any) => {
) : (
// middle item
- {item?.title}
+ {item?.title}
)}
diff --git a/moon/apps/web/components/CodeView/TreeView/CustomLabel.tsx b/moon/apps/web/components/CodeView/TreeView/CustomLabel.tsx
new file mode 100644
index 000000000..be3a7dec2
--- /dev/null
+++ b/moon/apps/web/components/CodeView/TreeView/CustomLabel.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+import { TreeItemLabel } from '@mui/x-tree-view/TreeItem';
+import { Box } from '@mui/material';
+
+interface CustomLabelProps {
+ children: React.ReactNode;
+ icon?: React.ElementType;
+ expandable?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+}
+
+// Custom label component used to render each node in the tree structure
+export function CustomLabel({ icon: Icon, children, onClick, ...other }: CustomLabelProps) {
+ return (
+
+ {Icon && (
+
+ )}
+ {
+ e.stopPropagation();
+ onClick?.(e);
+ }}
+ >
+ {children}
+
+
+ );
+}
diff --git a/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx b/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx
new file mode 100644
index 000000000..c2de354dd
--- /dev/null
+++ b/moon/apps/web/components/CodeView/TreeView/CustomTreeItem.tsx
@@ -0,0 +1,97 @@
+import * as React from 'react';
+import { CircularProgress } from '@mui/material';
+import {
+ TreeItemDragAndDropOverlay,
+ TreeItemIcon,
+ TreeItemProvider,
+ useTreeItem,
+ useTreeItemModel,
+ UseTreeItemParameters,
+} from '@mui/x-tree-view';
+import {
+ TreeItemContent,
+ TreeItemGroupTransition,
+ TreeItemIconContainer,
+ TreeItemRoot,
+} from '@mui/x-tree-view/TreeItem';
+import { styled, alpha } from '@mui/material/styles';
+import { MuiTreeNode, getIconFromFileType } from './TreeUtils';
+import { CustomLabel } from './CustomLabel';
+
+interface CustomTreeItemProps
+ extends Omit
,
+ Omit, 'onFocus'> {
+ onLabelClick?: (path: string, isDirectory: boolean) => void;
+ loadingDirectories?: Set;
+}
+
+// Custom tree structure node component, used to render elements such as icons and labels for each node
+export const CustomTreeItem = React.forwardRef(function CustomTreeItem(
+ { onLabelClick, loadingDirectories, ...props }: CustomTreeItemProps,
+ ref: React.Ref,
+) {
+ const { id, itemId, label, disabled, children, ...other } = props;
+ const {
+ getContextProviderProps,
+ getRootProps,
+ getContentProps,
+ getIconContainerProps,
+ getLabelProps,
+ getGroupTransitionProps,
+ getDragAndDropOverlayProps,
+ status,
+ } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
+
+ const item = useTreeItemModel(itemId)!;
+
+ // If it is a placeholder node, no content is rendered
+ if (item.isPlaceholder) {
+ return null;
+ }
+
+ let icon;
+
+ if (item.content_type === 'directory') {
+ icon = getIconFromFileType(item.content_type, status.expanded);
+ } else {
+ icon = getIconFromFileType(item.content_type, false);
+ }
+
+ // Check if the current node is loading
+ const isNodeLoading = loadingDirectories?.has(item.path);
+
+ const StyledGroupTransition = styled(TreeItemGroupTransition)(({ theme }) => ({
+ marginLeft: 15,
+ borderLeft: `1px dashed ${alpha(theme.palette.text.primary, 0.4)}`,
+ }));
+
+ return (
+
+
+
+
+ {isNodeLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {
+ if (item.content_type) {
+ onLabelClick?.(item.path, item.content_type === 'directory');
+ }
+ }}
+ />
+
+
+
+ {children && }
+
+
+ );
+});
diff --git a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
index 3272b81fe..b1d26a487 100644
--- a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
+++ b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
@@ -1,154 +1,18 @@
import * as React from 'react';
-import { useCallback, useEffect, useState, useRef } from 'react';
-import ArticleIcon from '@mui/icons-material/Article';
-import FolderRounded from '@mui/icons-material/FolderRounded';
-import FolderOpenIcon from '@mui/icons-material/FolderOpen';
+import { useCallback, useEffect, useState } from 'react';
import { Box, Skeleton } from '@mui/material';
-import {
- TreeItemDragAndDropOverlay,
- TreeItemIcon,
- TreeItemProvider,
- useTreeItem,
- useTreeItemModel,
- UseTreeItemParameters,
-} from '@mui/x-tree-view';
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
-import {
- TreeItemContent,
- TreeItemGroupTransition,
- TreeItemIconContainer,
- TreeItemLabel,
- TreeItemRoot,
-} from '@mui/x-tree-view/TreeItem';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
-import { styled, alpha } from '@mui/material/styles';
import { useGetTree } from '@/hooks/useGetTree';
-import { v4 as uuidv4 } from 'uuid';
-
-interface MuiTreeNode {
- id: string;
- label: string;
- path: string;
- content_type?: 'file' | 'directory';
- isLeaf?: boolean;
- children?: MuiTreeNode[];
- hasChildrenLoaded?: boolean;
- isPlaceholder?: boolean;
-}
-
-interface CustomLabelProps {
- children: React.ReactNode;
- icon?: React.ElementType;
- expandable?: boolean;
- onClick?: (event: React.MouseEvent) => void;
-}
-
-function CustomLabel({ icon: Icon, children, onClick, ...other }: CustomLabelProps) {
- return (
-
- {Icon && (
-
- )}
- {
- e.stopPropagation();
- onClick?.(e);
- }}
- >
- {children}
-
-
- );
-}
-
-const getIconFromFileType = (fileType: 'file' | 'directory' | undefined, isExpanded: boolean) => {
- switch (fileType) {
- case 'file':
- return ArticleIcon;
- case 'directory':
- return isExpanded ? FolderOpenIcon : FolderRounded;
- default:
- return ArticleIcon;
- }
-};
-
-interface CustomTreeItemProps
- extends Omit,
- Omit, 'onFocus'> {
- onLabelClick?: (path: string, isDirectory: boolean) => void;
-}
-
-const CustomTreeItem = React.forwardRef(function CustomTreeItem(
- { onLabelClick, ...props }: CustomTreeItemProps,
- ref: React.Ref,
-) {
- const { id, itemId, label, disabled, children, ...other } = props;
- const {
- getContextProviderProps,
- getRootProps,
- getContentProps,
- getIconContainerProps,
- getLabelProps,
- getGroupTransitionProps,
- getDragAndDropOverlayProps,
- status,
- } = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
-
- const item = useTreeItemModel(itemId)!;
-
- // 如果是占位节点,不渲染任何内容
- if (item.isPlaceholder) {
- return null;
- }
-
- let icon;
-
- if (item.content_type === 'directory') {
- icon = getIconFromFileType(item.content_type, status.expanded);
- } else {
- icon = getIconFromFileType(item.content_type, false);
- }
-
- const StyledGroupTransition = styled(TreeItemGroupTransition)(({ theme }) => ({
- marginLeft: 15,
- borderLeft: `1px dashed ${alpha(theme.palette.text.primary, 0.4)}`,
- }));
-
- return (
-
-
-
-
-
-
-
- {
- if (item.content_type) {
- onLabelClick?.(item.path, item.content_type === 'directory');
- }
- }}
- />
-
-
- {children && }
-
-
- );
-});
-
-const RepoTree = ({ flag, onCommitInfoChange }: { flag: string, onCommitInfoChange?:Function }) => {
+import { legacyApiClient } from '@/utils/queryClient';
+import { convertToTreeData, generateExpandedPaths, mergeTreeNodes, findNode } from './TreeUtils';
+import { CustomTreeItem } from './CustomTreeItem';
+import toast from 'react-hot-toast';
+import { useAtom } from 'jotai';
+import { expandedNodesAtom, treeAllDataAtom } from './codeTreeAtom';
+
+const RepoTree = ({ onCommitInfoChange }: { onCommitInfoChange?:Function }) => {
const router = useRouter();
const scope = router.query.org as string;
const pathname = usePathname();
@@ -157,527 +21,114 @@ const RepoTree = ({ flag, onCommitInfoChange }: { flag: string, onCommitInfoChan
''
) || '/';
- const [treeData, setTreeData] = useState([]);
- const [expandedNodes, setExpandedNodes] = useState([]);
- const [selectedNode, setSelectedNode] = useState(null);
- const [isInitialLoading, setIsInitialLoading] = useState(true);
+ const [treeAllData, setTreeAllData] = useAtom(treeAllDataAtom)
+ const [expandedNodes, setExpandedNodes] = useAtom(expandedNodesAtom)
+ const [loadingDirectories, setLoadingDirectories] = useState>(new Set());
- // 使用单个状态管理加载路径和节点
- const [loadingState, setLoadingState] = useState<{
- path: string;
- nodeId: string;
- isRefreshing: boolean;
- } | null>(null);
-
- const { data: treeItems, isLoading } = useGetTree({
- path: loadingState?.path || basePath
- });
-
- const currentPathNodeId = useRef(null);
- const hasSetInitialExpansion = useRef(false);
- const loadedNodes = useRef>(new Set());
-
- // 创建占位节点函数
- const createPlaceholderNode = (): MuiTreeNode => ({
- id: `placeholder-${uuidv4()}`,
- label: "placeholder",
- path: "",
- isLeaf: true,
- isPlaceholder: true
- });
-
- // 排序函数:目录在前,文件在后
- const sortProjectsByType = useCallback((projects: MuiTreeNode[]): MuiTreeNode[] => {
- if (!Array.isArray(projects) || projects.length === 0) {
- return [];
- }
-
- return [...projects].sort((a, b) => {
- if (a.content_type === 'directory' && b.content_type === 'file') {
- return -1;
- } else if (a.content_type === 'file' && b.content_type === 'directory') {
- return 1;
- } else {
- return a.label.localeCompare(b.label);
- }
- });
- }, []);
-
- // 处理树组件的加载数据
- const convertToTreeData = useCallback((responseData: any): MuiTreeNode[] => {
-
- if (!responseData?.data) {
- return [];
- }
+ const { data: treeItems } = useGetTree({ path: basePath });
- const data = responseData.data;
-
- // 1. 创建路径到节点的映射
- const pathToNodeMap = new Map();
+ // Set the expanded state on initialization
+ useEffect(() => {
+ const pathsToExpand = generateExpandedPaths(basePath);
+ const existingNode = findNode(treeAllData, basePath);
+ const hasRealData = existingNode?.children && !existingNode?.children[0].isPlaceholder
- // 2. 处理 file_tree 中的所有路径
- if (data.file_tree) {
- // 按路径长度排序,确保父节点先创建
- const sortedPaths = Object.keys(data.file_tree).sort((a, b) => a.split('/').length - b.split('/').length);
-
- sortedPaths.forEach(path => {
- const pathData = data.file_tree[path];
-
- if (!pathData) return;
-
- // 创建当前路径的节点(如果不存在)
- let currentNode = pathToNodeMap.get(path);
-
- if (!currentNode) {
- const label = path === '/' ? 'Repository' : path.split('/').pop() || '';
-
- currentNode = {
- id: path,
- label: label,
- path: path,
- content_type: 'directory',
- children: [],
- hasChildrenLoaded: path === basePath
- };
- pathToNodeMap.set(path, currentNode);
- }
-
- // 处理当前路径下的所有子项
- pathData.tree_items.forEach((item: any) => {
- const childPath = item.path;
-
- // 创建子节点(如果不存在)
- let childNode = pathToNodeMap.get(childPath);
-
- if (!childNode) {
-
- const isDirectory = item.content_type === 'directory';
-
- childNode = {
- id: childPath,
- label: item.name,
- path: childPath,
- content_type: item.content_type,
- isLeaf: !isDirectory,
- children: isDirectory ? [createPlaceholderNode()] : undefined,
- hasChildrenLoaded: false
- };
- pathToNodeMap.set(childPath, childNode);
- }
-
- // 确保子节点被添加到当前节点的children中
- if (currentNode.children && !currentNode.children.some(child => child.path === childPath)) {
- currentNode.children.push(childNode);
- }
- });
-
- // 排序当前节点的子节点
- if (currentNode.children) {
- currentNode.children = sortProjectsByType(currentNode.children);
- }
- });
+ if (!loadingDirectories.has(basePath) && !hasRealData && existingNode?.content_type === 'directory') {
+ setLoadingDirectories(prev => new Set(prev).add(basePath));
}
-
- // 3. 处理当前路径的 tree_items(覆盖file_tree中的数据)
- if (data.tree_items) {
- data.tree_items.forEach((item: any) => {
- const itemPath = item.path;
- const parentPath = itemPath.substring(0, itemPath.lastIndexOf('/')) || '/';
-
- // 获取父节点
- const parentNode = pathToNodeMap.get(parentPath);
-
- if (!parentNode) return;
-
- // 创建或更新当前节点
- let currentNode = pathToNodeMap.get(itemPath);
-
- if (!currentNode) {
+ setExpandedNodes(Array.from(new Set([...expandedNodes, ...pathsToExpand])));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [basePath]);
- const isDirectory = item.content_type === 'directory';
-
- currentNode = {
- id: itemPath,
- label: item.name,
- path: itemPath,
- content_type: item.content_type,
- isLeaf: !isDirectory,
- children: isDirectory ? [createPlaceholderNode()] : undefined,
- hasChildrenLoaded: false
- };
-
- pathToNodeMap.set(itemPath, currentNode);
-
- } else {
- // 更新现有节点
- currentNode.label = item.name;
- currentNode.content_type = item.content_type;
- currentNode.isLeaf = item.content_type !== 'directory';
- }
-
- // 确保当前节点被添加到父节点
- if (parentNode.children && !parentNode.children.some(child => child.path === itemPath)) {
- parentNode.children.push(currentNode);
- parentNode.children = sortProjectsByType(parentNode.children);
- }
-
- // 标记当前路径节点为已加载
- if (itemPath === basePath) {
- currentNode.hasChildrenLoaded = true;
- currentPathNodeId.current = currentNode.id;
- }
- });
- }
-
- // 4. 获取根节点
- const rootNode = pathToNodeMap.get('/');
-
- if (!rootNode) return [];
-
- // 5. 返回根节点的子节点
- return rootNode.children ? sortProjectsByType(rootNode.children) : [];
-
- }, [basePath, sortProjectsByType]);
-
- // 在 useEffect 中使用
useEffect(() => {
+ if (treeItems) {
+ const newPathTreeData = convertToTreeData(treeItems)
+ const newTreeAllData = mergeTreeNodes(treeAllData, newPathTreeData)
- if (!treeItems) return;
- // 获取完整的树结构
- const children = convertToTreeData(treeItems);
+ setTreeAllData(newTreeAllData)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [treeItems]);
- // 设置树数据
- setTreeData(children);
+ const handleNodeToggle = useCallback((_event: React.SyntheticEvent | null, nodeIds: string[]) => {
+ const newlyExpandedIds = nodeIds.filter(id => !expandedNodes.includes(id));
- // 自动展开当前路径的节点
- if (currentPathNodeId.current && !hasSetInitialExpansion.current) {
- // 确保只设置一次初始展开
- setExpandedNodes(prev => {
- // 如果当前节点不在已展开列表中,则添加
- if (!prev.includes(currentPathNodeId.current!)) {
- return [...prev, currentPathNodeId.current!];
- }
- return prev;
- });
+ newlyExpandedIds.forEach(nodeId => {
+ const existingNode = findNode(treeAllData, nodeId);
+ const hasRealData = existingNode?.children && !existingNode?.children[0].isPlaceholder
- // 标记已设置初始展开
- hasSetInitialExpansion.current = true;
- }
-
- setIsInitialLoading(false);
- }, [treeItems, convertToTreeData]);
-
- // 更新树中特定节点
- const updateTreeNode = useCallback((
- tree: MuiTreeNode[],
- nodeId: string,
- updateFn: (node: MuiTreeNode) => MuiTreeNode
- ): MuiTreeNode[] => {
- return tree.map(node => {
- if (node.id === nodeId) {
- return updateFn(node);
- }
- if (node.children && node.children.length > 0) {
- return {
- ...node,
- children: updateTreeNode(node.children, nodeId, updateFn)
- };
+ if (!loadingDirectories.has(nodeId) && !hasRealData) {
+ setLoadingDirectories(prev => new Set(prev).add(nodeId));
}
- return node;
});
- }, []);
- // 找到当前节点
- const findNode = useCallback(
- (data: MuiTreeNode[], nodeId: string): MuiTreeNode | null => {
- for (const node of data) {
- if (node.id === nodeId) return node;
- if (node.children) {
- const found = findNode(node.children, nodeId);
+ setExpandedNodes(nodeIds);
+ }, [expandedNodes, loadingDirectories, treeAllData, setLoadingDirectories, setExpandedNodes]);
- if (found) return found;
- }
- }
- return null;
- },
- [],
- );
-
- // 获取路径的所有父节点ID
- const getAllParentIds = useCallback((path: string): string[] => {
- if (!path || path === '/') return [];
-
- // 分割路径为各个部分
- const parts = path.split('/').filter(part => part !== '');
-
- // 生成所有父节点ID(包括自身)
- const parentIds: string[] = [];
- let currentPath = '';
-
- for (const part of parts) {
- currentPath = currentPath ? `${currentPath}/${part}` : `/${part}`;
- parentIds.push(currentPath);
- }
-
- return parentIds;
- }, []);
-
- // 监听 basePath 变化,自动展开路径
useEffect(() => {
+ loadingDirectories.forEach(path => {
+ legacyApiClient.v1.getApiTree().request({ path })
+ .then((response: any) => {
+ if (response) {
+ const newDirectoryData = convertToTreeData(response)
+ const newTreeAllData = mergeTreeNodes(treeAllData, newDirectoryData)
+
+ setTreeAllData(newTreeAllData)
+ }
+ })
+ .catch((_error: any) => {
+ toast.error('Loading failed.');
+ })
+ .finally(() => {
+ setLoadingDirectories(prev => {
+ const newSet = new Set(prev);
- if (basePath && !isInitialLoading && treeData.length > 0) {
-
- // 获取路径的所有父节点ID
- const parentIds = getAllParentIds(basePath);
-
- // 设置这些节点为展开状态
- setExpandedNodes(prev => {
- // 检查是否需要更新
- const hasNewNodes = parentIds.some(id => !prev.includes(id));
-
- if (!hasNewNodes) return prev;
-
- const newSet = new Set([...prev, ...parentIds]);
-
- return Array.from(newSet);
- });
-
- // 设置选中的节点
- setSelectedNode(basePath);
-
- // 确保路径中的所有节点都已加载
- parentIds.forEach(path => {
- const node = findNode(treeData, path);
-
- if (node && node.content_type === 'directory' && !loadedNodes.current.has(path)) {
- // 标记节点为正在加载
- loadedNodes.current.add(path);
-
- // 设置加载状态
- setLoadingState({
- path: node.path,
- nodeId: node.id,
- isRefreshing: false
- });
-
- // 设置节点加载状态
- setTreeData(prev =>
- updateTreeNode(prev, node.id, n => ({
- ...n,
- hasChildrenLoaded: false
- }))
- );
- }
- });
- }
- }, [basePath, isInitialLoading, treeData, findNode, updateTreeNode, getAllParentIds]);
-
- // 处理节点展开
- const handleNodeToggle = useCallback(
- (_event: React.SyntheticEvent | null, nodeIds: string[]) => {
- // 更新所有展开状态
- setExpandedNodes(nodeIds);
-
- // 找出新展开的节点
- const newlyExpandedIds = nodeIds.filter(id => !expandedNodes.includes(id));
-
- // 处理所有新展开的节点
- newlyExpandedIds.forEach(nodeId => {
- const targetNode = findNode(treeData, nodeId);
-
- // 检查节点是否是目录
- if (targetNode && targetNode.content_type === 'directory' && !loadedNodes.current.has(nodeId)) {
- // 标记节点为正在加载
- loadedNodes.current.add(nodeId);
-
- // 设置加载状态
- setLoadingState({
- path: targetNode.path,
- nodeId: targetNode.id,
- isRefreshing: false
+ newSet.delete(path);
+ return newSet;
});
-
- // 设置节点加载状态
- setTreeData(prev =>
- updateTreeNode(prev, targetNode.id, node => ({
- ...node,
- hasChildrenLoaded: false
- }))
- );
- }
- });
- },
- [expandedNodes, treeData, findNode, updateTreeNode]
- );
-
- // 处理子节点加载完成
- useEffect(() => {
-
- if (treeItems && !isLoading && loadingState) {
-
- const { nodeId } = loadingState;
-
- // 转换API数据为树节点
- const convertApiItems = (items: any[]): MuiTreeNode[] => {
- return items.map(item => {
-
- const isDirectory = item.content_type === 'directory';
-
- return {
- id: item.path,
- label: item.name,
- path: item.path,
- content_type: item.content_type,
- isLeaf: !isDirectory,
- children: isDirectory ? [createPlaceholderNode()] : undefined,
- hasChildrenLoaded: false
- };
});
- };
-
- // 从API响应中提取直接子节点
- const newChildren = treeItems.data?.tree_items
- ? convertApiItems(treeItems.data.tree_items)
- : [];
-
- // 更新树数据 - 保留已存在的子节点
- setTreeData(prev =>
- updateTreeNode(prev, nodeId, node => {
- // 保留已存在的非占位子节点
- const existingChildren = (node.children || [])
- .filter(child => !child.isPlaceholder);
-
- // 创建新子节点映射
- const newChildrenMap = new Map(newChildren.map(child => [child.id, child]));
-
- // 合并现有节点和新节点
- const mergedChildren = [
- ...existingChildren.filter(child => !newChildrenMap.has(child.id)),
- ...newChildren
- ];
-
- // 对子节点进行排序
- const sortedChildren = sortProjectsByType(mergedChildren);
-
- return {
- ...node,
- children: sortedChildren,
- hasChildrenLoaded: true
- };
- })
- );
-
- // 确保目标节点保持展开状态
- setExpandedNodes(prev => {
- if (!prev.includes(nodeId)) {
- return [...prev, nodeId];
- }
- return prev;
- });
-
- // 重置加载状态
- setLoadingState(null);
- }
- }, [treeItems, isLoading, loadingState, sortProjectsByType, updateTreeNode]);
-
- // 处理目录标签点击
- const handleLabelClick = useCallback((path: string, isDirectory: boolean) => {
- if (!isDirectory) return;
-
- // 构建完整路径并移除所有连续斜杠
- const fullPath = `/${scope}/code/tree${path}`;
- const cleanPath = fullPath.replace(/\/+/g, '/');
-
- // 更新 URL
- router.push(cleanPath);
-
- // 查找目标节点
- const targetNode = findNode(treeData, path);
-
- if (!targetNode) return;
-
- // 确保节点被展开
- setExpandedNodes(prev => {
- if (prev.includes(path)) {
- return prev;
- }
- return [...prev, path];
- });
-
- // 总是重新加载节点数据
- loadedNodes.current.delete(path); // 移除标记以重新加载
-
- // 设置加载状态
- setLoadingState({
- path: targetNode.path,
- nodeId: targetNode.id,
- isRefreshing: true
});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [loadingDirectories]);
- // 设置节点加载状态
- setTreeData(prev =>
- updateTreeNode(prev, targetNode.id, node => ({
- ...node,
- hasChildrenLoaded: false
- }))
- );
- }, [router, scope, findNode, treeData, updateTreeNode]);
-
- // 选择节点
- const handleNodeSelect = useCallback(
- (_event: React.SyntheticEvent | null, nodeId: string | null) => {
- if (!nodeId) return;
-
- setSelectedNode(nodeId);
- const node = findNode(treeData, nodeId);
-
- if (!node) return;
+ const handleLabelClick = useCallback((path: string, isDirectory: boolean) => {
+ if (isDirectory) {
+ const fullPath = `/${scope}/code/tree${path}`;
+ const cleanPath = fullPath.replace(/\/+/g, '/');
- // 只有当节点是文件时才跳转
- if (node.content_type === 'file' && flag === 'contents') {
- // 直接使用节点的完整路径构建跳转URL
- const filePath = node.path.startsWith('/') ? node.path : `/${node.path}`;
+ router.push(cleanPath);
+ } else {
+ const filePath = path.startsWith('/') ? path : `/${path}`;
- router.push(`/${scope}/code/blob${filePath}`);
- }
- },
- [findNode, treeData, scope, flag, router],
- );
+ router.push(`/${scope}/code/blob${filePath}`);
+ }
+ }, [router, scope]);
- // 将变化的basePath传回父组件,请求commit信息
useEffect(() => {
-
if (basePath) {
- // 更改commit接口的path
onCommitInfoChange?.(basePath);
}
}, [basePath, onCommitInfoChange]);
return (
<>
- {isInitialLoading ? (
-
-
-
- )
- : treeData?.length === 0 ? (
+ {treeAllData?.length === 0 ? (
)
: (
(
)
}}
diff --git a/moon/apps/web/components/CodeView/TreeView/TreeUtils.ts b/moon/apps/web/components/CodeView/TreeView/TreeUtils.ts
new file mode 100644
index 000000000..50347491c
--- /dev/null
+++ b/moon/apps/web/components/CodeView/TreeView/TreeUtils.ts
@@ -0,0 +1,313 @@
+import ArticleIcon from '@mui/icons-material/Article';
+import FolderRounded from '@mui/icons-material/FolderRounded';
+import FolderOpenIcon from '@mui/icons-material/FolderOpen';
+
+export interface MuiTreeNode {
+ id: string;
+ label: string;
+ path: string;
+ content_type?: 'file' | 'directory';
+ children?: MuiTreeNode[];
+ isPlaceholder?: boolean;
+}
+
+// Custom icon function, returns different icon components according to file type
+export const getIconFromFileType = (fileType: 'file' | 'directory' | undefined, isExpanded: boolean) => {
+ switch (fileType) {
+ case 'file':
+ return ArticleIcon;
+ case 'directory':
+ return isExpanded ? FolderOpenIcon : FolderRounded;
+ default:
+ return ArticleIcon;
+ }
+ };
+
+// Sort tree nodes: directories first, files second, and the same type sorted in alphabetical order by name
+export const sortProjectsByType = (projects: MuiTreeNode[]): MuiTreeNode[] => {
+ if (!Array.isArray(projects) || projects.length === 0) {
+ return [];
+ }
+
+ return [...projects].sort((a, b) => {
+ if (a.content_type === 'directory' && b.content_type === 'file') {
+ return -1;
+ } else if (a.content_type === 'file' && b.content_type === 'directory') {
+ return 1;
+ } else {
+ return a.label.localeCompare(b.label);
+ }
+ });
+};
+
+/**
+* Recursively search for a node with a specified ID in the tree.
+* @param data: Array of tree nodes
+* @param nodeId: ID of the node to search for
+* @returns: Found node or null
+*/
+export const findNode = (data: MuiTreeNode[], nodeId: string): MuiTreeNode | null => {
+ for (const node of data) {
+ if (node.id === nodeId) return node;
+ if (node.children) {
+ const found = findNode(node.children, nodeId);
+
+ if (found) return found;
+ }
+ }
+ return null;
+};
+
+/**
+* Get all parent node IDs for a specified path
+* @param path: A path string, such as '/a/b/c'
+* @returns: An array of parent node IDs, such as ['/a', '/a/b', '/a/b/c']
+*/
+export const getAllParentIds = (path: string): string[] => {
+ if (!path || path === '/') return [];
+
+ // Split the path into its parts
+ const parts = path.split('/').filter(part => part !== '');
+
+ // Generate all parent node IDs (including itself)
+ const parentIds: string[] = [];
+ let currentPath = '';
+
+ for (const part of parts) {
+ currentPath = currentPath ? `${currentPath}/${part}` : `/${part}`;
+ parentIds.push(currentPath);
+ }
+
+ return parentIds;
+};
+
+/**
+* Generates an array of node paths to be expanded based on the path.
+* @param path: A path string, such as '/a/b/c'
+* @returns: An array of expanded paths, such as ['/', '/a', '/a/b', '/a/b/c']
+*/
+export const generateExpandedPaths = (path: string): string[] => {
+ if (path === '/') return ['/'];
+
+ const segments = path.split('/').filter(Boolean);
+ const expandedPaths: string[] = ['/'];
+
+ let currentPath = '';
+
+ segments.forEach(segment => {
+ currentPath += `/${segment}`;
+ expandedPaths.push(currentPath);
+ });
+
+ return expandedPaths;
+};
+
+export const convertToTreeData = (responseData: any): MuiTreeNode[] => {
+ if (!responseData?.data) {
+ return [];
+ }
+ const data = responseData.data;
+
+ // Create a mapping of paths to nodes
+ const pathToNodeMap = new Map();
+
+ // 1. Process all paths in file_tree, sort by path length and ensure that parent nodes are created first
+ if (data.file_tree) {
+ // Fixed sorting logic: ensure parent paths are processed before child paths
+ const sortedPaths = Object.keys(data.file_tree).sort((a, b) => {
+ const aDepth = a === '/' ? 0 : a.split('/').length;
+ const bDepth = b === '/' ? 0 : b.split('/').length;
+
+ return aDepth - bDepth;
+ });
+
+ sortedPaths.forEach(path => {
+ const pathData = data.file_tree[path];
+
+ if (!pathData) return;
+
+ // Process all subitems under the current path
+ pathData.tree_items.forEach((item: any) => {
+ const itemPath = item.path;
+ const parentPath = itemPath.substring(0, itemPath.lastIndexOf('/')) || '/';
+
+ // Create the current node
+ const currentNode: MuiTreeNode = {
+ id: itemPath,
+ label: item.name,
+ path: itemPath,
+ content_type: item.content_type,
+ children: item.content_type === 'directory' ? [] : undefined
+ };
+
+ pathToNodeMap.set(itemPath, currentNode);
+
+ // Add the current node to the children of the parent node
+ if (parentPath !== '/') {
+ const parentNode = pathToNodeMap.get(parentPath);
+
+ if (parentNode && parentNode.children) {
+ parentNode.children.push(currentNode);
+ }
+ }
+ });
+ });
+ }
+
+ // 2. Process tree_items, updating existing nodes or adding new nodes
+ if (data.tree_items) {
+ data.tree_items.forEach((item: any) => {
+ const itemPath = item.path;
+ const parentPath = itemPath.substring(0, itemPath.lastIndexOf('/')) || '/';
+
+ let currentNode = pathToNodeMap.get(itemPath);
+
+ if (!currentNode) {
+ // Create a new node
+ currentNode = {
+ id: itemPath,
+ label: item.name,
+ path: itemPath,
+ content_type: item.content_type,
+ children: item.content_type === 'directory' ? [] : undefined
+ };
+ pathToNodeMap.set(itemPath, currentNode);
+ } else {
+ // Updating an existing node
+ currentNode.label = item.name;
+ currentNode.content_type = item.content_type;
+ if (item.content_type === 'directory' && !currentNode.children) {
+ currentNode.children = [];
+ }
+ }
+
+ // Adding a node to a parent node
+ if (parentPath !== '/') {
+ const parentNode = pathToNodeMap.get(parentPath);
+
+ if (parentNode && parentNode.children) {
+ // Check if it already exists
+ const existingIndex = parentNode.children.findIndex(child => child.path === itemPath);
+
+ if (existingIndex >= 0) {
+ parentNode.children[existingIndex] = currentNode;
+ } else {
+ parentNode.children.push(currentNode);
+ }
+ }
+ }
+ });
+ }
+
+ // 3. Build the list of children nodes of the root directory - fix the logic
+ const result: MuiTreeNode[] = [];
+
+ // Collect all root-level nodes (nodes whose parent path is '/')
+ pathToNodeMap.forEach((node, path) => {
+ const parentPath = path.substring(0, path.lastIndexOf('/')) || '/';
+
+ if (parentPath === '/') {
+ result.push(node);
+ }
+ });
+
+ // 4. Add placeholders for empty directories (but only for directories that actually have no children)
+ const addPlaceholdersToEmptyDirectories = (nodes: MuiTreeNode[]) => {
+ nodes.forEach(node => {
+ if (node.content_type === 'directory') {
+ // Checks if there are actual child nodes (excluding placeholders)
+ const hasRealChildren = node.children && node.children.some(child => !child.isPlaceholder);
+
+ if (!hasRealChildren) {
+ // A truly empty directory, add a placeholder
+ node.children = [{
+ id: `${node.path}-placeholder`,
+ label: 'placeholder',
+ path: `${node.path}-placeholder`,
+ content_type: 'file',
+ children: undefined,
+ isPlaceholder: true
+ }];
+ } else {
+ // Directories with subitems, processed recursively
+ if (node.children) {
+ addPlaceholdersToEmptyDirectories(node.children);
+ }
+ }
+ }
+ });
+ };
+
+ addPlaceholdersToEmptyDirectories(result);
+
+ // 5. Sort the children of each node
+ const sortNodeChildren = (nodes: MuiTreeNode[]) => {
+ nodes.forEach(node => {
+ if (node.children && node.children.length > 0) {
+ // Filter out placeholders and then sort
+ const nonPlaceholderChildren = node.children.filter(child => !child.isPlaceholder);
+
+ if (nonPlaceholderChildren.length > 0) {
+ node.children = sortProjectsByType(nonPlaceholderChildren);
+ sortNodeChildren(node.children);
+ }
+ }
+ });
+ };
+
+ sortNodeChildren(result);
+
+ // 6. Sort the root nodes and return them
+ return sortProjectsByType(result);
+};
+
+function mergeNode(node1: MuiTreeNode, node2: MuiTreeNode): MuiTreeNode {
+ if (node1.isPlaceholder && node2.isPlaceholder) {
+ return { ...node1 };
+ }
+ if (node1.isPlaceholder && !node2.isPlaceholder) {
+ return { ...node2 };
+ }
+ if (!node1.isPlaceholder && node2.isPlaceholder) {
+ return { ...node1 };
+ }
+
+ const merged: MuiTreeNode = { ...node1 };
+
+ if (node1.content_type === 'directory' || node2.content_type === 'directory') {
+ const children1 = node1.children || [];
+ const children2 = node2.children || [];
+
+ merged.children = mergeTreeNodes(children1, children2);
+ }
+ return merged;
+}
+
+export function mergeTreeNodes(nodes1: MuiTreeNode[], nodes2: MuiTreeNode[]): MuiTreeNode[] {
+ const map = new Map();
+
+ for (const node of nodes1) {
+ map.set(node.path, { ...node });
+ }
+
+ for (const node of nodes2) {
+ if (map.has(node.path)) {
+ const existing = map.get(node.path)!;
+
+ map.set(node.path, mergeNode(existing, node));
+ } else {
+ map.set(node.path, { ...node });
+ }
+ }
+
+ const result = Array.from(map.values()).map(n => {
+ if (n.children) {
+ n.children = sortProjectsByType(mergeTreeNodes(n.children, []));
+ }
+ return n;
+ });
+
+ return sortProjectsByType(result);
+}
+
+
diff --git a/moon/apps/web/components/CodeView/TreeView/codeTreeAtom.ts b/moon/apps/web/components/CodeView/TreeView/codeTreeAtom.ts
new file mode 100644
index 000000000..94ac2d95f
--- /dev/null
+++ b/moon/apps/web/components/CodeView/TreeView/codeTreeAtom.ts
@@ -0,0 +1,7 @@
+import { atomWithWebStorage } from '@/utils/atomWithWebStorage'
+import { MuiTreeNode } from './TreeUtils'
+
+
+export const treeAllDataAtom = atomWithWebStorage('treeAllDataAtom', [])
+
+export const expandedNodesAtom = atomWithWebStorage('expandedNodes', [])
diff --git a/moon/apps/web/pages/[org]/code/blob/[...path].tsx b/moon/apps/web/pages/[org]/code/blob/[...path].tsx
index 5ab838272..40dac57fc 100644
--- a/moon/apps/web/pages/[org]/code/blob/[...path].tsx
+++ b/moon/apps/web/pages/[org]/code/blob/[...path].tsx
@@ -8,20 +8,21 @@ import RepoTree from '@/components/CodeView/TreeView/RepoTree'
import { AppLayout } from '@/components/Layout/AppLayout'
import AuthAppProviders from '@/components/Providers/AuthAppProviders'
import { useGetBlob } from '@/hooks/useGetBlob'
+import { Theme } from '@radix-ui/themes'
const codeStyle = {
borderRadius: 8,
background: '#fff',
border: '1px solid #d1d9e0',
margin: '0 8px',
- // width: 'calc(80% - 8px)'
- width: '100%'
+ flexGrow: 1,
+ minWidth: 0
}
const treeStyle = {
borderRadius: 8,
overflow: 'hidden',
- width: 'calc(20% - 8px)',
+ width: '400px',
background: '#fff'
}
@@ -40,30 +41,28 @@ function BlobPage() {
}
return (
-
-
-
+
+
-
- {/* tree */}
-
-
-
-
-
-
-
+
)
}
diff --git a/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx b/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx
index de749500f..9b73f74a4 100644
--- a/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx
+++ b/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx
@@ -45,9 +45,9 @@ function TreeDetailPage() {
const treeStyle = {
borderRadius: 8,
- overflow: 'hidden',
- width: 'calc(20% - 8px)',
- minWidth: 'calc(20% - 8px)',
+ width: '400px',
+ minWidth: '0px',
+ flexShrink: 0,
background: '#fff'
}
@@ -59,18 +59,10 @@ function TreeDetailPage() {
background: '#fff'
}
- const breadStyle = {
- minHeight: 30,
- borderRadius: 8,
- overflow: 'hidden',
- width: 'calc(100% - 8px)',
- background: '#fff'
- }
-
return (
-
-
-
+
+
+
{canClone?.data && (
@@ -78,26 +70,28 @@ function TreeDetailPage() {
)}
- {/* tree */}
-
- setNewPath(path)} />
-
-
-
- {commitInfo && (
-
-
-
- )}
-
setNewPath(path)}
- readmeContent={readmeContent?.data}
- />
+
+ {/* tree */}
+
+ setNewPath(path)} />
+
+
+
+ {commitInfo && (
+
+
+
+ )}
+
setNewPath(path)}
+ readmeContent={readmeContent?.data}
+ />
+
-
+
)
}
@@ -116,9 +110,7 @@ TreeDetailPage.getProviders = (
) => {
return (
-
- {page}
-
+ {page}
)
}