diff --git a/moon/apps/web/components/CodeView/CodeTable.tsx b/moon/apps/web/components/CodeView/CodeTable.tsx
index 848dd4c0f..592479c45 100644
--- a/moon/apps/web/components/CodeView/CodeTable.tsx
+++ b/moon/apps/web/components/CodeView/CodeTable.tsx
@@ -3,12 +3,13 @@
import 'github-markdown-css/github-markdown-light.css'
import { useMemo } from 'react'
-import { DocumentIcon, FolderIcon } from '@heroicons/react/20/solid'
+import { FolderIcon } from '@heroicons/react/20/solid'
import { formatDistance, fromUnixTime } from 'date-fns'
import { usePathname, useRouter } from 'next/navigation'
import RTable from './Table'
import { columnsType, DirectoryType } from './Table/type'
import Markdown from 'react-markdown'
+import FileIcon from './FileIcon/FileIcon'
export interface DataType {
oid: string
@@ -18,7 +19,7 @@ export interface DataType {
date: number
}
-const CodeTable = ({ directory, loading, readmeContent}: any) => {
+const CodeTable = ({ directory, loading, readmeContent, onCommitInfoChange}: any) => {
const router = useRouter()
const pathname = usePathname()
let real_path = pathname?.replace('/tree', '')
@@ -41,8 +42,9 @@ const CodeTable = ({ directory, loading, readmeContent}: any) => {
<>
{record.content_type === 'directory' &&
}
- {record.content_type === 'file' &&
}
+ {record.content_type === 'file' &&
}
{record.name}
+
>
)
@@ -52,9 +54,6 @@ const CodeTable = ({ directory, loading, readmeContent}: any) => {
dataIndex: ['commit_message'],
key: 'commit_message',
render: (_, {commit_message}) => (
-
- // console.log(message, 'message')
-
{commit_message}
)
},
@@ -97,6 +96,8 @@ const CodeTable = ({ directory, loading, readmeContent}: any) => {
pathParts?.push(encodeURIComponent(record.name))
newPath = `/${pathParts?.join('/')}`
+
+ onCommitInfoChange?.(newPath);
router.push(newPath)
}
}
diff --git a/moon/apps/web/components/CodeView/FileIcon/FileIcon.tsx b/moon/apps/web/components/CodeView/FileIcon/FileIcon.tsx
new file mode 100644
index 000000000..05b7050f3
--- /dev/null
+++ b/moon/apps/web/components/CodeView/FileIcon/FileIcon.tsx
@@ -0,0 +1,11 @@
+import { getIcon } from 'material-file-icons';
+
+function FileIcon({ filename, style, className }:any) {
+ return ;
+}
+
+export default FileIcon;
\ No newline at end of file
diff --git a/moon/apps/web/components/CodeView/Table/index.tsx b/moon/apps/web/components/CodeView/Table/index.tsx
index 70cd875f7..0ed8af61f 100644
--- a/moon/apps/web/components/CodeView/Table/index.tsx
+++ b/moon/apps/web/components/CodeView/Table/index.tsx
@@ -1,8 +1,9 @@
-import { Spinner, Table } from '@radix-ui/themes'
-
+import { Table } from '@radix-ui/themes'
import { columnsType, DirectoryType } from './type'
+import { Skeleton } from '@mui/material';
+import { useMemo } from 'react';
-const table = ({
+const TableComponent = ({
columns,
datasource,
size,
@@ -19,48 +20,59 @@ const table = ({
onClick?: (record: T) => void
loading?: boolean
}) => {
+ // 使用useMemo缓存列配置,只有当columns数组变化时才重新计算
+ const memoizedColumns = useMemo(() => columns,[columns]);
+
return (
- <>
-
-
-
-
- {columns.map((c) => (
- {c.title}
+
+
+
+ {memoizedColumns.map((c) => (
+ {c.title}
+ ))}
+
+
+
+
+ {loading ? (
+ // 骨架屏行
+ Array.from({ length: 5 }).map((_, rowIndex) => {
+ const uniqueKey = `skeleton-row-${rowIndex}`; // 生成唯一 key
+
+ return (
+
+ {memoizedColumns.map((column) => (
+
+
+
+ ))}
+
+ );
+ })
+ ) : datasource.length > 0 && (
+ // 实际数据行
+ datasource.map((d, index) => {
+ const uniqueKey = `row-${index}`;
+
+ return
+ {memoizedColumns.map((c) => (
+ {
+ e.stopPropagation();
+ onClick?.(d);
+ }}
+ justify={justify}
+ key={c.key || c.title}
+ >
+ {c.render ? c.render(c.dataIndex[0], d, index) : null}
+
))}
-
-
-
- {datasource.map((d, index) => {
- if (d) {
- return (
- // eslint-disable-next-line react/no-array-index-key
-
- {columns.map((c, index) => (
- {
- e.stopPropagation()
- onClick?.(d)
- }}
- justify={justify}
- // eslint-disable-next-line react/no-array-index-key
- key={c.key + index}
- >
- {c.render ? c.render(c.dataIndex[0], d, index) : null}
-
- ))}
-
- )
- } else {
- return null
- }
- })}
-
-
-
- >
- )
-}
+ })
+ )}
+
+
+ );
+};
-export default table
+export default TableComponent;
\ No newline at end of file
diff --git a/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx b/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx
index ff70ce9ff..a6f7a30e7 100644
--- a/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx
+++ b/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx
@@ -1,19 +1,18 @@
import React, { useEffect, useState } from 'react'
-import { Button, Input, Popover, Space, Tabs, TabsProps } from 'antd'
+import { Button, cn, Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@gitmono/ui'
import copy from 'copy-to-clipboard'
import { usePathname } from 'next/navigation'
import { CheckIcon, CopyIcon, DownloadIcon } from '@gitmono/ui/Icons'
import {LEGACY_API_URL} from '@gitmono/config'
+import { Box, Flex, Tabs } from '@radix-ui/themes'
const CloneTabs = ({ endpoint }: any) => {
const pathname = usePathname()
const [text, setText] = useState(pathname || '')
const [copied, setCopied] = useState(false)
- const [active_tab, setActiveTab] = useState('1')
-
- const onChange = (key: string) => {
- setActiveTab(key)
- }
+ const [active_tab, setActiveTab] = useState('HTTP')
+ const [open, setOpen] = useState(false)
+ const url = new URL(LEGACY_API_URL)
useEffect(() => {
if (LEGACY_API_URL) {
@@ -33,37 +32,78 @@ const CloneTabs = ({ endpoint }: any) => {
setTimeout(() => setCopied(false), 2000) // Reset after 2 seconds
}
- const tab_items: TabsProps['items'] = [
+ const tabContent = [
{
- key: '1',
- label: 'HTTP',
- children: (
-
-
- : } size={'small'} />
-
- )
+ value:'HTTP',
+ inputValue:`${url.href}${pathname?.replace('/myorganization/code/tree/', '')}.git`,
+ info:'Clone using the web URL.'
},
{
- key: '2',
- label: 'SSH',
- children: (
-
-
- : } size={'small'} />
-
- )
+ value:'SSH',
+ inputValue:`ssh://git@${url.host}${pathname?.replace('/myorganization/code/tree', '')}.git`,
+ info:'Use a password-protected SSH key.'
}
]
return (
- }
- trigger='click'
- >
- }>Code
+ <>
+
+
+
+
+
+ {open && (
+ e.preventDefault()}
+ asChild
+ addDismissibleLayer
+ >
+
+
+
+
+
+
+
+
+
+
+ {
+ tabContent?.map((_item)=>{
+ return (
+
+
+
+
+
+
+ {_item.info}
+
+
+ )
+ })
+ }
+
+
+
+
+ )}
+
+
+>
)
}
diff --git a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
index c2686cfd6..3272b81fe 100644
--- a/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
+++ b/moon/apps/web/components/CodeView/TreeView/RepoTree.tsx
@@ -1,9 +1,9 @@
import * as React from 'react';
-import { useCallback, useEffect, useState } 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 { Box, CircularProgress, Skeleton } from '@mui/material';
+import { Box, Skeleton } from '@mui/material';
import {
TreeItemDragAndDropOverlay,
TreeItemIcon,
@@ -27,29 +27,24 @@ import { useGetTree } from '@/hooks/useGetTree';
import { v4 as uuidv4 } from 'uuid';
interface MuiTreeNode {
- id?: string;
- label?: string;
- path?: string;
- content_type?: FileType;
- isLeaf?: boolean;
- children?: MuiTreeNode[];
-}
-
-type FileType = 'file' | 'directory';
-
-interface ExtendedTreeItemProps {
- content_type?: FileType;
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, ...other }: CustomLabelProps) {
+function CustomLabel({ icon: Icon, children, onClick, ...other }: CustomLabelProps) {
return (
)}
- {children}
+ {
+ e.stopPropagation();
+ onClick?.(e);
+ }}
+ >
+ {children}
+
);
}
-const getIconFromFileType = (fileType: FileType, isExpanded:Boolean) => {
+const getIconFromFileType = (fileType: 'file' | 'directory' | undefined, isExpanded: boolean) => {
switch (fileType) {
case 'file':
return ArticleIcon;
case 'directory':
- return isExpanded ? FolderOpenIcon :FolderRounded;
+ return isExpanded ? FolderOpenIcon : FolderRounded;
default:
return ArticleIcon;
}
@@ -80,10 +83,11 @@ const getIconFromFileType = (fileType: FileType, isExpanded:Boolean) => {
interface CustomTreeItemProps
extends Omit,
Omit, 'onFocus'> {
- }
+ onLabelClick?: (path: string, isDirectory: boolean) => void;
+}
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
- { ...props }: CustomTreeItemProps,
+ { onLabelClick, ...props }: CustomTreeItemProps,
ref: React.Ref,
) {
const { id, itemId, label, disabled, children, ...other } = props;
@@ -98,12 +102,18 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
- const item = useTreeItemModel(itemId)!;
+ const item = useTreeItemModel(itemId)!;
+
+ // 如果是占位节点,不渲染任何内容
+ if (item.isPlaceholder) {
+ return null;
+ }
+
let icon;
- if (status.expandable) {
- icon = getIconFromFileType(item.content_type || 'file', status.expanded);
- } else if (item.content_type) {
+ if (item.content_type === 'directory') {
+ icon = getIconFromFileType(item.content_type, status.expanded);
+ } else {
icon = getIconFromFileType(item.content_type, false);
}
@@ -115,155 +125,273 @@ const CustomTreeItem = React.forwardRef(function CustomTreeItem(
return (
- {item.label === '加载中...' ? (
-
-
-
-
-
- ) : (
-
-
-
-
-
- {/* label */}
-
-
-
- )}
+
+
+
+
+
+ {
+ if (item.content_type) {
+ onLabelClick?.(item.path, item.content_type === 'directory');
+ }
+ }}
+ />
+
+
{children && }
);
});
-const RepoTree = ( {flag, directory }: {flag:string, directory: any[] }) => {
+const RepoTree = ({ flag, onCommitInfoChange }: { flag: string, onCommitInfoChange?:Function }) => {
const router = useRouter();
const scope = router.query.org as string;
const pathname = usePathname();
const basePath = pathname?.replace(
new RegExp(`\\/${scope}\\/code\\/(tree|blob)`),
''
- );
+ ) || '/';
const [treeData, setTreeData] = useState([]);
-
const [expandedNodes, setExpandedNodes] = useState([]);
const [selectedNode, setSelectedNode] = useState(null);
-
- const [loadPath, setLoadPath] = useState(null);
- const [targetNodeId, setTargetNodeId] = useState(null);
- // const [loadingError, setLoadingError] = useState(null);
const [isInitialLoading, setIsInitialLoading] = useState(true);
- const { data: response, isLoading, error } = useGetTree({ path: loadPath??undefined});
-
- const convertToTreeData = useCallback((parentBasePath: string, directory: any[]) => {
- if (!Array.isArray(directory) || directory.length === 0) {
- // console.warn('convertToTreeData: 接收到非数组或空数据', directory);
- return [];
- }
-
- return sortProjectsByType(directory).map((item) => {
-
- const nodeName = item?.name ?? '';
- const currentPath = flag === 'contents' ?
- `${parentBasePath}/${nodeName}`.replace('//', '/') || '/'
- : `${parentBasePath}`.replace('//', '/') || '/'
-
- // eslint-disable-next-line no-console
- // console.log('生成节点路径:', nodeName, '=>', currentPath);
-
- return {
- id: uuidv4(),
- label: item.name,
- path: currentPath,
- isLeaf: item.content_type !== 'directory',
- content_type: item.content_type,
- children: item.content_type === 'directory' ? [
- {
- id: uuidv4(),
- label: '加载中...',
- isLeaf: true,
- },
- ] : undefined,
- };
- });
- }, [flag]);
-
- useEffect(() => {
- if (!Array.isArray(directory) || directory.length === 0) {
- // console.warn('RepoTree: 接收到非数组或空的初始数据', directory);
- setIsInitialLoading(false);
- return;
- }
-
- const rootPath = basePath || '/'; // 使用基路径作为根路径
-
- setTreeData(convertToTreeData(rootPath, directory));
- setIsInitialLoading(false);
- }, [directory, convertToTreeData, basePath]);
-
- const sortProjectsByType = (projects: any[]) => {
+
+ // 使用单个状态管理加载路径和节点
+ 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) {
- // console.warn('sortProjectsByType: 接收到非数组或空数据', projects);
return [];
}
- return projects.sort((a, b) => {
+ 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 0;
+ return a.label.localeCompare(b.label);
}
});
- };
+ }, []);
- const updateTreeData = useCallback(
- (currentTree: MuiTreeNode[], nodeId: string, newChildren: MuiTreeNode[]): any => {
- if (!Array.isArray(currentTree) || currentTree.length === 0) {
- // console.warn('updateTreeData: 接收到非数组或空的当前树数据', currentTree);
- return [];
- }
-
- return currentTree.map((node) => {
+ // 处理树组件的加载数据
+ const convertToTreeData = useCallback((responseData: any): MuiTreeNode[] => {
+
+ if (!responseData?.data) {
+ return [];
+ }
- if (node.id === nodeId) {
- return {
- ...node,
- children: newChildren,
- isLeaf: newChildren.length === 0,
+ const data = responseData.data;
+
+ // 1. 创建路径到节点的映射
+ const pathToNodeMap = new Map();
+
+ // 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);
}
- if (node.children && node.children.length > 0) {
- return {
- ...node,
- children: updateTreeData(node.children, nodeId, newChildren),
+
+ // 处理当前路径下的所有子项
+ 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);
+ }
+ });
+ }
+
+ // 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) {
+
+ 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;
}
- return node;
});
- },
- [],
- );
+ }
+
+ // 4. 获取根节点
+ const rootNode = pathToNodeMap.get('/');
+
+ if (!rootNode) return [];
+
+ // 5. 返回根节点的子节点
+ return rootNode.children ? sortProjectsByType(rootNode.children) : [];
+
+ }, [basePath, sortProjectsByType]);
+
+ // 在 useEffect 中使用
+ useEffect(() => {
+
+ if (!treeItems) return;
+ // 获取完整的树结构
+ const children = convertToTreeData(treeItems);
+ // 设置树数据
+ setTreeData(children);
+
+ // 自动展开当前路径的节点
+ if (currentPathNodeId.current && !hasSetInitialExpansion.current) {
+ // 确保只设置一次初始展开
+ setExpandedNodes(prev => {
+ // 如果当前节点不在已展开列表中,则添加
+ if (!prev.includes(currentPathNodeId.current!)) {
+ return [...prev, currentPathNodeId.current!];
+ }
+ return prev;
+ });
+
+ // 标记已设置初始展开
+ 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)
+ };
+ }
+ return node;
+ });
+ }, []);
+
+ // 找到当前节点
const findNode = useCallback(
(data: MuiTreeNode[], nodeId: string): MuiTreeNode | null => {
- if (!Array.isArray(data) || data.length === 0) {
- return null;
- }
-
for (const node of data) {
-
if (node.id === nodeId) return node;
-
if (node.children) {
const found = findNode(node.children, nodeId);
@@ -275,131 +403,286 @@ const RepoTree = ( {flag, directory }: {flag:string, directory: any[] }) => {
[],
);
- const handleNodeToggle = useCallback(
- (_event: React.SyntheticEvent | null, nodeIds: string[]) => {
- const newlyExpandedId = nodeIds.find((id) => !expandedNodes.includes(id));
+ // 获取路径的所有父节点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(() => {
+
+ if (basePath && !isInitialLoading && treeData.length > 0) {
+
+ // 获取路径的所有父节点ID
+ const parentIds = getAllParentIds(basePath);
- if (newlyExpandedId) {
- const targetNode = findNode(treeData, newlyExpandedId);
+ // 设置这些节点为展开状态
+ setExpandedNodes(prev => {
+ // 检查是否需要更新
+ const hasNewNodes = parentIds.some(id => !prev.includes(id));
- const reqPath = targetNode?.path?.startsWith('/')
- ? targetNode.path
- : `/${targetNode?.path}`;
+ if (!hasNewNodes) return prev;
+
+ const newSet = new Set([...prev, ...parentIds]);
- // eslint-disable-next-line no-console
- // console.log(targetNode?.path, 'reqPath=1')
+ 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
+ });
- // eslint-disable-next-line no-console
- // console.log(reqPath,'reqPath=reqPath')
-
- if (
- targetNode &&
- targetNode.content_type === 'directory' &&
- targetNode.children?.[0]?.label === '加载中...' // 中文判断
- ) {
- setLoadPath(reqPath);
- setTargetNodeId(newlyExpandedId);
- // setLoadingError(null);
+ // 设置节点加载状态
+ 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
+ });
+
+ // 设置节点加载状态
+ setTreeData(prev =>
+ updateTreeNode(prev, targetNode.id, node => ({
+ ...node,
+ hasChildrenLoaded: false
+ }))
+ );
+ }
+ });
},
- [expandedNodes, treeData, setLoadPath, setTargetNodeId, findNode],
+ [expandedNodes, treeData, findNode, updateTreeNode]
);
+ // 处理子节点加载完成
useEffect(() => {
- if (!loadPath) return;
+
+ 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, '/');
- if (response && !isLoading) {
- if (
- !response.req_result ||
- !Array.isArray(response.data) ||
- response.data.length === 0
- ) {
- // console.error('API数据格式错误:', response);
- // setLoadingError('数据格式错误或为空');
- return;
+ // 更新 URL
+ router.push(cleanPath);
+
+ // 查找目标节点
+ const targetNode = findNode(treeData, path);
+
+ if (!targetNode) return;
+
+ // 确保节点被展开
+ setExpandedNodes(prev => {
+ if (prev.includes(path)) {
+ return prev;
}
-
- const items = response.data;
- const newChildren = convertToTreeData(loadPath, items);
- const newTree = updateTreeData(treeData, targetNodeId!, newChildren);
+ return [...prev, path];
+ });
- setTreeData(newTree);
- setLoadPath(null);
- setTargetNodeId(null);
- }
-
- // if (error) {
- // console.error('Error fetching data:', error);
- // // setLoadingError(error.message || '加载失败');
- // }
- }, [response, isLoading, error, loadPath, targetNodeId, treeData, convertToTreeData, updateTreeData]);
+ // 总是重新加载节点数据
+ loadedNodes.current.delete(path); // 移除标记以重新加载
+
+ // 设置加载状态
+ setLoadingState({
+ path: targetNode.path,
+ nodeId: targetNode.id,
+ isRefreshing: true
+ });
+ // 设置节点加载状态
+ 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 basePath = pathname?.replace(`/${scope}/code/tree`, '') || '';
- let fullPath = node.path;
+ // 只有当节点是文件时才跳转
+ if (node.content_type === 'file' && flag === 'contents') {
+ // 直接使用节点的完整路径构建跳转URL
+ const filePath = node.path.startsWith('/') ? node.path : `/${node.path}`;
- if (basePath && fullPath?.startsWith(basePath)) {
- fullPath = fullPath?.substring(basePath.length);
+ router.push(`/${scope}/code/blob${filePath}`);
}
-
- fullPath = fullPath?.replace(/^\//, '');
- if (flag ==='contents' && node?.isLeaf) {
- router.push(`/${scope}/code/blob${basePath}/${fullPath}`);
- }
-
- },
- [findNode, treeData, pathname, scope, flag, router],
+ },
+ [findNode, treeData, scope, flag, router],
);
+ // 将变化的basePath传回父组件,请求commit信息
+ useEffect(() => {
+
+ if (basePath) {
+ // 更改commit接口的path
+ onCommitInfoChange?.(basePath);
+ }
+ }, [basePath, onCommitInfoChange]);
+
return (
<>
- {isInitialLoading? (
+ {isInitialLoading ? (
-
+
)
- : treeData.length === 0 ? (
-
-
+ : treeData?.length === 0 ? (
+
+
)
: (
(
)
}}
- >
-
+ />
)}
- {/* {loadingError && (
-
- 加载错误: {loadingError}
-
- )} */}
>
);
};
diff --git a/moon/apps/web/package.json b/moon/apps/web/package.json
index 5e653586d..c6ff39d21 100644
--- a/moon/apps/web/package.json
+++ b/moon/apps/web/package.json
@@ -39,13 +39,15 @@
"@primer/primitives": "catalog:",
"@primer/react": "catalog:",
"@radix-ui/react-accordion": "catalog:",
+ "@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "catalog:",
"@radix-ui/react-hover-card": "catalog:",
"@radix-ui/react-popover": "catalog:",
"@radix-ui/react-portal": "catalog:",
+ "@radix-ui/react-primitive": "^2.1.3",
"@radix-ui/react-radio-group": "catalog:",
"@radix-ui/react-slider": "catalog:",
- "@radix-ui/react-slot": "catalog:",
+ "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "catalog:",
"@radix-ui/themes": "catalog:",
"@sentry/nextjs": "catalog:",
@@ -79,6 +81,7 @@
"linkify-react": "catalog:",
"linkifyjs": "catalog:",
"lottie-web": "catalog:",
+ "material-file-icons": "catalog:",
"metascraper": "catalog:",
"metascraper-audio": "catalog:",
"metascraper-author": "catalog:",
diff --git a/moon/apps/web/pages/[org]/code/blob/[...path].tsx b/moon/apps/web/pages/[org]/code/blob/[...path].tsx
index 4b7a82396..084c9d637 100644
--- a/moon/apps/web/pages/[org]/code/blob/[...path].tsx
+++ b/moon/apps/web/pages/[org]/code/blob/[...path].tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react'
+import React from 'react'
import { Flex, Layout } from 'antd'
import BreadCrumb from '@/components/CodeView/TreeView/BreadCrumb'
import CodeContent from '@/components/CodeView/BlobView/CodeContent'
@@ -8,8 +8,6 @@ import { useGetBlob } from '@/hooks/useGetBlob'
import { useRouter } from 'next/router'
import CommitHistory, { CommitInfo } from '@/components/CodeView/CommitHistory'
import RepoTree from '@/components/CodeView/TreeView/RepoTree'
-import { CommonResultVecTreeCommitItem } from '@gitmono/types/generated'
-import { useGetTreeCommitInfo } from '@/hooks/useGetTreeCommitInfo'
const codeStyle = {
borderRadius: 8,
@@ -39,23 +37,6 @@ function BlobPage() {
hash: '5fe4235',
date: '3 months ago'
}
-
- const newPath = useMemo(() => {
- return new_path?.split("/").slice(0, -1).join("/");
- }, [new_path]);
- const { data: TreeCommitInfo } = useGetTreeCommitInfo(newPath)
-
-
- type DirectoryType = NonNullable
- const directory: DirectoryType = useMemo(() => TreeCommitInfo?.data ?? [], [TreeCommitInfo])
-
- const newDirectory = useMemo(() => {
- if (!directory || directory.length === 0) return [];
-
- const currentFileName = new_path.split('/').pop() || '';
-
- return directory.filter(item => item.name === currentFileName);
- }, [directory, new_path]);
return (
@@ -66,7 +47,7 @@ function BlobPage() {
{/* 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 bf209cdeb..3cd57c12a 100644
--- a/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx
+++ b/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx
@@ -1,13 +1,10 @@
'use client'
-import React, { useMemo } from 'react'
+import React, { useMemo, useState } from 'react'
import { Theme } from '@radix-ui/themes'
import { Flex, Layout } from 'antd'
import { useParams } from 'next/navigation'
-
import { CommonResultVecTreeCommitItem } from '@gitmono/types/generated'
-import { LoadingSpinner } from '@gitmono/ui'
-
import CodeTable from '@/components/CodeView/CodeTable'
import BreadCrumb from '@/components/CodeView/TreeView/BreadCrumb'
import CloneTabs from '@/components/CodeView/TreeView/CloneTabs'
@@ -24,12 +21,13 @@ function TreeDetailPage() {
const { path = [] } = params as { path?: string[] }
const new_path = '/' + path?.join('/')
- const { data: TreeCommitInfo } = useGetTreeCommitInfo(new_path)
+ const [newPath, setNewPath] = useState(new_path)
+ const { data: TreeCommitInfo } = useGetTreeCommitInfo(newPath)
type DirectoryType = NonNullable
const directory: DirectoryType = useMemo(() => TreeCommitInfo?.data ?? [], [TreeCommitInfo])
- const { data: canClone } = useGetTreePathCanClone({ path: new_path })
+ const { data: canClone } = useGetTreePathCanClone({ path: newPath })
const reqPath = `${new_path}/README.md`
const {data: readmeContent}=useGetBlob({path:reqPath})
@@ -71,34 +69,39 @@ function TreeDetailPage() {
return (
- {!TreeCommitInfo ? (
-
-
-
- ) : (
+
{canClone?.data && (
-
+
)}
{/* tree */}
-
+ setNewPath(path)} />
+
{
commitInfo &&
}
-
-
+ setNewPath(path)}
+ readmeContent={readmeContent?.data}
+ />
+
+
- )}
+
)
}
diff --git a/moon/pnpm-lock.yaml b/moon/pnpm-lock.yaml
index 95769ca72..a973354d6 100644
--- a/moon/pnpm-lock.yaml
+++ b/moon/pnpm-lock.yaml
@@ -450,6 +450,9 @@ catalogs:
markdown-it:
specifier: ^14.1.0
version: 14.1.0
+ material-file-icons:
+ specifier: ^2.4.0
+ version: 2.4.0
metascraper:
specifier: ^5.34.8
version: 5.34.8
@@ -845,6 +848,9 @@ importers:
'@radix-ui/react-accordion':
specifier: 'catalog:'
version: 1.2.0(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-context-menu':
+ specifier: ^2.2.15
+ version: 2.2.15(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dialog':
specifier: 'catalog:'
version: 1.1.1(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -857,6 +863,9 @@ importers:
'@radix-ui/react-portal':
specifier: 'catalog:'
version: 1.1.1(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@radix-ui/react-primitive':
+ specifier: ^2.1.3
+ version: 2.1.3(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-radio-group':
specifier: 'catalog:'
version: 1.2.0(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -864,8 +873,8 @@ importers:
specifier: 'catalog:'
version: 1.2.0(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot':
- specifier: 'catalog:'
- version: 1.1.0(@types/react@18.2.74)(react@18.2.0)
+ specifier: ^1.2.3
+ version: 1.2.3(@types/react@18.2.74)(react@18.2.0)
'@radix-ui/react-tabs':
specifier: 'catalog:'
version: 1.1.0(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -965,6 +974,9 @@ importers:
lottie-web:
specifier: 'catalog:'
version: 5.11.0
+ material-file-icons:
+ specifier: 'catalog:'
+ version: 2.4.0
metascraper:
specifier: 'catalog:'
version: 5.34.8
@@ -3173,28 +3185,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.5':
resolution: {integrity: sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@14.2.5':
resolution: {integrity: sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@14.2.5':
resolution: {integrity: sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.5':
resolution: {integrity: sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==}
@@ -5170,55 +5178,46 @@ packages:
resolution: {integrity: sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.16.4':
resolution: {integrity: sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.16.4':
resolution: {integrity: sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.16.4':
resolution: {integrity: sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.16.4':
resolution: {integrity: sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.16.4':
resolution: {integrity: sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.16.4':
resolution: {integrity: sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.16.4':
resolution: {integrity: sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.16.4':
resolution: {integrity: sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.16.4':
resolution: {integrity: sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA==}
@@ -9402,6 +9401,9 @@ packages:
peerDependencies:
react: '>= 0.14.0'
+ material-file-icons@2.4.0:
+ resolution: {integrity: sha512-MgxhwBgoiNXyQdZVtXvdqP8t7Fu/Z3zW1aPeYN+UqtepzbKyf41b+Wme6DnwGk5Crt2JzmWLtl1XGE2YMooaQw==}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -22705,6 +22707,8 @@ snapshots:
dependencies:
react: 18.2.0
+ material-file-icons@2.4.0: {}
+
math-intrinsics@1.1.0: {}
md5.js@1.3.5:
diff --git a/moon/pnpm-workspace.yaml b/moon/pnpm-workspace.yaml
index 0ea928b00..eaafe26b0 100644
--- a/moon/pnpm-workspace.yaml
+++ b/moon/pnpm-workspace.yaml
@@ -270,3 +270,4 @@ catalog:
'@mui/x-tree-view': ^8.5.0
'@emotion/styled': ^11.14.0
prism-react-renderer: ^2.4.1
+ material-file-icons: ^2.4.0