From 26f2034057d5a0a1e0ff64b1684ab2c6fc22b090 Mon Sep 17 00:00:00 2001 From: lixiaoshu Date: Mon, 7 Jul 2025 21:48:37 +0800 Subject: [PATCH 1/3] feat: Refactor the tree component, add a file type icon library, and refactor the cloneTab. --- .../web/components/CodeView/CodeTable.tsx | 13 +- .../components/CodeView/FileIcon/FileIcon.tsx | 11 + .../web/components/CodeView/Table/index.tsx | 93 +-- .../CodeView/TreeView/CloneTabs.tsx | 96 ++- .../components/CodeView/TreeView/RepoTree.tsx | 703 ++++++++++++------ moon/apps/web/package.json | 5 +- .../web/pages/[org]/code/blob/[...path].tsx | 23 +- .../pages/[org]/code/tree/[...path]/index.tsx | 34 +- moon/pnpm-lock.yaml | 34 +- moon/pnpm-workspace.yaml | 1 + 10 files changed, 674 insertions(+), 339 deletions(-) create mode 100644 moon/apps/web/components/CodeView/FileIcon/FileIcon.tsx 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..2653e97ec 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,54 @@ 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((item, rowIndex) => ( + + {memoizedColumns.map((_) => ( + + + ))} - + )) + ) : datasource.length > 0 && ( + // 实际数据行 + datasource.map((d, index) => ( - - {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 - } - })} - - - - - ) -} + + {memoizedColumns.map((c) => ( + { + e.stopPropagation(); + onClick?.(d); + }} + justify={justify} + key={c.key || c.title} + > + {c.render ? c.render(c.dataIndex[0], d, index) : 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..c1c09d7f3 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 [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: ( - - - + <> + + + + + + {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..f8cd612d4 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,10 +21,12 @@ 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 [newDirectory,setNewDirectory] = useState(directory) const { data: canClone } = useGetTreePathCanClone({ path: new_path }) @@ -71,34 +70,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 From d6fbc0096fc33429fd9218321d9b47f165a8a297 Mon Sep 17 00:00:00 2001 From: lixiaoshu Date: Tue, 8 Jul 2025 13:15:36 +0800 Subject: [PATCH 2/3] fix: fix eslint --- .../web/components/CodeView/Table/index.tsx | 22 +++++++++++-------- .../CodeView/TreeView/CloneTabs.tsx | 2 +- .../pages/[org]/code/tree/[...path]/index.tsx | 3 +-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/moon/apps/web/components/CodeView/Table/index.tsx b/moon/apps/web/components/CodeView/Table/index.tsx index 2653e97ec..0542bd2ea 100644 --- a/moon/apps/web/components/CodeView/Table/index.tsx +++ b/moon/apps/web/components/CodeView/Table/index.tsx @@ -36,15 +36,19 @@ const TableComponent = ({ {loading ? ( // 骨架屏行 - Array.from({ length: 5 }).map((item, rowIndex) => ( - - {memoizedColumns.map((_) => ( - - - - ))} - - )) + Array.from({ length: 5 }).map((_, rowIndex) => { + const uniqueKey = `skeleton-row-${rowIndex}`; // 生成唯一 key + + return ( + + {memoizedColumns.map((column) => ( + + + + ))} + + ); + }) ) : datasource.length > 0 && ( // 实际数据行 datasource.map((d, index) => ( diff --git a/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx b/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx index c1c09d7f3..a6f7a30e7 100644 --- a/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx +++ b/moon/apps/web/components/CodeView/TreeView/CloneTabs.tsx @@ -10,7 +10,7 @@ const CloneTabs = ({ endpoint }: any) => { const pathname = usePathname() const [text, setText] = useState(pathname || '') const [copied, setCopied] = useState(false) - const [active_tab, setActiveTab] = useState('1') + const [active_tab, setActiveTab] = useState('HTTP') const [open, setOpen] = useState(false) const url = new URL(LEGACY_API_URL) 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 f8cd612d4..3cd57c12a 100644 --- a/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx +++ b/moon/apps/web/pages/[org]/code/tree/[...path]/index.tsx @@ -26,9 +26,8 @@ function TreeDetailPage() { type DirectoryType = NonNullable const directory: DirectoryType = useMemo(() => TreeCommitInfo?.data ?? [], [TreeCommitInfo]) - // const [newDirectory,setNewDirectory] = useState(directory) - 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}) From 3c6018724f91c9d0df394f4c343ab70648215786 Mon Sep 17 00:00:00 2001 From: lixiaoshu Date: Tue, 8 Jul 2025 14:11:02 +0800 Subject: [PATCH 3/3] fix: fix eslint --- moon/apps/web/components/CodeView/Table/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/moon/apps/web/components/CodeView/Table/index.tsx b/moon/apps/web/components/CodeView/Table/index.tsx index 0542bd2ea..0ed8af61f 100644 --- a/moon/apps/web/components/CodeView/Table/index.tsx +++ b/moon/apps/web/components/CodeView/Table/index.tsx @@ -38,7 +38,7 @@ const TableComponent = ({ // 骨架屏行 Array.from({ length: 5 }).map((_, rowIndex) => { const uniqueKey = `skeleton-row-${rowIndex}`; // 生成唯一 key - + return ( {memoizedColumns.map((column) => ( @@ -51,9 +51,10 @@ const TableComponent = ({ }) ) : datasource.length > 0 && ( // 实际数据行 - datasource.map((d, index) => ( + datasource.map((d, index) => { + const uniqueKey = `row-${index}`; - + return {memoizedColumns.map((c) => ( { @@ -67,7 +68,7 @@ const TableComponent = ({ ))} - )) + }) )}