CodePage.tsx

  1import { useState, useEffect } from 'react'
  2import { useSearchParams } from 'react-router-dom'
  3import { gql, useQuery } from '@apollo/client'
  4import { AlertCircle, 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'
 15
 16const REPO_NAME_QUERY = gql`
 17  query RepoName($ref: String) {
 18    repository(ref: $ref) {
 19      name
 20    }
 21  }
 22`
 23
 24type ViewMode = 'tree' | 'blob' | 'commits'
 25
 26// Code browser page (/:repo). Switches between tree view, file viewer, and
 27// commit history via the ?type= search param. Ref is selected via ?ref=.
 28export function CodePage() {
 29  const repo = useRepo()
 30  const [searchParams, setSearchParams] = useSearchParams()
 31
 32  const [refs, setRefs] = useState<GitRef[]>([])
 33  const [refsLoading, setRefsLoading] = useState(true)
 34  const [error, setError] = useState<string | null>(null)
 35
 36  const [entries, setEntries] = useState<GitTreeEntry[]>([])
 37  const [blob, setBlob] = useState<GitBlob | null>(null)
 38  const [contentLoading, setContentLoading] = useState(false)
 39
 40  const currentRef = searchParams.get('ref') ?? ''
 41  const currentPath = searchParams.get('path') ?? ''
 42  const viewMode: ViewMode = (searchParams.get('type') as ViewMode) ?? 'tree'
 43
 44  // Load refs once on mount
 45  useEffect(() => {
 46    getRefs()
 47      .then((data) => {
 48        setRefs(data)
 49        // If no ref in URL yet, use the default branch
 50        if (!searchParams.get('ref')) {
 51          const defaultRef = data.find((r) => r.isDefault) ?? data[0]
 52          if (defaultRef) {
 53            setSearchParams(
 54              (prev) => { prev.set('ref', defaultRef.shortName); return prev },
 55              { replace: true },
 56            )
 57          }
 58        }
 59      })
 60      .catch((e: Error) => setError(e.message))
 61      .finally(() => setRefsLoading(false))
 62  }, []) // eslint-disable-line react-hooks/exhaustive-deps
 63
 64  // Load tree or blob when ref/path/mode changes
 65  useEffect(() => {
 66    if (!currentRef) return
 67    setContentLoading(true)
 68    setEntries([])
 69    setBlob(null)
 70
 71    const load =
 72      viewMode === 'blob'
 73        ? getBlob(currentRef, currentPath).then((b) => setBlob(b))
 74        : getTree(currentRef, currentPath).then((e) => setEntries(e))
 75
 76    load
 77      .catch((e: Error) => setError(e.message))
 78      .finally(() => setContentLoading(false))
 79  }, [currentRef, currentPath, viewMode])
 80
 81  function navigate(path: string, type: ViewMode = 'tree') {
 82    setSearchParams((prev) => {
 83      prev.set('path', path)
 84      prev.set('type', type)
 85      return prev
 86    })
 87  }
 88
 89  function handleEntryClick(entry: GitTreeEntry) {
 90    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
 91    navigate(newPath, entry.type === 'blob' ? 'blob' : 'tree')
 92  }
 93
 94  function handleNavigateUp() {
 95    const parts = currentPath.split('/').filter(Boolean)
 96    parts.pop()
 97    navigate(parts.join('/'), 'tree')
 98  }
 99
100  function handleRefSelect(ref: GitRef) {
101    setSearchParams((prev) => {
102      prev.set('ref', ref.shortName)
103      prev.set('path', '')
104      prev.set('type', 'tree')
105      return prev
106    })
107  }
108
109  const { data: repoData } = useQuery(REPO_NAME_QUERY, { variables: { ref: repo } })
110  const repoName = repoData?.repository?.name ?? repo ?? 'git-bug'
111
112  if (error) {
113    return (
114      <div className="flex flex-col items-center gap-3 py-16 text-center">
115        <AlertCircle className="size-8 text-muted-foreground" />
116        <p className="text-sm font-medium">Code browser unavailable</p>
117        <p className="max-w-sm text-xs text-muted-foreground">{error}</p>
118        <p className="max-w-sm text-xs text-muted-foreground">
119          Make sure the git-bug server is running and the repository supports code browsing.
120        </p>
121      </div>
122    )
123  }
124
125  return (
126    <div className="space-y-4">
127      {/* Top bar: breadcrumb + ref selector */}
128      <div className="flex flex-wrap items-center justify-between gap-3">
129        {refsLoading ? (
130          <Skeleton className="h-5 w-48" />
131        ) : (
132          <CodeBreadcrumb
133            repoName={repoName}
134            ref={currentRef}
135            path={currentPath}
136            onNavigate={(p) => navigate(p, 'tree')}
137          />
138        )}
139        <div className="flex items-center gap-2">
140          {!refsLoading && (
141            <Button
142              variant={viewMode === 'commits' ? 'secondary' : 'outline'}
143              size="sm"
144              onClick={() =>
145                navigate(
146                  currentPath,
147                  viewMode === 'commits' ? 'tree' : 'commits',
148                )
149              }
150            >
151              <GitCommit className="size-3.5" />
152              History
153            </Button>
154          )}
155          {refsLoading ? (
156            <Skeleton className="h-8 w-28" />
157          ) : (
158            <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
159          )}
160        </div>
161      </div>
162
163      {/* Content */}
164      {viewMode === 'commits' ? (
165        <CommitList ref_={currentRef} path={currentPath || undefined} />
166      ) : viewMode === 'tree' || !blob ? (
167        <FileTree
168          entries={entries}
169          path={currentPath}
170          loading={contentLoading}
171          onNavigate={handleEntryClick}
172          onNavigateUp={handleNavigateUp}
173        />
174      ) : (
175        <FileViewer blob={blob} ref={currentRef} loading={contentLoading} />
176      )}
177    </div>
178  )
179}