1165 lines
44 KiB
TypeScript
1165 lines
44 KiB
TypeScript
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>
|
||
)
|
||
}
|