added side context panel and repo only search bar
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -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 (
|
||||
<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 */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -261,6 +265,15 @@ export default function RepoPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user