CodePage.tsx

  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}