1import { useState, useEffect } from 'react'
2import { useSearchParams } from 'react-router-dom'
3import { gql, useQuery } from '@apollo/client'
4import { AlertCircle, Check, Copy, GitCommit } from 'lucide-react'
5import { CodeBreadcrumb } from '@/components/code/CodeBreadcrumb'
6import { RefSelector } from '@/components/code/RefSelector'
7import { FileTree } from '@/components/code/FileTree'
8import { FileViewer } from '@/components/code/FileViewer'
9import { CommitList } from '@/components/code/CommitList'
10import { Skeleton } from '@/components/ui/skeleton'
11import { Button } from '@/components/ui/button'
12import { getRefs, getTree, getBlob } from '@/lib/gitApi'
13import type { GitRef, GitTreeEntry, GitBlob } from '@/lib/gitApi'
14import { useRepo } from '@/lib/repo'
15import { Markdown } from '@/components/content/Markdown'
16
17const REPO_NAME_QUERY = gql`
18 query RepoName($ref: String) {
19 repository(ref: $ref) {
20 name
21 }
22 }
23`
24
25type ViewMode = 'tree' | 'blob' | 'commits'
26
27// Code browser page (/:repo). Switches between tree view, file viewer, and
28// commit history via the ?type= search param. Ref is selected via ?ref=.
29export function CodePage() {
30 const repo = useRepo()
31 const [searchParams, setSearchParams] = useSearchParams()
32
33 const [refs, setRefs] = useState<GitRef[]>([])
34 const [refsLoading, setRefsLoading] = useState(true)
35 const [error, setError] = useState<string | null>(null)
36
37 const [entries, setEntries] = useState<GitTreeEntry[]>([])
38 const [blob, setBlob] = useState<GitBlob | null>(null)
39 const [readme, setReadme] = useState<string | null>(null)
40 const [contentLoading, setContentLoading] = useState(false)
41
42 const currentRef = searchParams.get('ref') ?? ''
43 const currentPath = searchParams.get('path') ?? ''
44 const viewMode: ViewMode = (searchParams.get('type') as ViewMode) ?? 'tree'
45
46 // Load refs once on mount
47 useEffect(() => {
48 getRefs()
49 .then((data) => {
50 setRefs(data)
51 // If no ref in URL yet, use the default branch
52 if (!searchParams.get('ref')) {
53 const defaultRef = data.find((r) => r.isDefault) ?? data[0]
54 if (defaultRef) {
55 setSearchParams(
56 (prev) => { prev.set('ref', defaultRef.shortName); return prev },
57 { replace: true },
58 )
59 }
60 }
61 })
62 .catch((e: Error) => setError(e.message))
63 .finally(() => setRefsLoading(false))
64 }, []) // eslint-disable-line react-hooks/exhaustive-deps
65
66 // Load tree or blob when ref/path/mode changes
67 useEffect(() => {
68 if (!currentRef) return
69 setContentLoading(true)
70 setEntries([])
71 setBlob(null)
72 setReadme(null)
73
74 const load =
75 viewMode === 'blob'
76 ? getBlob(currentRef, currentPath).then((b) => setBlob(b))
77 : getTree(currentRef, currentPath).then((e) => {
78 setEntries(e)
79 const readmeEntry = e.find((entry) =>
80 entry.type === 'blob' &&
81 /^readme(\.md|\.txt|\.rst)?$/i.test(entry.name),
82 )
83 if (readmeEntry) {
84 const readmePath = currentPath
85 ? `${currentPath}/${readmeEntry.name}`
86 : readmeEntry.name
87 getBlob(currentRef, readmePath)
88 .then((b) => !b.isBinary && setReadme(b.content))
89 .catch(() => {/* best-effort */})
90 }
91 })
92
93 load
94 .catch((e: Error) => setError(e.message))
95 .finally(() => setContentLoading(false))
96 }, [currentRef, currentPath, viewMode])
97
98 function navigate(path: string, type: ViewMode = 'tree') {
99 setSearchParams((prev) => {
100 prev.set('path', path)
101 prev.set('type', type)
102 return prev
103 })
104 }
105
106 function handleEntryClick(entry: GitTreeEntry) {
107 const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
108 navigate(newPath, entry.type === 'blob' ? 'blob' : 'tree')
109 }
110
111 function handleNavigateUp() {
112 const parts = currentPath.split('/').filter(Boolean)
113 parts.pop()
114 navigate(parts.join('/'), 'tree')
115 }
116
117 function handleRefSelect(ref: GitRef) {
118 setSearchParams((prev) => {
119 prev.set('ref', ref.shortName)
120 prev.set('path', '')
121 prev.set('type', 'tree')
122 return prev
123 })
124 }
125
126 const { data: repoData } = useQuery(REPO_NAME_QUERY, { variables: { ref: repo } })
127 const repoName = repoData?.repository?.name ?? repo ?? 'default-repo'
128
129 const cloneUrl = `${window.location.origin}/api/repos/_/_`
130 const cloneCmd = `git clone ${cloneUrl} ${repoName}`
131 const [copied, setCopied] = useState(false)
132 function handleCopy() {
133 navigator.clipboard.writeText(cloneCmd).then(() => {
134 setCopied(true)
135 setTimeout(() => setCopied(false), 1500)
136 })
137 }
138
139 if (error) {
140 return (
141 <div className="flex flex-col items-center gap-3 py-16 text-center">
142 <AlertCircle className="size-8 text-muted-foreground" />
143 <p className="text-sm font-medium">Code browser unavailable</p>
144 <p className="max-w-sm text-xs text-muted-foreground">{error}</p>
145 <p className="max-w-sm text-xs text-muted-foreground">
146 Make sure the git-bug server is running and the repository supports code browsing.
147 </p>
148 </div>
149 )
150 }
151
152 return (
153 <div className="space-y-4">
154 {/* Top bar: breadcrumb + ref selector */}
155 <div className="flex flex-wrap items-center justify-between gap-3">
156 {refsLoading ? (
157 <Skeleton className="h-5 w-48" />
158 ) : (
159 <CodeBreadcrumb
160 repoName={repoName}
161 ref={currentRef}
162 path={currentPath}
163 onNavigate={(p) => navigate(p, 'tree')}
164 />
165 )}
166 <div className="flex items-center gap-2">
167 {!refsLoading && (
168 <Button
169 variant={viewMode === 'commits' ? 'secondary' : 'outline'}
170 size="sm"
171 onClick={() =>
172 navigate(
173 currentPath,
174 viewMode === 'commits' ? 'tree' : 'commits',
175 )
176 }
177 >
178 <GitCommit className="size-3.5" />
179 History
180 </Button>
181 )}
182 {refsLoading ? (
183 <Skeleton className="h-8 w-28" />
184 ) : (
185 <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
186 )}
187 </div>
188 </div>
189
190 {/* Clone command */}
191 <div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1.5">
192 <span className="text-xs text-muted-foreground shrink-0">clone</span>
193 <code className="flex-1 truncate text-xs">{cloneCmd}</code>
194 <Button variant="ghost" size="icon" className="size-6 shrink-0" onClick={handleCopy}>
195 {copied ? <Check className="size-3 text-green-600" /> : <Copy className="size-3" />}
196 </Button>
197 </div>
198
199 {/* Content */}
200 {viewMode === 'commits' ? (
201 <CommitList ref_={currentRef} path={currentPath || undefined} />
202 ) : viewMode === 'tree' || !blob ? (
203 <>
204 <FileTree
205 entries={entries}
206 path={currentPath}
207 loading={contentLoading}
208 onNavigate={handleEntryClick}
209 onNavigateUp={handleNavigateUp}
210 />
211 {readme && (
212 <div className="rounded-md border">
213 <div className="border-b px-4 py-2 text-xs font-medium text-muted-foreground">
214 README
215 </div>
216 <div className="px-6 py-4">
217 <Markdown content={readme} />
218 </div>
219 </div>
220 )}
221 </>
222 ) : (
223 <FileViewer blob={blob} ref={currentRef} loading={contentLoading} />
224 )}
225 </div>
226 )
227}