From ec9a286d33e65c6201453cbf68e81d8a0f2c19c8 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Sun, 17 May 2026 21:13:45 +0200 Subject: [PATCH] added side context panel and repo only search bar --- frontend/src/api/queries/insights.ts | 34 +++++ frontend/src/api/queries/repos.ts | 36 +++++ frontend/src/pages/RepoPage.tsx | 15 +- internal/api/handlers/insights.go | 49 +++++++ internal/api/handlers/repos.go | 30 ++++ internal/api/router.go | 3 + internal/domain/git/binary.go | 196 +++++++++++++++++++++++++++ 7 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/queries/insights.ts create mode 100644 internal/api/handlers/insights.go diff --git a/frontend/src/api/queries/insights.ts b/frontend/src/api/queries/insights.ts new file mode 100644 index 0000000..7e4bbff --- /dev/null +++ b/frontend/src/api/queries/insights.ts @@ -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 +export type Contributor = z.infer +export type RepoInsights = z.infer + +export function useRepoInsights(owner: string, repo: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'insights'], + queryFn: () => api.get(`/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 + }) +} diff --git a/frontend/src/api/queries/repos.ts b/frontend/src/api/queries/repos.ts index fde644a..e87e133 100644 --- a/frontend/src/api/queries/repos.ts +++ b/frontend/src/api/queries/repos.ts @@ -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 + +export function useRepoHealth(owner: string, repo: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'health'], + queryFn: () => + api.get(`/api/v1/repos/${owner}/${repo}/health`, repoHealthSchema), + enabled: Boolean(owner && repo), + staleTime: 60 * 1000, // 1 min + }) +} + export function useImportRepo() { const queryClient = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/RepoPage.tsx b/frontend/src/pages/RepoPage.tsx index 0c767e1..1f4fd58 100644 --- a/frontend/src/pages/RepoPage.tsx +++ b/frontend/src/pages/RepoPage.tsx @@ -6,6 +6,8 @@ import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queri import { useEnvironments } from '../api/queries/environments' import { useInstance } from '../api/queries/instance' import { TreeBrowser } from '../components/repos/TreeBrowser' +import { RepoContextPanel } from '../components/repos/RepoContextPanel' +import { RepoFileSearch } from '../components/repos/RepoFileSearch' import { RepoListSkeleton } from '../ui/Skeleton' import { RepoAvatar } from '../ui/RepoAvatar' import { useRecentRepos } from '../hooks/useRecentRepos' @@ -59,7 +61,9 @@ export default function RepoPage() { } return ( -
+
+
+
{/* Header row */}
@@ -261,6 +265,15 @@ export default function RepoPage() { )}
+ + {/* Right sidebar — hidden below lg breakpoint */} +
+ + +
+ +
+
) } diff --git a/internal/api/handlers/insights.go b/internal/api/handlers/insights.go new file mode 100644 index 0000000..f202173 --- /dev/null +++ b/internal/api/handlers/insights.go @@ -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, + }) +} diff --git a/internal/api/handlers/repos.go b/internal/api/handlers/repos.go index cb08b79..a2509d8 100644 --- a/internal/api/handlers/repos.go +++ b/internal/api/handlers/repos.go @@ -650,3 +650,33 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model jsonError(w, "repository not found", http.StatusNotFound) 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) +} diff --git a/internal/api/router.go b/internal/api/router.go index ec3d3b3..30c78c3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -81,6 +81,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even vulnH := handlers.NewVulnScanHandler(engine, vulnScanner) archiveH := handlers.NewArchiveHandler(engine) instanceH := handlers.NewInstanceHandler(cfg) + insightsH := handlers.NewInsightsHandler(engine) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // 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("/branches", repoH.Branches) r.Get("/archive", archiveH.Download) + r.Get("/insights", insightsH.Get) + r.Get("/files", repoH.SearchFiles) r.Get("/diff", repoH.Diff) r.Route("/pulls", func(r chi.Router) { r.Get("/", prH.List) diff --git a/internal/domain/git/binary.go b/internal/domain/git/binary.go index b615ea0..0cbb0ae 100644 --- a/internal/domain/git/binary.go +++ b/internal/domain/git/binary.go @@ -7,6 +7,8 @@ import ( "os" "os/exec" "path/filepath" + "sort" + "strconv" "strings" ) @@ -508,3 +510,197 @@ func ArchiveStream(repoPath string, ref string, format string, w io.Writer) erro } 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 +}