1 Commits

Author SHA1 Message Date
erangel1 ec9a286d33 added side context panel and repo only search bar 2026-05-17 21:13:45 +02:00
7 changed files with 362 additions and 1 deletions
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
const langStatSchema = z.object({
name: z.string(),
color: z.string(),
count: z.number(),
pct: z.number(),
})
const contributorSchema = z.object({
name: z.string(),
commits: z.number(),
})
const insightsSchema = z.object({
languages: z.array(langStatSchema),
contributors: z.array(contributorSchema),
totalCommits: z.number(),
})
export type LangStat = z.infer<typeof langStatSchema>
export type Contributor = z.infer<typeof contributorSchema>
export type RepoInsights = z.infer<typeof insightsSchema>
export function useRepoInsights(owner: string, repo: string) {
return useQuery<RepoInsights>({
queryKey: ['repos', owner, repo, 'insights'],
queryFn: () => api.get<RepoInsights>(`/api/v1/repos/${owner}/${repo}/insights`, insightsSchema),
enabled: !!owner && !!repo,
staleTime: 5 * 60 * 1000, // 5 min — git stats don't change on every page load
})
}
+36
View File
@@ -194,6 +194,42 @@ export function useCreateRepo() {
}) })
} }
const latestDeploymentSchema = z.object({
envName: z.string(),
status: z.string(),
sha: z.string(),
finishedAt: z.string().nullable().optional(),
})
const pipelineRunSchema = z.object({
id: z.number(),
status: z.string(),
triggerRef: z.string().optional(),
startedAt: z.string().nullable().optional(),
finishedAt: z.string().nullable().optional(),
}).nullable()
const repoHealthSchema = z.object({
ciPassRate7d: z.number(),
totalRuns7d: z.number(),
latestRun: pipelineRunSchema.optional(),
latestDeployments: z.array(latestDeploymentSchema),
openDriftCount: z.number(),
openPRCount: z.number(),
})
export type RepoHealth = z.infer<typeof repoHealthSchema>
export function useRepoHealth(owner: string, repo: string) {
return useQuery<RepoHealth>({
queryKey: ['repos', owner, repo, 'health'],
queryFn: () =>
api.get<RepoHealth>(`/api/v1/repos/${owner}/${repo}/health`, repoHealthSchema),
enabled: Boolean(owner && repo),
staleTime: 60 * 1000, // 1 min
})
}
export function useImportRepo() { export function useImportRepo() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
+14 -1
View File
@@ -6,6 +6,8 @@ import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queri
import { useEnvironments } from '../api/queries/environments' import { useEnvironments } from '../api/queries/environments'
import { useInstance } from '../api/queries/instance' import { useInstance } from '../api/queries/instance'
import { TreeBrowser } from '../components/repos/TreeBrowser' import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoContextPanel } from '../components/repos/RepoContextPanel'
import { RepoFileSearch } from '../components/repos/RepoFileSearch'
import { RepoListSkeleton } from '../ui/Skeleton' import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar' import { RepoAvatar } from '../ui/RepoAvatar'
import { useRecentRepos } from '../hooks/useRecentRepos' import { useRecentRepos } from '../hooks/useRecentRepos'
@@ -59,7 +61,9 @@ export default function RepoPage() {
} }
return ( return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4"> <div className="max-w-[1400px] mx-auto px-4 md:px-6 py-6">
<div className="flex gap-6 items-start">
<div className="flex-1 min-w-0 space-y-4">
{/* Header row */} {/* Header row */}
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
@@ -261,6 +265,15 @@ export default function RepoPage() {
</> </>
)} )}
</div> </div>
{/* Right sidebar — hidden below lg breakpoint */}
<div className="w-72 shrink-0 hidden lg:block space-y-3">
<RepoFileSearch owner={owner} repo={repoName} branch={branch} />
<RepoContextPanel owner={owner} repo={repoName} />
</div>
</div>
</div>
) )
} }
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type InsightsHandler struct {
db *xorm.Engine
}
func NewInsightsHandler(db *xorm.Engine) *InsightsHandler {
return &InsightsHandler{db: db}
}
type insightsResponse struct {
Languages []gitdomain.LangStat `json:"languages"`
Contributors []gitdomain.Contributor `json:"contributors"`
TotalCommits int `json:"totalCommits"`
}
// Get returns language breakdown, top contributors, and total commit count
// for a repository. All data is read directly from git — no DB writes.
func (h *InsightsHandler) Get(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
langs, _ := gitdomain.LanguageStats(repo.DiskPath, repo.DefaultBranch)
contribs, _ := gitdomain.Contributors(repo.DiskPath, 8)
count, _ := gitdomain.CommitCount(repo.DiskPath)
if langs == nil {
langs = []gitdomain.LangStat{}
}
if contribs == nil {
contribs = []gitdomain.Contributor{}
}
jsonOK(w, insightsResponse{
Languages: langs,
Contributors: contribs,
TotalCommits: count,
})
}
+30
View File
@@ -650,3 +650,33 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
jsonError(w, "repository not found", http.StatusNotFound) jsonError(w, "repository not found", http.StatusNotFound)
return nil, false return nil, false
} }
// SearchFiles handles GET /repos/{owner}/{repo}/files?q=...&ref=...
// Returns up to 20 matching file paths (case-insensitive substring match).
func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
jsonOK(w, []string{})
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, 20)
if err != nil {
jsonError(w, "search failed", http.StatusInternalServerError)
return
}
if files == nil {
files = []string{}
}
jsonOK(w, files)
}
+3
View File
@@ -81,6 +81,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner) vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
archiveH := handlers.NewArchiveHandler(engine) archiveH := handlers.NewArchiveHandler(engine)
instanceH := handlers.NewInstanceHandler(cfg) instanceH := handlers.NewInstanceHandler(cfg)
insightsH := handlers.NewInsightsHandler(engine)
// ── Git smart-HTTP transport ─────────────────────────────────────────────── // ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs // Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -181,6 +182,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/commits", repoH.Commits) r.Get("/commits", repoH.Commits)
r.Get("/branches", repoH.Branches) r.Get("/branches", repoH.Branches)
r.Get("/archive", archiveH.Download) r.Get("/archive", archiveH.Download)
r.Get("/insights", insightsH.Get)
r.Get("/files", repoH.SearchFiles)
r.Get("/diff", repoH.Diff) r.Get("/diff", repoH.Diff)
r.Route("/pulls", func(r chi.Router) { r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List) r.Get("/", prH.List)
+196
View File
@@ -7,6 +7,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
) )
@@ -508,3 +510,197 @@ func ArchiveStream(repoPath string, ref string, format string, w io.Writer) erro
} }
return nil return nil
} }
// ── Language stats ─────────────────────────────────────────────────────────────
// LangStat holds the aggregate file-count and percentage for one language.
type LangStat struct {
Name string `json:"name"`
Color string `json:"color"`
Count int `json:"count"`
Pct float64 `json:"pct"`
}
// extLang maps file extensions to (display name, hex color).
var extLang = map[string][2]string{
".go": {"Go", "#00ADD8"},
".ts": {"TypeScript", "#3178C6"},
".tsx": {"TypeScript", "#3178C6"},
".js": {"JavaScript", "#F7DF1E"},
".jsx": {"JavaScript", "#F7DF1E"},
".mjs": {"JavaScript", "#F7DF1E"},
".py": {"Python", "#3572A5"},
".rb": {"Ruby", "#CC342D"},
".rs": {"Rust", "#DEA584"},
".java": {"Java", "#B07219"},
".cs": {"C#", "#178600"},
".cpp": {"C++", "#F34B7D"},
".cc": {"C++", "#F34B7D"},
".c": {"C", "#555555"},
".h": {"C", "#555555"},
".swift": {"Swift", "#F05138"},
".kt": {"Kotlin", "#A97BFF"},
".php": {"PHP", "#4F5D95"},
".html": {"HTML", "#E34C26"},
".css": {"CSS", "#563D7C"},
".scss": {"SCSS", "#C6538C"},
".sql": {"SQL", "#e38c00"},
".sh": {"Shell", "#89E051"},
".bash": {"Shell", "#89E051"},
".yaml": {"YAML", "#CB171E"},
".yml": {"YAML", "#CB171E"},
".json": {"JSON", "#292929"},
".md": {"Markdown", "#083FA1"},
".tf": {"HCL", "#844FBA"},
".proto": {"Protobuf", "#5F5CE9"},
".dart": {"Dart", "#00B4AB"},
".lua": {"Lua", "#000080"},
".r": {"R", "#198CE7"},
}
// LanguageStats analyses the file tree at ref and returns language breakdown
// sorted by file count descending. Languages under 3% are collapsed into "Other".
func LanguageStats(repoPath, ref string) ([]LangStat, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return nil, err
}
counts := map[string]int{}
total := 0
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
ext := strings.ToLower(filepath.Ext(line))
lang, ok := extLang[ext]
if ok {
counts[lang[0]]++
} else {
counts["Other"]++
}
total++
}
if total == 0 {
return []LangStat{}, nil
}
// Aggregate by name with color lookup.
colorOf := map[string]string{"Other": "#8B8B8B"}
for _, v := range extLang {
colorOf[v[0]] = v[1]
}
var stats []LangStat
otherCount := 0
for name, count := range counts {
pct := float64(count) / float64(total) * 100
if name == "Other" || pct < 3.0 {
otherCount += count
continue
}
stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct})
}
sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count })
if len(stats) > 10 {
for _, s := range stats[10:] {
otherCount += s.Count
}
stats = stats[:10]
}
if otherCount > 0 {
stats = append(stats, LangStat{
Name: "Other",
Color: "#8B8B8B",
Count: otherCount,
Pct: float64(otherCount) / float64(total) * 100,
})
}
return stats, nil
}
// ── Contributor stats ─────────────────────────────────────────────────────────
// Contributor holds a commit author name and their commit count.
type Contributor struct {
Name string `json:"name"`
Commits int `json:"commits"`
}
// Contributors returns the top limit commit authors sorted by commit count.
// Uses git shortlog which is fast even on large repos.
func Contributors(repoPath string, limit int) ([]Contributor, error) {
out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD")
if err != nil {
return []Contributor{}, nil // empty repo or detached HEAD — not an error
}
var result []Contributor
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: " 42\tAlice Wang"
idx := strings.Index(line, "\t")
if idx < 0 {
continue
}
countStr := strings.TrimSpace(line[:idx])
name := strings.TrimSpace(line[idx+1:])
n, err := strconv.Atoi(countStr)
if err != nil {
continue
}
result = append(result, Contributor{Name: name, Commits: n})
if len(result) >= limit {
break
}
}
return result, nil
}
// CommitCount returns the total number of commits reachable from HEAD.
func CommitCount(repoPath string) (int, error) {
out, err := run(repoPath, "rev-list", "--count", "HEAD")
if err != nil {
return 0, nil // empty repo
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, nil
}
return n, nil
}
// SearchFiles returns file paths matching query (case-insensitive substring)
// in the repository tree at ref, capped at limit results.
func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return []string{}, nil // empty repo
}
lower := strings.ToLower(query)
var results []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(strings.ToLower(line), lower) {
results = append(results, line)
if len(results) >= limit {
break
}
}
}
return results, nil
}