frontend changes
This commit is contained in:
parent
2176100b0e
commit
b6d91b9a08
76
src/app/github/repo/RepoTree.tsx
Normal file
76
src/app/github/repo/RepoTree.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { getRepositoryStructure, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"
|
||||
|
||||
type Node = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
}
|
||||
|
||||
export default function RepoTree({ repositoryId, rootPath = "", onSelectFile, onSelectDirectory }: { repositoryId: string, rootPath?: string, onSelectFile: (path: string) => void, onSelectDirectory?: (path: string) => void }) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({})
|
||||
const [children, setChildren] = useState<Record<string, Node[]>>({})
|
||||
|
||||
const toggle = async (nodePath: string) => {
|
||||
const next = !expanded[nodePath]
|
||||
setExpanded(prev => ({ ...prev, [nodePath]: next }))
|
||||
if (next && !children[nodePath]) {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: true }))
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, nodePath)
|
||||
const list = (res?.structure || []) as RepoStructureEntry[]
|
||||
const mapped: Node[] = list.map(e => ({
|
||||
name: (e as any).name || (e as any).filename || (((e as any).path || (e as any).relative_path || '').split('/').slice(-1)[0]) || 'unknown',
|
||||
path: (e as any).path || (e as any).relative_path || '',
|
||||
type: (e as any).type === 'directory' || (e as any).type === 'dir' ? 'directory' : 'file'
|
||||
}))
|
||||
setChildren(prev => ({ ...prev, [nodePath]: mapped }))
|
||||
onSelectDirectory && onSelectDirectory(nodePath)
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initial load for root
|
||||
useEffect(() => {
|
||||
toggle(rootPath)
|
||||
onSelectDirectory && onSelectDirectory(rootPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repositoryId, rootPath])
|
||||
|
||||
const renderNode = (node: Node) => {
|
||||
const isDir = node.type === 'directory'
|
||||
const isOpen = !!expanded[node.path]
|
||||
return (
|
||||
<div key={node.path} className="select-none">
|
||||
<div className="flex items-center gap-1 py-1 px-1 hover:bg-white/5 rounded cursor-pointer"
|
||||
onClick={() => isDir ? toggle(node.path) : onSelectFile(node.path)}>
|
||||
{isDir ? (
|
||||
isOpen ? <ChevronDown className="h-4 w-4 text-white/70"/> : <ChevronRight className="h-4 w-4 text-white/70"/>
|
||||
) : (
|
||||
<span className="w-4"/>
|
||||
)}
|
||||
{isDir ? <Folder className="h-4 w-4 mr-1"/> : <FileText className="h-4 w-4 mr-1"/>}
|
||||
<span className="truncate text-sm text-white">{node.name}</span>
|
||||
</div>
|
||||
{isDir && isOpen && (
|
||||
<div className="pl-5 border-l border-white/10 ml-2">
|
||||
{loading[node.path] && <div className="text-xs text-white/60 py-1">Loading…</div>}
|
||||
{(children[node.path] || []).map(ch => renderNode(ch))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-white/90">
|
||||
{(children[rootPath] || []).map(n => renderNode(n))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, getRepositoryCommitSummary, type RepoStructureEntry, type CommitSummaryResponse } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search, GitCommit } from "lucide-react"
|
||||
import RepoTree from "./RepoTree"
|
||||
|
||||
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||
const [path, setPath] = useState(initialPath)
|
||||
@ -15,6 +16,11 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [commitSummary, setCommitSummary] = useState<CommitSummaryResponse | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewContent, setPreviewContent] = useState<string | null>(null)
|
||||
const [selectedDir, setSelectedDir] = useState<string>("")
|
||||
const [dirEntries, setDirEntries] = useState<RepoStructureEntry[]>([])
|
||||
|
||||
const timeAgo = (iso?: string) => {
|
||||
if (!iso) return ''
|
||||
@ -73,9 +79,9 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||
}, [entries, fileQuery])
|
||||
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
const navigateFolder = (rel: string) => {
|
||||
// rel is expected to be a relative path from repo root
|
||||
setPath(rel)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
@ -130,31 +136,116 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
{/* Three-column layout: left tree, middle directory listing, right preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<div className="lg:col-span-1 bg-white/5 border border-white/10 rounded-lg p-2 overflow-auto max-h-[70vh]">
|
||||
<RepoTree
|
||||
repositoryId={repositoryId}
|
||||
rootPath=""
|
||||
onSelectDirectory={async (dirPath) => {
|
||||
// Update center panel with this directory contents
|
||||
setSelectedDir(dirPath)
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, dirPath)
|
||||
setDirEntries(res?.structure || [])
|
||||
} catch {
|
||||
setDirEntries([])
|
||||
}
|
||||
}}
|
||||
onSelectFile={async (filePath) => {
|
||||
setSelectedFile(filePath)
|
||||
try {
|
||||
setPreviewLoading(true)
|
||||
const file = await getRepositoryFileContent(repositoryId, filePath)
|
||||
setPreviewContent(file?.content || null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Middle: Directory listing */}
|
||||
<div className="lg:col-span-1 bg-white/5 border border-white/10 rounded-lg overflow-hidden">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex items-center justify-between">
|
||||
<span>Folder</span>
|
||||
<span className="text-xs text-white/60 truncate ml-2">{selectedDir || "/"}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{dirEntries.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">Select a folder from the tree.</div>
|
||||
)}
|
||||
{dirEntries.map((e, i) => {
|
||||
const rawPath = (e as any).path || (e as any).relative_path || ''
|
||||
const displayName = (e as any).name || (e as any).filename || (rawPath ? rawPath.split('/').slice(-1)[0] : '(unknown)')
|
||||
const typeVal = (e as any).type || (e as any).entry_type
|
||||
const isDirHint = typeVal === 'directory' || typeVal === 'dir' || (e as any).is_directory === true || (e as any).isDir === true
|
||||
// heuristic fallback if type missing: no dot in last segment often means folder
|
||||
const isDir = isDirHint || (!!displayName && !displayName.includes('.'))
|
||||
return (
|
||||
<div key={(e as any).path || (e as any).relative_path || displayName + i} className="flex items-center px-4 py-3 hover:bg-white/5 cursor-pointer"
|
||||
onClick={async () => {
|
||||
const relPath = (e as any).path || (e as any).relative_path || displayName
|
||||
if (isDir) {
|
||||
// drill down in tree and center
|
||||
setSelectedDir(relPath)
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, relPath)
|
||||
setDirEntries(res?.structure || [])
|
||||
} catch {
|
||||
setDirEntries([])
|
||||
}
|
||||
return
|
||||
}
|
||||
// file
|
||||
setSelectedFile(relPath)
|
||||
try {
|
||||
setPreviewLoading(true)
|
||||
let file = await getRepositoryFileContent(repositoryId, relPath)
|
||||
// if backend still reports directory, probe and navigate instead of showing error
|
||||
if (!file?.content) {
|
||||
try {
|
||||
const probe = await getRepositoryStructure(repositoryId, relPath)
|
||||
if (Array.isArray(probe?.structure) && probe.structure.length > 0) {
|
||||
setPreviewContent(null)
|
||||
setSelectedFile(null)
|
||||
setSelectedDir(relPath)
|
||||
setDirEntries(probe.structure)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setPreviewContent(file?.content || null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{isDir ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate text-white">{displayName}</div>
|
||||
<div className="w-28 text-right text-sm text-white/60">{isDir ? 'folder' : 'file'}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Preview */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-lg">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex items-center justify-between">
|
||||
<span>Preview</span>
|
||||
{selectedFile && <span className="text-xs text-white/60 truncate ml-2">{selectedFile}</span>}
|
||||
</div>
|
||||
{previewLoading ? (
|
||||
<div className="px-4 py-6 text-white/60">Loading file...</div>
|
||||
) : !selectedFile ? (
|
||||
<div className="p-6 text-white/60">Select a file to preview its contents.</div>
|
||||
) : previewContent ? (
|
||||
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90 overflow-auto">{previewContent}</pre>
|
||||
) : (
|
||||
<div className="p-6 text-white/60">No preview available (binary or empty file).</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => {
|
||||
const displayName = (e as any).name || (e as any).filename || (e as any).relative_path || (e as any).path || '(unknown)'
|
||||
const isDir = e.type === 'directory' || (e as any).type === 'dir'
|
||||
return (
|
||||
<div key={(e as any).path || (e as any).relative_path || displayName + i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => isDir ? navigateFolder(displayName) : void 0}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{isDir ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate text-white">{displayName}</div>
|
||||
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||
<Clock className="h-4 w-4"/>
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-0">
|
||||
|
||||
@ -108,6 +108,21 @@ export async function getRepositoryCommits(repositoryId: string, opts?: { page?:
|
||||
return res.data?.data as CommitsListResponse
|
||||
}
|
||||
|
||||
export interface ResolvePathInfo {
|
||||
repository_id: string
|
||||
local_path: string
|
||||
requested_file_path: string
|
||||
resolved_absolute_path: string | null
|
||||
exists: boolean
|
||||
is_directory: boolean
|
||||
}
|
||||
|
||||
export async function resolveRepositoryPath(repositoryId: string, filePath: string): Promise<ResolvePathInfo> {
|
||||
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/resolve-path?file_path=${encodeURIComponent(filePath)}`
|
||||
const res = await authApiClient.get(url)
|
||||
return res.data?.data as ResolvePathInfo
|
||||
}
|
||||
|
||||
export interface AttachRepositoryResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user