import { useState, useEffect, useCallback, useMemo } from 'react' import { Folder, Plus, FileCode, FileText, MoreHorizontal, ChevronRight, ChevronDown, Copy, Home, ArrowLeft, Filter, ArrowDownToLine, GitCommitHorizontal, User, Calendar, GitBranch, ChevronsUpDown, Check } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism' import { giteaApi } from '@/api/gitea' import { GiteaRepository, GiteaFile, GiteaTreeNode, RepoTreeCache, TreeDiffResult, GiteaCommit } from '@/api/types' import { useCache, cacheUtils } from '@/hooks/useCache' import { useToast } from '@/hooks/use-toast' import { useDownloadManager } from '@/components/DownloadManager' // 格式化字节大小的辅助函数 function formatBytes(bytes: number, decimals = 2) { if (!+bytes) return '0 B' const k = 1024 const dm = decimals < 0 ? 0 : decimals const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` } // 解码 Base64 的辅助函数(处理 UTF-8) function decodeBase64(str: string) { try { return decodeURIComponent(escape(window.atob(str))); } catch (e) { return window.atob(str); } } const BINARY_EXTENSIONS = new Set([ 'dll', 'exe', 'lib', 'so', 'dylib', 'bin', 'obj', 'pdb', 'zip', 'tar', 'gz', '7z', 'rar', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg', 'mp4', 'mp3', 'wav', 'mov', 'avi', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'ttf', 'otf', 'woff', 'woff2', 'unitypackage', 'apk', 'ipa' ]) const MAX_PREVIEW_SIZE = 1024 * 512; // 512KB Limit // 树形数据类型 interface TreeItem { id: string label: string type: 'folder' | 'project' children?: TreeItem[] repoId?: number // Link to real repo if applicable tag?: 'Git' | 'Public' | 'Private' expanded?: boolean } export default function ProjectLibrary() { const { toast, success, error: showError } = useToast() const { startDownload } = useDownloadManager() // 使用缓存Hook获取仓库列表 const fetchRepos = useCallback(() => giteaApi.getRepositories(), []) const cacheConfig = useMemo(() => ({ namespace: 'projects' }), []) const { data: repos } = useCache( 'project-library-repos', // 缓存键 fetchRepos, // 数据获取函数 cacheConfig // 缓存配置:使用projects命名空间 ) const [selectedRepo, setSelectedRepo] = useState(null) const [currentPath, setCurrentPath] = useState([]) const [files, setFiles] = useState([]) const [selectedFile, setSelectedFile] = useState(null) const [fileContent, setFileContent] = useState('') const [isEditorVisible, setIsEditorVisible] = useState(false) const [loading, setLoading] = useState(false) // 树形状态 const [treeData, setTreeData] = useState([]) const [selectedTreeId, setSelectedTreeId] = useState('') // 缓存优化相关状态 const [repoTreeCache, setRepoTreeCache] = useState(null) // 当前仓库的目录树缓存 const [cacheProgress, setCacheProgress] = useState<{ current: number; total: number; status: string }>({ current: 0, total: 0, status: '' }) const [isCaching, setIsCaching] = useState(false) // 是否正在预缓存 const [syncResult, setSyncResult] = useState(null) // 同步结果 const [commits, setCommits] = useState([]) // Git Commits const [commitsLoading, setCommitsLoading] = useState(false) // Commits loading state // Branch Management const [branches, setBranches] = useState([]) const [selectedBranch, setSelectedBranch] = useState('') const [isBranchDropdownOpen, setIsBranchDropdownOpen] = useState(false) // 当repos数据变化时,构建树形结构 useEffect(() => { if (!repos || repos.length === 0) return // 从仓库构建树形数据 const newTreeData: TreeItem[] = [] // 按所有者分组 const ownerGroups: Record = {} repos.forEach(repo => { const ownerName = repo.owner.username if (!ownerGroups[ownerName]) { ownerGroups[ownerName] = [] } ownerGroups[ownerName].push(repo) }) // 为每个所有者创建文件夹 Object.keys(ownerGroups).forEach((owner) => { const ownerRepos = ownerGroups[owner] newTreeData.push({ id: `owner-${owner}`, label: owner, type: 'folder', expanded: true, children: ownerRepos.map(repo => ({ id: `repo-${repo.id}`, label: repo.name, type: 'project', repoId: repo.id, tag: repo.private ? 'Private' : 'Public' })) }) }) setTreeData(newTreeData) // 如果有可用仓库,选择第一个 if (!selectedRepo && repos.length > 0) { const firstRepo = repos[0] setSelectedRepo(firstRepo) setSelectedTreeId(`repo-${firstRepo.id}`) } }, [repos]) /** * 对比两个目录树,找出差异 */ const compareTree = (oldTree: GiteaTreeNode[], newTree: GiteaTreeNode[]): TreeDiffResult => { const oldMap = new Map(oldTree.map(n => [n.path, n])) const newMap = new Map(newTree.map(n => [n.path, n])) const added: GiteaTreeNode[] = [] const deleted: GiteaTreeNode[] = [] const modified: GiteaTreeNode[] = [] let unchanged = 0 // 找新增和修改 for (const [path, node] of newMap) { const oldNode = oldMap.get(path) if (!oldNode) { added.push(node) } else if (oldNode.sha !== node.sha) { modified.push(node) } else { unchanged++ } } // 找删除 for (const [path, node] of oldMap) { if (!newMap.has(path)) { deleted.push(node) } } return { added, deleted, modified, unchanged } } // 当仓库变化时获取提交记录 useEffect(() => { if (!selectedRepo) { setCommits([]) return } const fetchCommits = async () => { try { setCommitsLoading(true) const cacheKey = `commits-${selectedRepo.owner.username}-${selectedRepo.name}` console.log(`尝试读取提交记录缓存: ${cacheKey}`) const cachedCommits = await cacheUtils.getJSON(cacheKey, { namespace: 'projects' }) if (cachedCommits) { console.log('从缓存加载提交记录') setCommits(cachedCommits) // 即使有缓存,也可以在后台静默更新(可选,暂不实现以保证速度) setCommitsLoading(false) return } console.log('调用API获取提交记录') // 1. 首先尝试获取分支信息以快速填充 Banner(通常比获取完整提交列表快得多) try { const branchData = await giteaApi.getRepoBranch(selectedRepo.owner.username, selectedRepo.name, selectedRepo.default_branch) if (branchData && branchData.commit) { // 构造一个简单的 GiteaCommit 对象用于 Banner 显示 const latestCommit: any = { sha: branchData.commit.id, commit: { message: branchData.commit.message, author: { name: branchData.commit.author.name, date: branchData.commit.timestamp } } } setCommits([latestCommit]) // 这里不设置 Loading 结束,因为还要拉取列表 } } catch (branchError) { console.error("Failed to fetch branch info:", branchError) } // 2. 拉取完整的提交列表用于下方的记录显示 const data = await giteaApi.getRepoCommits(selectedRepo.owner.username, selectedRepo.name, 1, 20) setCommits(data) // 缓存提交记录 await cacheUtils.setJSON(cacheKey, data, { namespace: 'projects', maxAge: 1000 * 60 * 5 }) // 5分钟缓存 } catch (error: any) { console.error("Failed to fetch commits:", error) setCommits([]) // 如果是超时错误,给用户明确提示 if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { toast(`获取提交记录超时 (60s),请检查网络或稍后重试`, { type: 'error' }) } } finally { setCommitsLoading(false) } } fetchCommits() fetchCommits() }, [selectedRepo]) // Fetch Branches when Repo changes useEffect(() => { if (!selectedRepo) { setBranches([]) setSelectedBranch('') return } const fetchBranches = async () => { try { const data = await giteaApi.getRepoBranches(selectedRepo.owner.username, selectedRepo.name) setBranches(data) // Set default branch initially if (!selectedBranch || selectedBranch !== selectedRepo.default_branch) { setSelectedBranch(selectedRepo.default_branch) } } catch (error) { console.error("Failed to fetch branches:", error) } } fetchBranches() }, [selectedRepo]) /** * 预加载仓库目录树缓存 */ const prefetchRepoTree = async (repo: GiteaRepository) => { const treeCacheKey = `repo-tree-${repo.owner.username}-${repo.name}` try { setIsCaching(true) setCacheProgress({ current: 0, total: 1, status: '检查缓存...' }) // 尝试从缓存读取 const cachedTree = await cacheUtils.getJSON(treeCacheKey, { namespace: 'projects' }) if (cachedTree) { console.log(`从缓存加载目录树: ${treeCacheKey}`) setRepoTreeCache(cachedTree) setCacheProgress({ current: 1, total: 1, status: '缓存已加载' }) setIsCaching(false) return cachedTree } // 缓存不存在,调用 API 获取 setCacheProgress({ current: 0, total: 1, status: '获取目录树...' }) console.log(`调用 API 获取目录树: ${repo.owner.username}/${repo.name}`) const result = await giteaApi.getRepoTree(repo.owner.username, repo.name, repo.default_branch) const newTreeCache: RepoTreeCache = { owner: repo.owner.username, repo: repo.name, sha: result.sha, fetchedAt: Date.now(), tree: result.tree } // 写入缓存 await cacheUtils.setJSON(treeCacheKey, newTreeCache, { namespace: 'projects' }) console.log(`目录树缓存完成: ${result.tree.length} 个节点`) setRepoTreeCache(newTreeCache) setCacheProgress({ current: 1, total: 1, status: `已缓存 ${result.tree.length} 个文件/目录` }) setIsCaching(false) return newTreeCache } catch (error) { console.error('预加载目录树失败:', error) setCacheProgress({ current: 0, total: 0, status: '缓存失败' }) setIsCaching(false) return null } } /** * 增量同步 - 只更新变化的部分 */ const incrementalSync = async (repo: GiteaRepository) => { const treeCacheKey = `repo-tree-${repo.owner.username}-${repo.name}` try { setIsCaching(true) setCacheProgress({ current: 0, total: 3, status: '获取最新目录树...' }) // 1. 获取最新的目录树 const result = await giteaApi.getRepoTree(repo.owner.username, repo.name, repo.default_branch) setCacheProgress({ current: 1, total: 3, status: '对比差异...' }) // 2. 获取旧缓存 const oldCache = await cacheUtils.getJSON(treeCacheKey, { namespace: 'projects' }) const oldTree = oldCache?.tree || [] // 3. 对比差异 const diff = compareTree(oldTree, result.tree) console.log('同步差异:', { added: diff.added.length, deleted: diff.deleted.length, modified: diff.modified.length, unchanged: diff.unchanged }) setCacheProgress({ current: 2, total: 3, status: '更新缓存...' }) // 4. 删除被移除文件的内容缓存 for (const node of diff.deleted) { if (node.type === 'blob') { const contentKey = `content-${repo.owner.username}-${repo.name}-${node.path}` await cacheUtils.delete(contentKey, 'projects') } } // 5. 删除被修改文件的内容缓存(下次点击时重新加载) for (const node of diff.modified) { if (node.type === 'blob') { const contentKey = `content-${repo.owner.username}-${repo.name}-${node.path}` await cacheUtils.delete(contentKey, 'projects') } } // 6. 更新目录树缓存 const newTreeCache: RepoTreeCache = { owner: repo.owner.username, repo: repo.name, sha: result.sha, fetchedAt: Date.now(), tree: result.tree } await cacheUtils.setJSON(treeCacheKey, newTreeCache, { namespace: 'projects' }) setRepoTreeCache(newTreeCache) setSyncResult(diff) setCacheProgress({ current: 3, total: 3, status: '同步完成' }) setIsCaching(false) // 3秒后清除同步结果提示 setTimeout(() => setSyncResult(null), 5000) return diff } catch (error) { console.error('增量同步失败:', error) setCacheProgress({ current: 0, total: 0, status: '同步失败' }) setIsCaching(false) throw error } } /** * Git同步功能 - 增量同步 */ const handleGitSync = async () => { if (!selectedRepo) return try { console.log('Git同步: 开始增量同步') await incrementalSync(selectedRepo) console.log('Git同步完成') } catch (error) { console.error('Git同步失败:', error) } } // Fetch Files when Repo or Path changes (优先使用树缓存) useEffect(() => { if (!selectedRepo) return const fetchFiles = async () => { try { setLoading(true) const pathStr = currentPath.join("/") // 首先尝试从树缓存中提取当前路径的文件列表 if (repoTreeCache && repoTreeCache.owner === selectedRepo.owner.username && repoTreeCache.repo === selectedRepo.name) { console.log(`从树缓存提取文件列表: ${pathStr || 'root'}`) // 从树缓存中筛选当前路径下的直接子节点 const currentPathPrefix = pathStr ? `${pathStr}/` : '' const filesFromCache: GiteaFile[] = [] for (const node of repoTreeCache.tree) { // 检查是否为当前路径的直接子节点 if (pathStr === '') { // 根目录:只取第一级(不包含 /) if (!node.path.includes('/')) { filesFromCache.push({ name: node.path, path: node.path, sha: node.sha, type: node.type === 'tree' ? 'dir' : 'file', size: node.size || 0, url: node.url || '' }) } } else { // 子目录:取以 currentPathPrefix 开头但后面不再包含 / 的节点 if (node.path.startsWith(currentPathPrefix)) { const relativePath = node.path.slice(currentPathPrefix.length) if (!relativePath.includes('/')) { filesFromCache.push({ name: relativePath, path: node.path, sha: node.sha, type: node.type === 'tree' ? 'dir' : 'file', size: node.size || 0, url: node.url || '' }) } } } } // 排序:文件夹优先,然后按名称排序 const sortedFiles = filesFromCache.sort((a, b) => { if (a.type === 'dir' && b.type !== 'dir') return -1 if (a.type !== 'dir' && b.type === 'dir') return 1 return a.name.localeCompare(b.name) }) setFiles(sortedFiles) setLoading(false) return } // 树缓存不可用,回退到旧的缓存/API方式 const cacheKey = `files-${selectedRepo.owner.username}-${selectedRepo.name}-${pathStr || 'root'}` // 尝试从缓存读取 console.log(`尝试从缓存读取文件列表: ${cacheKey}`) const cachedFiles = await cacheUtils.getJSON(cacheKey, { namespace: 'projects' }) if (cachedFiles) { console.log(`从缓存加载文件列表成功: ${cacheKey}`) setFiles(cachedFiles) setLoading(false) return } // 缓存不存在,调用API获取 console.log(`缓存不存在,调用API获取文件列表: ${cacheKey}`) const data = await giteaApi.getRepoContents(selectedRepo.owner.username, selectedRepo.name, pathStr) // 排序:文件夹优先,然后按名称排序 const sortedData = data.sort((a, b) => { if (a.type === 'dir' && b.type !== 'dir') return -1 if (a.type !== 'dir' && b.type === 'dir') return 1 return a.name.localeCompare(b.name) }) // 写入缓存 console.log(`写入文件列表缓存: ${cacheKey}`) await cacheUtils.setJSON(cacheKey, sortedData, { namespace: 'projects' }) setFiles(sortedData) } catch (error) { console.error("Failed to fetch files:", error) setFiles([]) } finally { setLoading(false) } } fetchFiles() }, [selectedRepo, currentPath, repoTreeCache]) const toggleExpand = (id: string) => { const toggleNode = (nodes: TreeItem[]): TreeItem[] => { return nodes.map(node => { if (node.id === id) { return { ...node, expanded: !node.expanded } } if (node.children) { return { ...node, children: toggleNode(node.children) } } return node }) } setTreeData(toggleNode(treeData)) } /** * 检测 Git 地址连通性 */ const checkGitConnectivity = async (repo: GiteaRepository) => { try { // 使用一个轻量级的 API 调用来测试连通性(获取根目录内容) await giteaApi.getRepoContents(repo.owner.username, repo.name, '', repo.default_branch) success(`Git 地址连接正常:${repo.name}`) } catch (error) { console.error('Git 地址连接失败:', error) showError(`Git 地址连接失败:${repo.name}`) } } /** * 处理项目下载 */ const handleDownloadProject = async () => { if (!selectedRepo) return try { // 1. 选择保存目录 const result = await window.electronAPI.file.openDirectory() if (result.canceled || !result.path) return const targetPath = result.path // 2. 委托给 DownloadManager 开始下载(支持排队和进度显示) startDownload(selectedRepo.clone_url, targetPath, selectedBranch) } catch (error) { console.error('打开下载目录失败:', error) showError('无法启动下载') } } const handleTreeSelect = (item: TreeItem) => { setSelectedTreeId(item.id) if (item.type === 'project' && item.repoId) { const foundRepo = repos?.find(r => r.id === item.repoId) if (foundRepo) { setSelectedRepo(foundRepo) // 检测 Git 地址连通性 checkGitConnectivity(foundRepo) // 自动预缓存仓库目录树 prefetchRepoTree(foundRepo) } } setCurrentPath([]) setSelectedFile(null) setIsEditorVisible(false) setFileContent('') } const getFileExtension = (filename: string) => { return filename.split('.').pop()?.toLowerCase() || 'text' } const handleFileClick = async (file: GiteaFile) => { if (file.type === 'dir') { setCurrentPath(prev => [...prev, file.name]) setSelectedFile(null) setIsEditorVisible(false) setFileContent('') return } // 如果点击相同文件且编辑器可见,不执行任何操作(或切换) if (selectedFile?.path === file.path && isEditorVisible) { return } // Check for binary extensions const ext = getFileExtension(file.name) if (BINARY_EXTENSIONS.has(ext)) { setSelectedFile(file) setFileContent('此文件为二进制文件或不支持预览。') setIsEditorVisible(true) return } // Check file size if (file.size > MAX_PREVIEW_SIZE) { setSelectedFile(file) setFileContent(`文件过大 (${formatBytes(file.size)}),暂不支持在线预览。`) setIsEditorVisible(true) return } try { setLoading(true) setSelectedFile(file) // 生成缓存键: content-owner-repo-filepath if (selectedRepo) { const cacheKey = `content-${selectedRepo.owner.username}-${selectedRepo.name}-${file.path}` // 尝试从缓存读取文件内容 console.log(`尝试从缓存读取文件内容: ${cacheKey}`) const cachedContent = await cacheUtils.getJSON(cacheKey, { namespace: 'projects' }) if (cachedContent) { console.log(`从缓存加载文件内容成功: ${cacheKey}`) setFileContent(cachedContent) setIsEditorVisible(true) setLoading(false) return } // 缓存不存在,调用API获取 console.log(`缓存不存在,调用API获取文件内容: ${cacheKey}`) const fileData = await giteaApi.getFileBlob(selectedRepo.owner.username, selectedRepo.name, file.path) if (fileData.content) { const decodedContent = decodeBase64(fileData.content) // 写入缓存 console.log(`写入文件内容缓存: ${cacheKey}`) await cacheUtils.setJSON(cacheKey, decodedContent, { namespace: 'projects' }) setFileContent(decodedContent) } else { setFileContent('无法获取文件内容或内容为空。') } } setIsEditorVisible(true) } catch (error) { console.error("Failed to fetch file content:", error) setFileContent('加载文件内容出错。') } finally { setLoading(false) } } const handleNavigateUp = () => { if (currentPath.length > 0) { setCurrentPath(prev => prev.slice(0, -1)) setSelectedFile(null) setIsEditorVisible(false) setFileContent('') } } const handleBreadcrumbClick = (index: number) => { setCurrentPath(prev => prev.slice(0, index + 1)) setSelectedFile(null) setIsEditorVisible(false) setFileContent('') } // Recursive Tree Renderer const renderTree = (nodes: TreeItem[], level = 0) => { return nodes.map(node => (
handleTreeSelect(node)} >
{ e.stopPropagation() if (node.children) toggleExpand(node.id) }} > {node.children ? ( ) : (
)}
{node.label} {node.tag && ( {node.tag === 'Public' ? '公共' : node.tag === 'Private' ? '私有' : node.tag} )}
{node.children && node.expanded && (
{renderTree(node.children, level + 1)}
)}
)) } return (
{/* Left Sidebar: Project Directory */}

项目目录

{renderTree(treeData)}
{/* Right Content: Details */}
{selectedRepo ? ( <> {/* Header Toolbar */}
{/* Breadcrumb Mock based on selected tree item */} 物理仿真项目 / {selectedTreeId === 'proj-optics' ? '光学实验仿真' : selectedRepo.name}
{/* Info Banner */}
分支
setIsBranchDropdownOpen(!isBranchDropdownOpen)} > {selectedBranch || 'Loading...'}
{/* Branch Dropdown */} {isBranchDropdownOpen && ( <>
setIsBranchDropdownOpen(false)} />
{branches.map((branch) => (
{ setSelectedBranch(branch.name) setIsBranchDropdownOpen(false) }} >
{branch.name}
{selectedBranch === branch.name && }
))}
)}
最后提交
{commitsLoading ? ( 加载中... ) : commits.length > 0 ? (
{commits[0].commit.message} {commits[0].sha.substring(0, 7)} {new Date(commits[0].commit.author.date).toLocaleString()}
) : ( 暂无记录 )}
克隆/拉取命令
git clone {selectedRepo.clone_url}
{/* 缓存进度条 */} {isCaching && (
{cacheProgress.status} {cacheProgress.total > 0 ? `${Math.round((cacheProgress.current / cacheProgress.total) * 100)}%` : ''}
0 ? `${(cacheProgress.current / cacheProgress.total) * 100}%` : '0%' }} />
)} {/* 同步结果提示 */} {syncResult && !isCaching && (
同步完成
{syncResult.added.length > 0 && ( 新增 {syncResult.added.length} 个 )} {syncResult.modified.length > 0 && ( 更新 {syncResult.modified.length} 个 )} {syncResult.deleted.length > 0 && ( 删除 {syncResult.deleted.length} 个 )} 未变化 {syncResult.unchanged} 个
)} {/* Git提交记录 */}

Git提交记录

{commitsLoading ? (
加载提交记录中...
) : commits.length === 0 ? (
暂无提交记录
) : ( commits.map((commit) => (
{commit.commit.message}
{commit.commit.author.name}
{new Date(commit.commit.author.date).toLocaleString()}
{commit.sha.substring(0, 7)}
)) )}
{/* File List */}

项目文件列表

{/* Breadcrumbs */}
{currentPath.map((folder, index) => (
))}
{currentPath.length > 0 && (
返回上一级
)} {loading && files.length === 0 ? ( ) : files.length === 0 ? ( ) : ( files.map((file) => ( handleFileClick(file)} className={cn( "border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors cursor-pointer group", selectedFile?.path === file.path ? "bg-slate-50" : "" )} > )) )}
文件名 大小 类型 操作
加载中...
暂无文件
{file.type === 'dir' ? ( ) : ( getFileExtension(file.name) === 'ts' || getFileExtension(file.name) === 'tsx' || getFileExtension(file.name) === 'js' ? ( ) : ( ) )} {file.name}
{file.type === 'dir' ? '-' : formatBytes(file.size)} {file.type === 'dir' ? '文件夹' : '文件'}
{/* Code Editor Area (Collapsible, Now Below File List) */} {isEditorVisible && selectedFile && (

代码预览: {selectedFile.name}

{loading ? (
加载中...
) : ( {fileContent} )}
)}
) : (

请选择一个项目以查看详情

)}
) }