Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

web.go (41488B)


      1 package jci
      2 
      3 import (
      4 	"encoding/json"
      5 	"fmt"
      6 	"net/http"
      7 	"os/exec"
      8 	"path/filepath"
      9 	"strings"
     10 )
     11 
     12 // BranchInfo holds branch data for the UI
     13 type BranchInfo struct {
     14 	Name     string `json:"name"`
     15 	IsRemote bool   `json:"isRemote"`
     16 }
     17 
     18 // CommitInfo holds commit data for the UI
     19 type CommitInfo struct {
     20 	Hash      string    `json:"hash"`
     21 	ShortHash string    `json:"shortHash"`
     22 	Message   string    `json:"message"`
     23 	HasCI     bool      `json:"hasCI"`
     24 	CIStatus  string    `json:"ciStatus"` // "success", "failed", or ""
     25 	Runs      []RunInfo `json:"runs"`
     26 }
     27 
     28 // RunInfo holds info about a single CI run
     29 type RunInfo struct {
     30 	RunID  string `json:"runId"`
     31 	Status string `json:"status"`
     32 	Ref    string `json:"ref"`
     33 }
     34 
     35 // Web starts a web server to view CI results
     36 func Web(args []string) error {
     37 	port := "8000"
     38 	if len(args) > 0 {
     39 		port = args[0]
     40 	}
     41 
     42 	repoRoot, err := GetRepoRoot()
     43 	if err != nil {
     44 		return err
     45 	}
     46 
     47 	fmt.Printf("Starting JCI web server on http://localhost:%s\n", port)
     48 	fmt.Println("Press Ctrl+C to stop")
     49 
     50 	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
     51 		handleRequest(w, r, repoRoot)
     52 	})
     53 
     54 	return http.ListenAndServe(":"+port, nil)
     55 }
     56 
     57 func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) {
     58 	path := r.URL.Path
     59 
     60 	// API endpoint for branch data (names only, no commits)
     61 	if path == "/api/branches" {
     62 		serveBranchesAPI(w)
     63 		return
     64 	}
     65 
     66 	// API endpoint for paginated commits for a branch
     67 	if path == "/api/commits" {
     68 		serveCommitsAPI(w, r)
     69 		return
     70 	}
     71 
     72 	// API endpoint for commit info
     73 	if strings.HasPrefix(path, "/api/commit/") {
     74 		commit := strings.TrimPrefix(path, "/api/commit/")
     75 		serveCommitAPI(w, commit)
     76 		return
     77 	}
     78 
     79 	// /jci/<commit>/<file>/raw - serve raw file
     80 	// Also handles /jci/<commit>/<runid>/<file>/raw
     81 	if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") {
     82 		trimmed := strings.TrimPrefix(path, "/jci/")
     83 		trimmed = strings.TrimSuffix(trimmed, "/raw")
     84 		// trimmed is now: <commit>/<file> or <commit>/<runid>/<file>
     85 		parts := strings.SplitN(trimmed, "/", 3)
     86 		if len(parts) == 2 && parts[1] != "" {
     87 			// <commit>/<file>
     88 			serveFromRef(w, parts[0], "", parts[1])
     89 			return
     90 		} else if len(parts) == 3 && parts[2] != "" {
     91 			// <commit>/<runid>/<file>
     92 			serveFromRef(w, parts[0], parts[1], parts[2])
     93 			return
     94 		}
     95 	}
     96 
     97 	// Root or /jci/... - show main SPA (UI handles routing)
     98 	if path == "/" || strings.HasPrefix(path, "/jci/") {
     99 		showMainPage(w, r)
    100 		return
    101 	}
    102 
    103 	http.NotFound(w, r)
    104 }
    105 
    106 // getLocalBranches returns local branch names
    107 func getLocalBranches() ([]string, error) {
    108 	out, err := git("branch", "--format=%(refname:short)")
    109 	if err != nil {
    110 		return nil, err
    111 	}
    112 	if out == "" {
    113 		return nil, nil
    114 	}
    115 	return strings.Split(out, "\n"), nil
    116 }
    117 
    118 // getCIStatusFromRef returns status from a refs/jci-runs/ ref
    119 func getCIStatusFromRef(ref string) string {
    120 	cmd := exec.Command("git", "show", ref+":status.txt")
    121 	out, err := cmd.Output()
    122 	if err != nil {
    123 		return ""
    124 	}
    125 	switch strings.TrimSpace(string(out)) {
    126 	case "ok":
    127 		return "success"
    128 	case "err":
    129 		return "failed"
    130 	case "running":
    131 		return "running"
    132 	}
    133 	return ""
    134 }
    135 
    136 // CommitDetail holds detailed commit info for the API
    137 type CommitDetail struct {
    138 	Hash   string    `json:"hash"`
    139 	Author string    `json:"author"`
    140 	Date   string    `json:"date"`
    141 	Status string    `json:"status"`
    142 	Files  []string  `json:"files"`
    143 	Ref    string    `json:"ref"`   // The actual ref used
    144 	RunID  string    `json:"runId"` // Current run ID
    145 	Runs   []RunInfo `json:"runs"`  // All runs for this commit
    146 }
    147 
    148 // serveCommitAPI returns commit details and file list
    149 // commit can be just <hash> or <hash>/<runid>
    150 func serveCommitAPI(w http.ResponseWriter, commit string) {
    151 	var ref string
    152 	var actualCommit string
    153 	var currentRunID string
    154 
    155 	// Check if this is a run-specific request: <commit>/<runid>
    156 	if strings.Contains(commit, "/") {
    157 		parts := strings.SplitN(commit, "/", 2)
    158 		actualCommit = parts[0]
    159 		currentRunID = parts[1]
    160 		ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID
    161 	} else {
    162 		actualCommit = commit
    163 		// Find the latest run for this commit
    164 		runRefs, _ := ListJCIRunRefs()
    165 		for _, r := range runRefs {
    166 			if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
    167 				ref = r // last one wins (sorted by timestamp)
    168 				parts := strings.Split(r, "/")
    169 				if len(parts) >= 4 {
    170 					currentRunID = parts[3]
    171 				}
    172 			}
    173 		}
    174 	}
    175 
    176 	if !RefExists(ref) {
    177 		http.Error(w, "not found", 404)
    178 		return
    179 	}
    180 
    181 	// Get all runs for this commit
    182 	var runs []RunInfo
    183 	runRefs, _ := ListJCIRunRefs()
    184 	for _, r := range runRefs {
    185 		if strings.HasPrefix(r, "refs/jci-runs/"+actualCommit+"/") {
    186 			parts := strings.Split(r, "/")
    187 			if len(parts) >= 4 {
    188 				runID := parts[3]
    189 				status := getCIStatusFromRef(r)
    190 				runs = append(runs, RunInfo{
    191 					RunID:  runID,
    192 					Status: status,
    193 					Ref:    r,
    194 				})
    195 			}
    196 		}
    197 	}
    198 
    199 	// Get commit info
    200 	author, _ := git("log", "-1", "--format=%an", actualCommit)
    201 	date, _ := git("log", "-1", "--format=%cr", actualCommit)
    202 	status := getCIStatusFromRef(ref)
    203 
    204 	// List files in the CI ref
    205 	filesOut, err := git("ls-tree", "--name-only", ref)
    206 	var files []string
    207 	if err == nil && filesOut != "" {
    208 		for _, f := range strings.Split(filesOut, "\n") {
    209 			if f != "" && f != "index.html" {
    210 				files = append(files, f)
    211 			}
    212 		}
    213 	}
    214 
    215 	detail := CommitDetail{
    216 		Hash:   actualCommit,
    217 		Author: author,
    218 		Date:   date,
    219 		Status: status,
    220 		Files:  files,
    221 		Ref:    ref,
    222 		RunID:  currentRunID,
    223 		Runs:   runs,
    224 	}
    225 
    226 	w.Header().Set("Content-Type", "application/json")
    227 	json.NewEncoder(w).Encode(detail)
    228 }
    229 
    230 // serveBranchesAPI returns branch names as JSON (no commits for fast load)
    231 func serveBranchesAPI(w http.ResponseWriter) {
    232 	branches, err := getLocalBranches()
    233 	if err != nil {
    234 		http.Error(w, err.Error(), 500)
    235 		return
    236 	}
    237 
    238 	var branchInfos []BranchInfo
    239 	for _, branch := range branches {
    240 		branchInfos = append(branchInfos, BranchInfo{
    241 			Name:     branch,
    242 			IsRemote: false,
    243 		})
    244 	}
    245 
    246 	w.Header().Set("Content-Type", "application/json")
    247 	json.NewEncoder(w).Encode(branchInfos)
    248 }
    249 
    250 // CommitsPage holds a page of commits for the paginated API
    251 type CommitsPage struct {
    252 	Branch   string       `json:"branch"`
    253 	Page     int          `json:"page"`
    254 	PageSize int          `json:"pageSize"`
    255 	HasMore  bool         `json:"hasMore"`
    256 	Commits  []CommitInfo `json:"commits"`
    257 }
    258 
    259 const commitsPageSize = 100
    260 
    261 // serveCommitsAPI returns a paginated list of commits for a branch.
    262 // Query params: branch (required), page (optional, 0-indexed, default 0)
    263 func serveCommitsAPI(w http.ResponseWriter, r *http.Request) {
    264 	branch := r.URL.Query().Get("branch")
    265 	if branch == "" {
    266 		http.Error(w, "branch query parameter is required", 400)
    267 		return
    268 	}
    269 
    270 	page := 0
    271 	if p := r.URL.Query().Get("page"); p != "" {
    272 		fmt.Sscanf(p, "%d", &page)
    273 		if page < 0 {
    274 			page = 0
    275 		}
    276 	}
    277 
    278 	// Fetch one extra commit beyond the page size to detect whether more pages exist
    279 	offset := page * commitsPageSize
    280 	limit := commitsPageSize + 1
    281 
    282 	commits, err := getBranchCommitsPaginated(branch, offset, limit)
    283 	if err != nil {
    284 		http.Error(w, err.Error(), 500)
    285 		return
    286 	}
    287 
    288 	hasMore := len(commits) > commitsPageSize
    289 	if hasMore {
    290 		commits = commits[:commitsPageSize]
    291 	}
    292 
    293 	result := CommitsPage{
    294 		Branch:   branch,
    295 		Page:     page,
    296 		PageSize: commitsPageSize,
    297 		HasMore:  hasMore,
    298 		Commits:  commits,
    299 	}
    300 
    301 	w.Header().Set("Content-Type", "application/json")
    302 	json.NewEncoder(w).Encode(result)
    303 }
    304 
    305 // getBranchCommitsPaginated returns commits for a branch starting at the given offset.
    306 func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, error) {
    307 	out, err := git("log", branch,
    308 		fmt.Sprintf("--skip=%d", offset),
    309 		fmt.Sprintf("--max-count=%d", limit),
    310 		"--format=%H|%s")
    311 	if err != nil {
    312 		return nil, err
    313 	}
    314 	if out == "" {
    315 		return nil, nil
    316 	}
    317 
    318 	// Get local JCI run refs: commit -> list of run refs
    319 	jciRuns := make(map[string][]string)
    320 	runRefs, _ := ListJCIRunRefs()
    321 	for _, ref := range runRefs {
    322 		parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/")
    323 		if len(parts) >= 2 {
    324 			commit := parts[0]
    325 			jciRuns[commit] = append(jciRuns[commit], ref)
    326 		}
    327 	}
    328 
    329 	var commits []CommitInfo
    330 	for _, line := range strings.Split(out, "\n") {
    331 		parts := strings.SplitN(line, "|", 2)
    332 		if len(parts) != 2 {
    333 			continue
    334 		}
    335 		hash := parts[0]
    336 		msg := parts[1]
    337 
    338 		hasCI := len(jciRuns[hash]) > 0
    339 		commit := CommitInfo{
    340 			Hash:      hash,
    341 			ShortHash: hash[:7],
    342 			Message:   msg,
    343 			HasCI:     hasCI,
    344 		}
    345 
    346 		for _, runRef := range jciRuns[hash] {
    347 			rparts := strings.Split(strings.TrimPrefix(runRef, "refs/jci-runs/"), "/")
    348 			if len(rparts) >= 2 {
    349 				runID := rparts[1]
    350 				status := getCIStatusFromRef(runRef)
    351 				commit.Runs = append(commit.Runs, RunInfo{
    352 					RunID:  runID,
    353 					Status: status,
    354 					Ref:    runRef,
    355 				})
    356 			}
    357 		}
    358 
    359 		if commit.CIStatus == "" && len(commit.Runs) > 0 {
    360 			commit.CIStatus = commit.Runs[len(commit.Runs)-1].Status
    361 		}
    362 
    363 		commits = append(commits, commit)
    364 	}
    365 
    366 	return commits, nil
    367 }
    368 
    369 func showMainPage(w http.ResponseWriter, r *http.Request) {
    370 	w.Header().Set("Content-Type", "text/html")
    371 	fmt.Fprint(w, `<!DOCTYPE html>
    372 <html>
    373 <head>
    374     <meta charset="utf-8">
    375     <meta name="viewport" content="width=device-width, initial-scale=1">
    376     <title>JCI</title>
    377     <style>
    378         * { box-sizing: border-box; margin: 0; padding: 0; }
    379         body {
    380             font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
    381             font-size: 12px;
    382             background: #f5f5f5;
    383             color: #333;
    384             display: flex;
    385             height: 100vh;
    386             overflow: hidden;
    387         }
    388         a { color: #0969da; text-decoration: none; }
    389         a:hover { text-decoration: underline; }
    390         
    391         /* Left panel - commits */
    392         .commits-panel {
    393             width: 280px;
    394             background: #fff;
    395             border-right: 1px solid #d0d7de;
    396             display: flex;
    397             flex-direction: column;
    398             flex-shrink: 0;
    399         }
    400         .panel-header {
    401             padding: 8px 10px;
    402             background: #f6f8fa;
    403             border-bottom: 1px solid #d0d7de;
    404             display: flex;
    405             align-items: center;
    406             gap: 8px;
    407         }
    408         .panel-header h1 { font-size: 13px; font-weight: 600; color: #24292f; }
    409         .logo { height: 24px; width: auto; }
    410         .branch-selector {
    411             flex: 1;
    412             padding: 4px 8px;
    413             font-size: 12px;
    414             border: 1px solid #d0d7de;
    415             border-radius: 6px;
    416             background: #fff;
    417             color: #24292f;
    418         }
    419         .commit-list {
    420             list-style: none;
    421             overflow-y: auto;
    422             flex: 1;
    423         }
    424         .commit-item {
    425             padding: 6px 10px;
    426             cursor: pointer;
    427             display: flex;
    428             align-items: center;
    429             gap: 8px;
    430             border-bottom: 1px solid #eaeef2;
    431         }
    432         .commit-item:hover { background: #f6f8fa; }
    433         .commit-item.selected { background: #ddf4ff; }
    434         .commit-item.no-ci { opacity: 0.5; }
    435         
    436         /* Status indicator */
    437         .status-badge {
    438             font-size: 10px;
    439             font-weight: 600;
    440             padding: 2px 6px;
    441             border-radius: 12px;
    442             flex-shrink: 0;
    443         }
    444         .status-badge.success { background: #dafbe1; color: #1a7f37; }
    445         .status-badge.failed { background: #ffebe9; color: #cf222e; }
    446         .status-badge.running { background: #fff8c5; color: #9a6700; }
    447         .status-badge.none { background: #eaeef2; color: #656d76; }
    448         
    449         .commit-hash { font-size: 11px; color: #0969da; flex-shrink: 0; font-family: monospace; }
    450         .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #57606a; font-size: 12px; }
    451         
    452         /* Push status badge */
    453         .push-badge {
    454             font-size: 9px;
    455             font-weight: 600;
    456             padding: 1px 5px;
    457             border-radius: 10px;
    458             flex-shrink: 0;
    459         }
    460         .push-badge.pushed { background: #ddf4ff; color: #0969da; }
    461         .push-badge.local { background: #fff8c5; color: #9a6700; }
    462         
    463         /* Middle panel - files */
    464         .files-panel {
    465             width: 200px;
    466             background: #fff;
    467             border-right: 1px solid #d0d7de;
    468             display: flex;
    469             flex-direction: column;
    470             flex-shrink: 0;
    471         }
    472         .files-panel.hidden { display: none; }
    473         .commit-info {
    474             padding: 10px 12px;
    475             background: #f6f8fa;
    476             border-bottom: 1px solid #d0d7de;
    477             font-size: 12px;
    478         }
    479         .commit-info .status-line {
    480             display: flex;
    481             align-items: center;
    482             gap: 8px;
    483             margin-bottom: 4px;
    484         }
    485         .commit-info .status-icon { font-size: 14px; font-weight: bold; }
    486         .commit-info .status-icon.success { color: #1a7f37; }
    487         .commit-info .status-icon.failed { color: #cf222e; }
    488         .commit-info .status-icon.running { color: #9a6700; }
    489         .commit-info .hash { color: #0969da; font-family: monospace; }
    490         .commit-info .meta { color: #656d76; margin-top: 4px; font-size: 11px; }
    491         .run-selector { margin-top: 8px; }
    492         .run-selector select { width: 100%; padding: 4px 6px; font-size: 11px; border: 1px solid #d0d7de; border-radius: 4px; background: #fff; }
    493         .run-nav { display: flex; gap: 4px; margin-top: 6px; }
    494         .run-nav button { flex: 1; padding: 4px 8px; font-size: 10px; border: 1px solid #d0d7de; border-radius: 4px; background: #f6f8fa; cursor: pointer; }
    495         .run-nav button:hover { background: #eaeef2; }
    496         .run-nav button:disabled { opacity: 0.5; cursor: not-allowed; }
    497         .file-list {
    498             list-style: none;
    499             overflow-y: auto;
    500             flex: 1;
    501         }
    502         .file-item {
    503             padding: 6px 12px;
    504             cursor: pointer;
    505             border-bottom: 1px solid #eaeef2;
    506             white-space: nowrap;
    507             overflow: hidden;
    508             text-overflow: ellipsis;
    509             font-size: 12px;
    510             color: #24292f;
    511         }
    512         .file-item:hover { background: #f6f8fa; }
    513         .file-item.selected { background: #ddf4ff; }
    514         
    515         /* Right panel - content */
    516         .content-panel {
    517             flex: 1;
    518             display: flex;
    519             flex-direction: column;
    520             min-width: 0;
    521             background: #fff;
    522         }
    523         .content-header {
    524             padding: 6px 12px;
    525             background: #f6f8fa;
    526             border-bottom: 1px solid #d0d7de;
    527             font-size: 12px;
    528             color: #57606a;
    529             font-family: monospace;
    530             display: flex;
    531             align-items: center;
    532             justify-content: space-between;
    533         }
    534         .content-header .filename { flex: 1; }
    535         .download-btn {
    536             padding: 3px 8px;
    537             font-size: 11px;
    538             background: #fff;
    539             border: 1px solid #d0d7de;
    540             border-radius: 4px;
    541             color: #24292f;
    542             cursor: pointer;
    543             text-decoration: none;
    544         }
    545         .download-btn:hover { background: #f6f8fa; text-decoration: none; }
    546         .content-body {
    547             flex: 1;
    548             overflow: auto;
    549             background: #fff;
    550         }
    551         .content-body pre {
    552             padding: 12px;
    553             font-family: "Monaco", "Menlo", "Consolas", monospace;
    554             font-size: 12px;
    555             line-height: 1.5;
    556             white-space: pre-wrap;
    557             word-wrap: break-word;
    558             color: #24292f;
    559         }
    560         .content-body iframe {
    561             width: 100%;
    562             height: 100%;
    563             border: none;
    564             background: #fff;
    565         }
    566         .empty-state {
    567             display: flex;
    568             align-items: center;
    569             justify-content: center;
    570             height: 100%;
    571             color: #656d76;
    572             font-size: 13px;
    573         }
    574         
    575         @media (max-width: 700px) {
    576             .commits-panel { width: 200px; }
    577             .files-panel { width: 150px; }
    578         }
    579     </style>
    580 </head>
    581 <body>
    582     <div class="commits-panel">
    583         <div class="panel-header">
    584             <img class="logo" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEUAAABSCAYAAAACXhkKAAAABHNCSVQICAgIfAhkiAAADG1JREFUeF7tXAlsXMUZnt23+/a+T+967Y2PtR3jJk4CzR0gB02hDYW0hUKrlkKLaBGIVm1ppQikqkWqqNRKqIKqRfSiAdIDEKBCWlFIRCBK4hrnThN8r8+93763b3f7/85Rx5m3eW9tJzbekUb2zvzXfPvPP/8cNiGVUkGggkAFgQoCs4qAalalXwHh1dU1OwKh2sfLVdV5aH8tx3Hdk/k15QqbS3ysniGfvatZkUkDPUmy5x/dRRrTxwMUliEbt9XTxifZ9uH+KIJCnSlqSa4F3LHQQaFOn4UOSmX6yI0IC91TqDgtdFAqMYXqFpTGhe4plUBLcQpq00L3lEpMobhFZfpQQKE2LfTpUwGFigClccY9xWKxuNxut4Wia940zRgora2trM1m215T1/CeVmt4bN4gQDF0RkDR6/XhRCr9q+bWtj/WLPI2BELBb7Is20LRNy+apguKymy2X1/f0PxaS1vjPXc+sIT9zk/XEG/AZgqEan4CCDDzAoUpRk4HFMZmc25viER2tq8Otzz4+EqydkstcbgNE+elbrfvZogvn15IoKhdHs8Djc1Nz37yhrD3nu8uJ4EaK1GpzuZCy9YGSOM1bm2wOrzD5/OZ5hswZXmKw+H6Ul195Gdrt9SZvvJQO7E59BeNW6NRk9u+upjYXY72fJ7c97EHxWw2b6hrjDx97YaQ7o7724jeQL8QCEccZOUN1UwgFHoEAnHdfAJGkafodLpIQ6TlxZZ2v/HLDy4lWrhakCpqtYps/UKEuL22kNdX9SjQUfcZUvxXs102KOFwWF9dG/6Fv9rhue97Kwirkwbk/IBcPiPZ/Ll64vH5vwir1IarOVAlumWDkkyn73V7vBvvuP8TxGxlZenAwLt6cy1Z1OSy+AJVP5ovQVcWKBBHWoPB8PdX3hjSLl7mkQXIeSIE8KbbG4nb41rPCcJnFDFfJWI5oKjtLs9D/mp79c13NBGGkcNy8WiuWeEjbSv8bCAY+iEA7L1KY6WpLe+QyWQytXk83tvWbK4hTkjMyikYf9bANHI47K0mk+VekDFXgi7VDvp6+v+Rqx0u7wP+oM21amMNUcGKoqQUi2e/iK4DQ2TXs12Ey2SiPC/0ngOF+i0pkT9btCVBgfyi1ul0b1u1CbzEW4aXwLD37u4u7vptl6r3TP/rvT1nHuJ5/iQMZs4CgkCXBsVk2mB3mb3tqwMXUni53w56Sce+QbLz6c5i95mPnk0l4o8AIHG5/FeIjvrllAJF43K4bw3V2VQev1GxjSODGfKHpw4V+7r7dnHp1MPj4+NJxUKuEoMkKDB1gharff0y8JJSmSvN7kKhSHY+00mGB2OnhqL9D2cymbkKCDVISq6vDMs2W2wGx6ImO23cJduO/WeEwEshsbv79A4ApL8k8RzslATFbDJda7HrIMAqnzpvvHSimIzHOxLj43+Zg2OebBI1pkiBwphM1iV2l56YLPJS+vOahvpT5MyxcVV0eODX0MbPcVDkT59AIKDTaDThYK2V4G5XSTnaMUKyXE4o5HIvKOGbS7RUT8lmsxqtVuuaengkx/Azx8dJKp44Fo/Hx+XQz0UaKiiQY6hUasaA71OVFpw+XDbzoVK+q0RPjSnUJVkURQ0cKDkx0CotyZhACsX8oFK+6dDz2Tz523OHFYkYhjwKCjU2UEFBYrVarVG6I8YsVswXIPtV5xRZOA3ifDGfSqcyo68+35VWIqZAiipSUJnhzEecykcFhWEYURCyY4lY1j2VodRnPFSy2NC7ir5SdDPZN9DXtxPqAMh8XqFcNPQbUC/Jo6gxBQZXLIj5LKwhivSgp+Ayzmp1YUWM0yOuAfbTZYjABIyaaVNBgStPUcjlxpKxrCJd6Ck19XY4rrQsAUaqFyoSKI8Yr2ePyCO9iMoKn1I0Pioo0WhUyIviwFC/omk6IT/S5iIGo8Fuslo30hTOQpsTZJaz+8YTwCGaPVRQgDDHZ7nDwwMZSMQuiUM0ORfaFjU5iNNjIB6X9+slCWemsxbE9JQpCuNllMYrBQpJJGL7INCS8WGOxifZhivW9TfXwX2P9xaj0dguSTgzHbeCmL+XKSoAfCM0XklQCoVCRzLG89E+6rSjybrQtm5rLXH7zYZAsPYJfLdSkrj8ziZgxSRRWeA7qw9twqPEMZp6yZTV4XBwkKtsYnWGUPvqKkUnb+gt3iozOXxwvLavpzeVTqf2gXJq9kgzSkYbXl5vhvoaVGVL5FnhEfiBDnGcpksSlHQ6ndMwmoBOZ7lx+ZoAMZi0NH7JNk+VifBcXj0ymF+TyXJ9WY7Db7WcAUzVgYBsg7obKnVJncpA+bwW2jqhxih9pR/VFIuFpMnkuDsccbHBMK5g8gsuz/UtTjI+yrPphGZTTuCzdputI5lMTifbNYMFeKH2DtRytxI4bRCUf0Kleq+kp+Dw4ZozXsgX1+VyTN3SVVWKjyXxSUbzEgjyBcImRtU38LzYBqvaiVwup2hAePcEWfYW2JNhToIeoogfxzKpYA6FHiaZ8JUEJZFIiDlBjKtVltvAUzRKvQUN0WoZ0tjmxkc9zFhUbGZZ63Y4lmjKi8V0Lsf3AonUlNLBbeI6rz/waDBU80RezG/K5YQf5/P57ouGqOwDxoBboL4KVTLXKAkK6nO7nT2Qvl/PZ9S1y9cFiUYruWBJmocHVf6QmaC3ma16YybBtJuMtu1Ol+fzOoNhNSR7rQaTpclitiyHbHir0+m5N1RTu8NXFfxWKOxftfamOmO0L2nk0oLAcZm3JBVdvgO9BKdMyQyYunWeKttut29rbF78/N0PLjPg9en5Z1xT6eR8xv0RlxbJqSOjpPP9KOk5HSdjkAul4sIEO17IOyD5Cy2ykla4g268xjXxMOiVPx0hv3ly90g8NrYJFoEOObqm0OAyfBfU30HNl+KXBQoIYELhumfqG8Nfe/Tn61Vu/8w8Y0OA8DqkCBPo/BUrAq4CZ0Tvmgw+5EzksW+/RroOnfrrsMVyJzl5Uun5Ly7hOPWOlQIE+y47fc4JKOZzwvssa9o2GhVcK9YHFZ/d0gzBQePg1Yxq4jUDVvx9KiDIq9NriM1uIvvf6W4Qo4NdgiAoOVXCnTQme2/T7JjaJhcUAitGKstnewqCfpuW1Wjrm52KL9ynKlf6GXOf7hMJBuJLREWKLwEwE8dnlym4BG+H+txl6C50ywYFOYRs9jh4u2a4v7Aezk1UcKU6rfgi18jzdLjE+4JW8sE73f5kPD3K89m9l5GBceR2qC9Dlb2JUwQKCC6mkok9apU6MNCdW+byGGGptVxRYPDxciZZIMe6htpEgd8FuQs1KwVbEZCtUD+AqiivUQoKyCcFmPtvc5zY2HOSW6w3akmo3jYjMQaFX65gHKqqsZGDe3rN6UTWDNeyr1B4EBDcQR+A+hGlv2RTOaAQ+H8jPCnk3+IyWWvfaWE5JGKqumZHWU+/Slon0Wkys/A6U0cOvdfbKgj8m+AtfZNI8c9qcG/0LtQeCRElm8sCBSXCW5Os02HfHY2O5Eb6xTW9p5MMHjAZweArUYJhGzlycEQzEk1EMpn070EnJmUNULdAfQMq9QBJjm1lg4LCY7GYmE4l302kkp2ZOFl5YE/UBtNJhdsBXFpns2Bm7a2ykb1vnarO54sfwe1DHejDI8YXocpZlSTNmxYo56QWYVU6CgH4z9lszvvfw8mmIweHtXjV4YRAzMCKMRtF4PNkNJomxzuT6mQisRqm0VNwMPYv0EXd+SqxYSZAmdAHeUw6Hou9DODsTyXFUOe+saoTXaMaTLrw5QILT9aVPiScPBDMePFdYQIy28PwsPCFZz4kb7x4nI+Nxt5PxMd+AAH33zMBCOqcLR/Xwt8C3eqvCjwMG7ylbp/F2LzUQ5au9BOcWri/wWekl9tDIRDoEemkQAZ7UuTA3n5y9NAwvJBKcolEqmN4sPeX8GwM38AoTfkn433J77MFyoSi6upqA8Sd6yxW66fsTvcWOBdpsTtNenizr8LsFHMcs42dSOHx3hqNQU8Q4G44GecJ/DOp4tBAWjUazZDYKFzbp1JHx8ZH30wlUq9brcZ9/f3904odl6BxrmFWQZms1Ol0WjlRXKxn2CVWm/U6vdEYYdQaH6NhzGoVo2d17MTRnsALCQI7rZyYj+cL4hCX4U7Aq6h9osh3wJuZw+AZ5dzxSI2f2n7FQLlUe7XBbI5Z4NBID9MExqvBPQrGpixMKxFO2rLw16vJ2fKGS+2ptFQQqCBQQWB2EPgfia/++s3cE5MAAAAASUVORK5CYII=" alt="JCI">
    585             <select class="branch-selector" id="branchSelect"></select>
    586         </div>
    587         <ul class="commit-list" id="commitList"></ul>
    588     </div>
    589     <div class="files-panel hidden" id="filesPanel">
    590         <div class="commit-info" id="commitInfo"></div>
    591         <ul class="file-list" id="fileList"></ul>
    592     </div>
    593     <div class="content-panel">
    594         <div class="content-header" id="contentHeader"></div>
    595         <div class="content-body" id="contentBody">
    596             <div class="empty-state">Select a commit</div>
    597         </div>
    598     </div>
    599 
    600     <script>
    601         let branches = [], currentCommit = null, currentFiles = [], currentFile = null, currentRuns = [], currentRunId = null;
    602         let currentBranch = null, currentPage = 0, loadedCommits = [], hasMoreCommits = false;
    603 
    604         async function loadBranches() {
    605             const res = await fetch('/api/branches');
    606             branches = await res.json() || [];
    607             const select = document.getElementById('branchSelect');
    608             select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join('');
    609             const def = branches.find(b => b.name === 'main') || branches[0];
    610             if (def) { select.value = def.name; await showBranch(def.name); }
    611 
    612             // Check URL for initial commit and file
    613             const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/);
    614             if (m) selectCommitByHash(m[1]);
    615         }
    616 
    617         function getStatusLabel(status) {
    618             switch(status) {
    619                 case 'success': return '✓ ok';
    620                 case 'failed': return '✗ err';
    621                 case 'running': return '⋯ run';
    622                 default: return '—';
    623             }
    624         }
    625 
    626         function renderCommitItem(c) {
    627             const status = c.hasCI ? (c.ciStatus || 'none') : 'none';
    628             const noCiClass = c.hasCI ? '' : 'no-ci';
    629             let pushBadge = '';
    630             if (c.hasCI) {
    631                 pushBadge = c.ciPushed
    632                     ? '<span class="push-badge pushed">pushed</span>'
    633                     : '<span class="push-badge local">local</span>';
    634             }
    635             return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' +
    636                 '<span class="status-badge ' + status + '">' + getStatusLabel(status) + '</span>' +
    637                 '<span class="commit-hash">' + c.shortHash + '</span>' +
    638                 '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' +
    639                 pushBadge + '</li>';
    640         }
    641 
    642         function attachCommitClickHandlers() {
    643             document.querySelectorAll('.commit-item').forEach(el => {
    644                 el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true');
    645             });
    646         }
    647 
    648         async function showBranch(name) {
    649             currentBranch = name;
    650             currentPage = 0;
    651             loadedCommits = [];
    652             hasMoreCommits = false;
    653             const list = document.getElementById('commitList');
    654             list.innerHTML = '<li style="padding:8px 10px;color:#656d76;">Loading…</li>';
    655             await loadMoreCommits();
    656         }
    657 
    658         async function loadMoreCommits() {
    659             const res = await fetch('/api/commits?branch=' + encodeURIComponent(currentBranch) + '&page=' + currentPage);
    660             const data = await res.json();
    661             loadedCommits = loadedCommits.concat(data.commits || []);
    662             hasMoreCommits = data.hasMore || false;
    663 
    664             const list = document.getElementById('commitList');
    665             // Remove existing load-more button if present
    666             const oldBtn = document.getElementById('loadMoreBtn');
    667             if (oldBtn) oldBtn.remove();
    668 
    669             if (currentPage === 0) {
    670                 list.innerHTML = loadedCommits.map(renderCommitItem).join('');
    671             } else {
    672                 // Append newly loaded commits (remove loading placeholder first)
    673                 const placeholder = document.getElementById('loadMorePlaceholder');
    674                 if (placeholder) placeholder.remove();
    675                 const frag = document.createDocumentFragment();
    676                 const tmp = document.createElement('ul');
    677                 tmp.innerHTML = (data.commits || []).map(renderCommitItem).join('');
    678                 while (tmp.firstChild) frag.appendChild(tmp.firstChild);
    679                 list.appendChild(frag);
    680             }
    681 
    682             attachCommitClickHandlers();
    683 
    684             if (hasMoreCommits) {
    685                 const btn = document.createElement('li');
    686                 btn.id = 'loadMoreBtn';
    687                 btn.style.cssText = 'padding:8px 10px;text-align:center;cursor:pointer;color:#0969da;border-top:1px solid #eaeef2;';
    688                 btn.textContent = 'Load more commits…';
    689                 btn.onclick = async () => {
    690                     btn.textContent = 'Loading…';
    691                     btn.onclick = null;
    692                     currentPage++;
    693                     await loadMoreCommits();
    694                 };
    695                 list.appendChild(btn);
    696             }
    697 
    698             // Re-highlight selected commit if any
    699             if (currentCommit) {
    700                 document.querySelectorAll('.commit-item').forEach(el =>
    701                     el.classList.toggle('selected', el.dataset.hash === currentCommit)
    702                 );
    703             }
    704         }
    705 
    706         function selectCommitByHash(hash) {
    707             // Search already-loaded commits
    708             const c = loadedCommits.find(c => c.hash.startsWith(hash));
    709             if (c) { selectCommit(c.hash, c.hasCI); return; }
    710         }
    711 
    712         async function selectCommit(hash, hasCI) {
    713             currentCommit = hash;
    714             currentFile = null;
    715             document.querySelectorAll('.commit-item').forEach(el => 
    716                 el.classList.toggle('selected', el.dataset.hash === hash)
    717             );
    718             
    719             const filesPanel = document.getElementById('filesPanel');
    720             const contentBody = document.getElementById('contentBody');
    721             const contentHeader = document.getElementById('contentHeader');
    722             
    723             if (!hasCI) {
    724                 filesPanel.classList.add('hidden');
    725                 contentHeader.innerHTML = '';
    726                 contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>';
    727                 history.pushState(null, '', '/');
    728                 return;
    729             }
    730             
    731             filesPanel.classList.remove('hidden');
    732             // Only update URL to commit if not already on a file URL for this commit
    733             if (!location.pathname.startsWith('/jci/' + hash)) {
    734                 history.pushState(null, '', '/jci/' + hash);
    735             }
    736             
    737             // Load commit info and files
    738             try {
    739                 const infoRes = await fetch('/api/commit/' + hash);
    740                 const info = await infoRes.json();
    741                 
    742                 let statusIcon = '?';
    743                 let statusClass = '';
    744                 if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; }
    745                 else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
    746                 else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
    747                 
    748                 // Store runs info
    749                 currentRuns = info.runs || [];
    750                 currentRunId = info.runId || null;
    751                 
    752                 // Build run selector if multiple runs
    753                 let runSelectorHtml = '';
    754                 if (currentRuns.length > 1) {
    755                     const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
    756                     runSelectorHtml = '<div class="run-selector">' +
    757                         '<select id="runSelect">' +
    758                         currentRuns.map((r, i) => {
    759                             const ts = parseInt(r.runId.split('-')[0]) * 1000;
    760                             const date = new Date(ts).toLocaleString();
    761                             const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
    762                             const selected = r.runId === currentRunId ? ' selected' : '';
    763                             return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
    764                         }).join('') +
    765                         '</select></div>' +
    766                         '<div class="run-nav">' +
    767                         '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
    768                         '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
    769                         '</div>';
    770                 } else if (currentRuns.length === 1) {
    771                     const ts = parseInt(currentRuns[0].runId.split('-')[0]) * 1000;
    772                     const date = new Date(ts).toLocaleString();
    773                     runSelectorHtml = '<div class="meta">Run: ' + date + '</div>';
    774                 }
    775                 
    776                 document.getElementById('commitInfo').innerHTML = 
    777                     '<div class="status-line">' +
    778                     '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
    779                     '<span class="hash">' + hash.slice(0,7) + '</span></div>' +
    780                     '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
    781                     runSelectorHtml;
    782                 
    783                 // Set up run selector events
    784                 const runSelect = document.getElementById('runSelect');
    785                 if (runSelect) {
    786                     runSelect.onchange = (e) => selectRun(hash, e.target.value);
    787                 }
    788                 const prevBtn = document.getElementById('prevRun');
    789                 const nextBtn = document.getElementById('nextRun');
    790                 if (prevBtn) {
    791                     prevBtn.onclick = () => {
    792                         const idx = currentRuns.findIndex(r => r.runId === currentRunId);
    793                         if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
    794                     };
    795                 }
    796                 if (nextBtn) {
    797                     nextBtn.onclick = () => {
    798                         const idx = currentRuns.findIndex(r => r.runId === currentRunId);
    799                         if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
    800                     };
    801                 }
    802                 
    803                 currentFiles = info.files || [];
    804                 const fileList = document.getElementById('fileList');
    805                 fileList.innerHTML = currentFiles.map(f => 
    806                     '<li class="file-item" data-file="' + f + '">' + f + '</li>'
    807                 ).join('');
    808                 fileList.querySelectorAll('.file-item').forEach(el => {
    809                     el.onclick = () => loadFile(el.dataset.file);
    810                 });
    811                 
    812                 // Check URL for initial file, otherwise load default
    813                 const urlMatch = location.pathname.match(/^\/jci\/[a-f0-9]+\/(.+)$/);
    814                 if (urlMatch && urlMatch[1]) {
    815                     loadFile(urlMatch[1], true);
    816                 } else {
    817                     const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
    818                     if (defaultFile) loadFile(defaultFile, true);
    819                 }
    820             } catch (e) {
    821                 contentBody.innerHTML = '<div class="empty-state">Failed to load</div>';
    822             }
    823         }
    824 
    825         function loadFile(name, skipHistory) {
    826             currentFile = name;
    827             document.querySelectorAll('.file-item').forEach(el => 
    828                 el.classList.toggle('selected', el.dataset.file === name)
    829             );
    830             
    831             const contentHeader = document.getElementById('contentHeader');
    832             const contentBody = document.getElementById('contentBody');
    833             // Include runId in URL if available
    834             const commitPath = currentRunId ? currentCommit + '/' + currentRunId : currentCommit;
    835             const rawUrl = '/jci/' + commitPath + '/' + name + '/raw';
    836             
    837             contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' +
    838                 '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>';
    839             
    840             if (!skipHistory) {
    841                 history.pushState(null, '', '/jci/' + commitPath + '/' + name);
    842             }
    843             
    844             const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
    845             const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf'];
    846             const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'];
    847             
    848             if (ext === 'html' || ext === 'htm') {
    849                 contentBody.innerHTML = '<iframe src="' + rawUrl + '"></iframe>';
    850             } else if (imageExts.includes(ext)) {
    851                 contentBody.innerHTML = '<div style="padding: 12px; text-align: center;"><img src="' + rawUrl + '" style="max-width: 100%; max-height: 100%;"></div>';
    852             } else if (textExts.includes(ext)) {
    853                 fetch(rawUrl).then(r => r.text()).then(text => {
    854                     contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
    855                 });
    856             } else {
    857                 contentBody.innerHTML = '<div class="empty-state">Binary file. <a href="' + rawUrl + '" target="_blank">Download ' + escapeHtml(name) + '</a></div>';
    858             }
    859         }
    860 
    861         async function selectRun(hash, runId) {
    862             currentRunId = runId;
    863             currentFile = null;
    864             // Fetch the specific run
    865             const infoRes = await fetch('/api/commit/' + hash + '/' + runId);
    866             const info = await infoRes.json();
    867             
    868             let statusIcon = '?';
    869             let statusClass = '';
    870             if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; }
    871             else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
    872             else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
    873             
    874             // Update runs from response
    875             currentRuns = info.runs || [];
    876             
    877             // Build run selector
    878             let runSelectorHtml = '';
    879             if (currentRuns.length > 1) {
    880                 const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
    881                 runSelectorHtml = '<div class="run-selector">' +
    882                     '<select id="runSelect">' +
    883                     currentRuns.map((r, i) => {
    884                         const ts = parseInt(r.runId.split('-')[0]) * 1000;
    885                         const date = new Date(ts).toLocaleString();
    886                         const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
    887                         const selected = r.runId === currentRunId ? ' selected' : '';
    888                         return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
    889                     }).join('') +
    890                     '</select></div>' +
    891                     '<div class="run-nav">' +
    892                     '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
    893                     '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
    894                     '</div>';
    895             }
    896             
    897             document.getElementById('commitInfo').innerHTML = 
    898                 '<div class="status-line">' +
    899                 '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
    900                 '<span class="hash">' + hash.slice(0,7) + '</span></div>' +
    901                 '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
    902                 runSelectorHtml;
    903             
    904             // Re-attach event listeners
    905             const runSelect = document.getElementById('runSelect');
    906             if (runSelect) {
    907                 runSelect.onchange = (e) => selectRun(hash, e.target.value);
    908             }
    909             const prevBtn = document.getElementById('prevRun');
    910             const nextBtn = document.getElementById('nextRun');
    911             if (prevBtn) {
    912                 prevBtn.onclick = () => {
    913                     const idx = currentRuns.findIndex(r => r.runId === currentRunId);
    914                     if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
    915                 };
    916             }
    917             if (nextBtn) {
    918                 nextBtn.onclick = () => {
    919                     const idx = currentRuns.findIndex(r => r.runId === currentRunId);
    920                     if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
    921                 };
    922             }
    923             
    924             // Update files
    925             currentFiles = info.files || [];
    926             const fileList = document.getElementById('fileList');
    927             fileList.innerHTML = currentFiles.map(f => 
    928                 '<li class="file-item" data-file="' + f + '">' + f + '</li>'
    929             ).join('');
    930             fileList.querySelectorAll('.file-item').forEach(el => {
    931                 el.onclick = () => loadFile(el.dataset.file);
    932             });
    933             
    934             // Update URL
    935             history.pushState(null, '', '/jci/' + hash + '/' + runId);
    936             
    937             // Load default file
    938             const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
    939             if (defaultFile) loadFile(defaultFile, true);
    940         }
    941 
    942         function escapeHtml(t) {
    943             if (!t) return '';
    944             return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    945         }
    946 
    947         window.onpopstate = () => {
    948             const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/);
    949             if (m) {
    950                 const commit = m[1];
    951                 const file = m[2] || null;
    952                 if (commit !== currentCommit) {
    953                     selectCommitByHash(commit);
    954                 } else if (file && file !== currentFile) {
    955                     loadFile(file, true);
    956                 } else if (!file && currentFile) {
    957                     // Went back to commit-only URL
    958                     currentFile = null;
    959                     const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
    960                     if (defaultFile) loadFile(defaultFile, true);
    961                 }
    962             } else if (location.pathname === '/') {
    963                 currentCommit = null;
    964                 currentFile = null;
    965                 document.querySelectorAll('.commit-item').forEach(el => el.classList.remove('selected'));
    966                 document.getElementById('filesPanel').classList.add('hidden');
    967                 document.getElementById('contentHeader').innerHTML = '';
    968                 document.getElementById('contentBody').innerHTML = '<div class="empty-state">Select a commit</div>';
    969             }
    970         };
    971 
    972         document.getElementById('branchSelect').onchange = e => showBranch(e.target.value);
    973         loadBranches();
    974 
    975     </script>
    976 </body>
    977 </html>
    978 `)
    979 }
    980 
    981 func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) {
    982 	var ref string
    983 
    984 	if runID != "" {
    985 		ref = "refs/jci-runs/" + commit + "/" + runID
    986 	} else {
    987 		// No runID: find the latest run for this commit
    988 		runRefs, _ := ListJCIRunRefs()
    989 		for _, r := range runRefs {
    990 			if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
    991 				ref = r // last one wins (sorted by timestamp)
    992 			}
    993 		}
    994 	}
    995 
    996 	if !RefExists(ref) {
    997 		http.Error(w, "CI results not found for commit: "+commit, 404)
    998 		return
    999 	}
   1000 
   1001 	// Use git show to get file content from the ref
   1002 	cmd := exec.Command("git", "show", ref+":"+filePath)
   1003 	out, err := cmd.Output()
   1004 	if err != nil {
   1005 		http.Error(w, "File not found: "+filePath, 404)
   1006 		return
   1007 	}
   1008 
   1009 	// Set content type based on extension
   1010 	ext := filepath.Ext(filePath)
   1011 	switch ext {
   1012 	case ".html":
   1013 		w.Header().Set("Content-Type", "text/html")
   1014 	case ".css":
   1015 		w.Header().Set("Content-Type", "text/css")
   1016 	case ".js":
   1017 		w.Header().Set("Content-Type", "application/javascript")
   1018 	case ".json":
   1019 		w.Header().Set("Content-Type", "application/json")
   1020 	case ".txt":
   1021 		w.Header().Set("Content-Type", "text/plain")
   1022 	default:
   1023 		// Binary files (executables, etc.)
   1024 		w.Header().Set("Content-Type", "application/octet-stream")
   1025 		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(filePath)))
   1026 	}
   1027 
   1028 	w.Write(out)
   1029 }