Jaypore CI

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

web.go (43339B)


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