AssetPro/src/pages/projects/ProjectLibrary.tsx

1165 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<GiteaRepository[]>(
'project-library-repos', // 缓存键
fetchRepos, // 数据获取函数
cacheConfig // 缓存配置使用projects命名空间
)
const [selectedRepo, setSelectedRepo] = useState<GiteaRepository | null>(null)
const [currentPath, setCurrentPath] = useState<string[]>([])
const [files, setFiles] = useState<GiteaFile[]>([])
const [selectedFile, setSelectedFile] = useState<GiteaFile | null>(null)
const [fileContent, setFileContent] = useState<string>('')
const [isEditorVisible, setIsEditorVisible] = useState(false)
const [loading, setLoading] = useState(false)
// 树形状态
const [treeData, setTreeData] = useState<TreeItem[]>([])
const [selectedTreeId, setSelectedTreeId] = useState<string>('')
// 缓存优化相关状态
const [repoTreeCache, setRepoTreeCache] = useState<RepoTreeCache | null>(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<TreeDiffResult | null>(null) // 同步结果
const [commits, setCommits] = useState<GiteaCommit[]>([]) // Git Commits
const [commitsLoading, setCommitsLoading] = useState(false) // Commits loading state
// Branch Management
const [branches, setBranches] = useState<any[]>([])
const [selectedBranch, setSelectedBranch] = useState<string>('')
const [isBranchDropdownOpen, setIsBranchDropdownOpen] = useState(false)
// 当repos数据变化时构建树形结构
useEffect(() => {
if (!repos || repos.length === 0) return
// 从仓库构建树形数据
const newTreeData: TreeItem[] = []
// 按所有者分组
const ownerGroups: Record<string, GiteaRepository[]> = {}
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<GiteaCommit[]>(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<RepoTreeCache>(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<RepoTreeCache>(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<GiteaFile[]>(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<string>(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 => (
<div key={node.id}>
<div
className={cn(
"flex items-center gap-2 py-1.5 px-2 cursor-pointer transition-colors text-sm rounded-sm mb-0.5",
selectedTreeId === node.id ? "bg-[#e0f2fe]" : "hover:bg-slate-100"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => handleTreeSelect(node)}
>
<div
className="p-0.5 rounded-sm hover:bg-slate-200/50"
onClick={(e) => {
e.stopPropagation()
if (node.children) toggleExpand(node.id)
}}
>
{node.children ? (
<ChevronDown className={cn("w-3.5 h-3.5 text-slate-400 transition-transform", !node.expanded && "-rotate-90")} />
) : (
<div className="w-3.5 h-3.5" />
)}
</div>
<Folder className={cn(
"w-4 h-4",
node.type === 'folder' ? "text-amber-400 fill-amber-400/20" : "text-amber-200 fill-amber-200/20"
)} />
<span className={cn("truncate flex-1 font-medium", selectedTreeId === node.id ? "text-slate-800" : "text-slate-600")}>
{node.label}
</span>
{node.tag && (
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
node.tag === 'Git' && "bg-indigo-50 text-indigo-500 border-indigo-100",
node.tag === 'Public' && "bg-emerald-50 text-emerald-500 border-emerald-100", // "公共"
node.tag === 'Private' && "bg-orange-50 text-orange-500 border-orange-100", // "私有"
)}>
{node.tag === 'Public' ? '公共' : node.tag === 'Private' ? '私有' : node.tag}
</span>
)}
</div>
{node.children && node.expanded && (
<div>
{renderTree(node.children, level + 1)}
</div>
)}
</div>
))
}
return (
<div className="flex h-full bg-white rounded-xl shadow-sm overflow-hidden border border-slate-100">
{/* Left Sidebar: Project Directory */}
<div className="w-[280px] border-r border-slate-100 flex flex-col bg-white">
<div className="h-14 px-4 border-b border-slate-100 flex items-center justify-between shrink-0">
<h2 className="font-bold text-slate-800"></h2>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1 text-slate-500 font-normal">
<Filter className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{renderTree(treeData)}
</div>
</div>
{/* Right Content: Details */}
<div className="flex-1 flex flex-col overflow-hidden bg-white">
{selectedRepo ? (
<>
{/* Header Toolbar */}
<div className="h-16 border-b border-slate-100 flex items-center justify-between px-6 bg-white shrink-0">
<div className="flex items-center gap-2 text-sm text-slate-500">
{/* Breadcrumb Mock based on selected tree item */}
<span className="font-medium text-slate-800 text-lg">
仿 / {selectedTreeId === 'proj-optics' ? '光学实验仿真' : selectedRepo.name}
</span>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]"
onClick={() => window.location.hash = '#/app/projects/new'}
>
<Plus className="w-4 h-4" />
</Button>
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]"
onClick={handleDownloadProject}
>
<ArrowDownToLine className="w-4 h-4" />
</Button>
<Button
variant="ghost"
className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]"
onClick={handleGitSync}
>
<GitCommitHorizontal className="w-4 h-4" />
Git同步
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Info Banner */}
<div className="bg-slate-50/80 rounded-sm p-6 grid grid-cols-3 gap-8 text-sm shrink-0 border-0">
<div className="space-y-2">
<div className="text-slate-400 text-xs flex items-center gap-1">
<GitBranch className="w-3 h-3" />
</div>
<div className="relative">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-slate-200/50 rounded p-1 -ml-1 transition-colors"
onClick={() => setIsBranchDropdownOpen(!isBranchDropdownOpen)}
>
<span className="text-slate-700 font-medium">{selectedBranch || 'Loading...'}</span>
<ChevronsUpDown className="w-3 h-3 text-slate-400" />
</div>
{/* Branch Dropdown */}
{isBranchDropdownOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsBranchDropdownOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 w-56 max-h-60 overflow-y-auto bg-white border border-slate-200 rounded-md shadow-lg z-20 py-1">
{branches.map((branch) => (
<div
key={branch.name}
className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer hover:bg-slate-50",
selectedBranch === branch.name && "bg-cyan-50 text-[#0e7490]"
)}
onClick={() => {
setSelectedBranch(branch.name)
setIsBranchDropdownOpen(false)
}}
>
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 opacity-70" />
<span>{branch.name}</span>
</div>
{selectedBranch === branch.name && <Check className="w-3.5 h-3.5" />}
</div>
))}
</div>
</>
)}
</div>
</div>
<div className="space-y-2">
<div className="text-slate-400 text-xs"></div>
<div className="text-slate-700 font-medium truncate">
{commitsLoading ? (
<span className="text-slate-400 italic">...</span>
) : commits.length > 0 ? (
<div className="flex flex-col">
<span className="truncate" title={`${commits[0].commit.message} (${commits[0].sha.substring(0, 7)})`}>
{commits[0].commit.message}
<span className="ml-2 font-mono text-[10px] text-[#0e7490] bg-cyan-50 px-1 rounded">
{commits[0].sha.substring(0, 7)}
</span>
</span>
<span className="text-[10px] text-slate-400 mt-0.5">
{new Date(commits[0].commit.author.date).toLocaleString()}
</span>
</div>
) : (
<span className="text-slate-400 italic"></span>
)}
</div>
</div>
<div className="space-y-2">
<div className="text-slate-400 text-xs">/</div>
<div className="text-slate-700 font-medium break-all select-all">
git clone {selectedRepo.clone_url}
</div>
</div>
</div>
{/* 缓存进度条 */}
{isCaching && (
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4 animate-in fade-in duration-300">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-blue-700">{cacheProgress.status}</span>
<span className="text-xs text-blue-500">
{cacheProgress.total > 0 ? `${Math.round((cacheProgress.current / cacheProgress.total) * 100)}%` : ''}
</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: cacheProgress.total > 0 ? `${(cacheProgress.current / cacheProgress.total) * 100}%` : '0%' }}
/>
</div>
</div>
)}
{/* 同步结果提示 */}
{syncResult && !isCaching && (
<div className="bg-emerald-50 border border-emerald-100 rounded-lg p-4 animate-in fade-in duration-300">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium text-emerald-700"></span>
<div className="flex items-center gap-3 text-emerald-600">
{syncResult.added.length > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
{syncResult.added.length}
</span>
)}
{syncResult.modified.length > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-amber-500 rounded-full"></span>
{syncResult.modified.length}
</span>
)}
{syncResult.deleted.length > 0 && (
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
{syncResult.deleted.length}
</span>
)}
<span className="text-slate-400">
{syncResult.unchanged}
</span>
</div>
</div>
</div>
)}
{/* Git提交记录 */}
<div className="space-y-3">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<GitCommitHorizontal className="w-4 h-4 text-[#0e7490]" />
Git提交记录
</h3>
<div className="bg-slate-50 border border-slate-100 rounded-lg divide-y divide-slate-100 max-h-[420px] overflow-y-auto">
{commitsLoading ? (
<div className="p-4 text-sm text-slate-400 text-center">...</div>
) : commits.length === 0 ? (
<div className="p-4 text-sm text-slate-400 text-center"></div>
) : (
commits.map((commit) => (
<div key={commit.sha} className="p-4 hover:bg-white transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="font-medium text-sm text-slate-700">{commit.commit.message}</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
{commit.commit.author.name}
</div>
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(commit.commit.author.date).toLocaleString()}
</div>
</div>
</div>
<div className="font-mono text-xs text-[#0e7490] bg-cyan-50 px-2 py-1 rounded">
{commit.sha.substring(0, 7)}
</div>
</div>
</div>
))
)}
</div>
</div>
{/* File List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Folder className="w-4 h-4 text-[#0e7490]" />
</h3>
{/* Breadcrumbs */}
<div className="flex items-center text-sm bg-slate-50 px-3 py-1 rounded-md border border-slate-100">
<button
onClick={() => setCurrentPath([])}
className={cn("hover:text-[#0e7490] flex items-center gap-1", currentPath.length === 0 ? "font-bold text-[#0e7490]" : "text-slate-500")}
>
<Home className="w-3.5 h-3.5" />
Root
</button>
{currentPath.map((folder, index) => (
<div key={index} className="flex items-center">
<ChevronRight className="w-3 h-3 text-slate-300 mx-1" />
<button
onClick={() => handleBreadcrumbClick(index)}
className={cn(
"hover:text-[#0e7490]",
index === currentPath.length - 1 ? "font-bold text-[#0e7490]" : "text-slate-500"
)}
>
{folder}
</button>
</div>
))}
</div>
</div>
<div className="border border-slate-200 rounded-lg overflow-hidden bg-white">
{currentPath.length > 0 && (
<div
className="px-4 py-2 bg-slate-50 border-b border-slate-100 text-slate-500 text-sm hover:bg-slate-100 cursor-pointer flex items-center gap-2"
onClick={handleNavigateUp}
>
<ArrowLeft className="w-4 h-4" />
<span></span>
</div>
)}
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-500 border-b border-slate-200">
<tr>
<th className="px-4 py-3 font-medium w-[40%]"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{loading && files.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-slate-400">
...
</td>
</tr>
) : files.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-slate-400">
</td>
</tr>
) : (
files.map((file) => (
<tr
key={file.path}
onClick={() => 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" : ""
)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{file.type === 'dir' ? (
<Folder className="w-4 h-4 text-[#0e7490] fill-[#0e7490]/20" />
) : (
getFileExtension(file.name) === 'ts' || getFileExtension(file.name) === 'tsx' || getFileExtension(file.name) === 'js' ? (
<FileCode className="w-4 h-4 text-emerald-500" />
) : (
<FileText className="w-4 h-4 text-slate-400" />
)
)}
<span className={cn("font-medium", selectedFile?.path === file.path ? "text-[#0e7490]" : "text-slate-700")}>
{file.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-slate-500 font-mono text-xs">
{file.type === 'dir' ? '-' : formatBytes(file.size)}
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{file.type === 'dir' ? '文件夹' : '文件'}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="w-4 h-4 text-slate-400" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Code Editor Area (Collapsible, Now Below File List) */}
{isEditorVisible && selectedFile && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-4 duration-300">
<div className="flex items-center justify-between">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<FileCode className="w-4 h-4 text-[#0e7490]" />
: {selectedFile.name}
</h3>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs">
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setIsEditorVisible(false)}>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
</div>
<div className="rounded-lg border border-slate-200 overflow-visible shadow-sm relative group">
<div className="absolute right-4 top-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="secondary" size="sm" className="h-8 text-xs shadow-sm bg-white/90 backdrop-blur">
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{loading ? (
<div className="h-[400px] flex items-center justify-center bg-[#1e1e1e] text-slate-400">
...
</div>
) : (
<SyntaxHighlighter
language={getFileExtension(selectedFile.name)}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1.5rem',
height: 'auto',
minHeight: 'auto',
fontSize: '14px',
lineHeight: '1.5',
overflow: 'visible',
overflowY: 'visible',
overflowX: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
showLineNumbers={true}
PreTag="div"
wrapLines={true}
wrapLongLines={true}
>
{fileContent}
</SyntaxHighlighter>
)}
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<Folder className="w-16 h-16 mb-4 text-slate-200" />
<p></p>
</div>
)}
</div>
</div>
)
}