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() {
|
export function useImportRepo() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user