Jaypore CI

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

commit 55bd5f13bdda283858b851b65aa2084072990dee
parent 0625cbea069d463fe8fe9eb30b5abee71d5437f4
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 05:53:59 +0000

Redesign CI result pages with artifact sidebar

Main page:
- Simple commit list, no iframe
- Click commit hash to navigate to CI results
- URL changes properly for navigation

CI result page:
- Compact header: back link, pass/fail badge, commit, message, author, date
- Collapsible file tree sidebar on left
- Click artifact to view inline (text files) or in iframe (HTML)
- Binary files show download link
- URL updates when selecting files (enables relative paths in artifacts)
- Responsive: sidebar narrows on mobile

Co-authored-by: Shelley <shelley@exe.dev>

Diffstat:
Minternal/jci/run.go | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Minternal/jci/web.go | 114++++++++++++++------------------------------------------------------------------
2 files changed, 199 insertions(+), 180 deletions(-)

diff --git a/internal/jci/run.go b/internal/jci/run.go @@ -118,22 +118,10 @@ func runCI(repoRoot string, outputDir string, commit string) error { // generateIndexHTML creates an index.html with CI results func generateIndexHTML(outputDir string, commit string, ciErr error) error { - repoRoot, err := GetRepoRoot() - if err != nil { - return err - } - // Get commit info commitMsg, _ := git("log", "-1", "--format=%s", commit) - commitAuthor, _ := git("log", "-1", "--format=%an <%ae>", commit) - commitDate, _ := git("log", "-1", "--format=%ci", commit) - - // Read output file if it exists - outputContent := "" - outputFile := filepath.Join(outputDir, "run.output.txt") - if data, err := os.ReadFile(outputFile); err == nil { - outputContent = string(data) - } + commitAuthor, _ := git("log", "-1", "--format=%an", commit) + commitDate, _ := git("log", "-1", "--format=%cr", commit) // relative date // List files in output directory var files []string @@ -145,108 +133,215 @@ func generateIndexHTML(outputDir string, commit string, ciErr error) error { } status := "success" - statusText := "✓ Passed" + statusIcon := "✓" if ciErr != nil { status = "failed" - statusText = "✗ Failed" + statusIcon = "✗" } - // Build files list HTML - filesHTML := "" - if len(files) > 0 { - filesHTML = "<h2>Artifacts</h2>\n<ul class=\"files\">\n" - for _, f := range files { - filesHTML += fmt.Sprintf("<li><a href=\"%s\">%s</a></li>\n", f, f) + // Build files list JSON for JS + filesJSON := "[" + for i, f := range files { + if i > 0 { + filesJSON += "," } - filesHTML += "</ul>\n" + filesJSON += fmt.Sprintf("%q", f) } + filesJSON += "]" html := fmt.Sprintf(`<!DOCTYPE html> <html> <head> <meta charset="utf-8"> - <title>CI Results - %s</title> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>%s %s</title> <style> + * { box-sizing: border-box; margin: 0; padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - max-width: 900px; - margin: 40px auto; - padding: 0 20px; - background: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + font-size: 12px; + background: #1a1a1a; + color: #e0e0e0; + display: flex; + flex-direction: column; + height: 100vh; } - .container { - background: white; - border-radius: 8px; - padding: 24px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + a { color: #58a6ff; text-decoration: none; } + a:hover { text-decoration: underline; } + .header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: #252525; + border-bottom: 1px solid #333; + flex-shrink: 0; } - h1 { color: #333; margin-top: 0; } - h2 { color: #555; margin-top: 24px; } - .commit-info { - background: #f8f9fa; - border-radius: 4px; - padding: 16px; - margin: 16px 0; - font-family: monospace; - font-size: 14px; + .header a.back { color: #888; margin-right: 4px; } + .badge { + padding: 2px 6px; + border-radius: 3px; + font-weight: 600; + font-size: 11px; } - .commit-info p { margin: 4px 0; } - .label { color: #666; font-weight: bold; } - .status { - display: inline-block; - padding: 4px 12px; - border-radius: 4px; - font-weight: bold; + .badge.success { background: #1a4a2a; color: #3fb950; } + .badge.failed { background: #4a1a1a; color: #f85149; } + .commit-hash { color: #58a6ff; font-family: monospace; } + .commit-msg { color: #aaa; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .commit-meta { color: #666; font-size: 11px; } + .container { display: flex; flex: 1; overflow: hidden; } + .sidebar { + width: 200px; + background: #1e1e1e; + border-right: 1px solid #333; + display: flex; + flex-direction: column; + flex-shrink: 0; + } + .sidebar-header { + padding: 4px 8px; + background: #252525; + border-bottom: 1px solid #333; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + } + .sidebar-header:hover { background: #2a2a2a; } + .sidebar-header .toggle { color: #666; } + .file-list { + list-style: none; + overflow-y: auto; + flex: 1; } - .status.success { background: #d4edda; color: #155724; } - .status.failed { background: #f8d7da; color: #721c24; } - .timestamp { color: #666; font-size: 0.9em; } - .output { + .file-list li { + padding: 3px 8px; + cursor: pointer; + border-bottom: 1px solid #252525; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .file-list li:hover { background: #2a2a2a; } + .file-list li.selected { background: #2d4a3e; } + .main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + } + .content { + flex: 1; + overflow: auto; background: #1e1e1e; - color: #d4d4d4; - padding: 16px; - border-radius: 4px; - overflow-x: auto; - font-family: "Monaco", "Menlo", monospace; - font-size: 13px; + } + .content pre { + padding: 8px; + font-family: "Monaco", "Menlo", "Consolas", monospace; + font-size: 12px; + line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; - max-height: 500px; - overflow-y: auto; } - .files { list-style: none; padding: 0; } - .files li { - padding: 8px 12px; - border-bottom: 1px solid #eee; + .content iframe { + width: 100%%; + height: 100%%; + border: none; + background: #fff; + } + .sidebar.collapsed { width: 0; border: none; overflow: hidden; } + @media (max-width: 600px) { + .sidebar { width: 150px; } + .commit-meta { display: none; } } - .files li:last-child { border-bottom: none; } - .files a { color: #0366d6; text-decoration: none; } - .files a:hover { text-decoration: underline; } </style> </head> <body> + <div class="header"> + <a href="/" class="back">← JCI</a> + <span class="badge %s">%s</span> + <span class="commit-hash">%s</span> + <span class="commit-msg">%s</span> + <span class="commit-meta">%s · %s</span> + </div> <div class="container"> - <h1>CI Results</h1> - <p><span class="status %s">%s</span></p> - - <div class="commit-info"> - <p><span class="label">Commit:</span> %s</p> - <p><span class="label">Message:</span> %s</p> - <p><span class="label">Author:</span> %s</p> - <p><span class="label">Date:</span> %s</p> + <div class="sidebar" id="sidebar"> + <div class="sidebar-header" onclick="toggleSidebar()"> + <span>Artifacts</span> + <span class="toggle">◀</span> + </div> + <ul class="file-list" id="fileList"></ul> + </div> + <div class="main"> + <div class="content" id="content"><pre id="textContent"></pre></div> </div> - - <p><span class="label">Repository:</span> %s</p> - <p class="timestamp">CI run at: %s</p> + </div> + <script> + const files = %s; + const fileList = document.getElementById('fileList'); + const content = document.getElementById('content'); + const textContent = document.getElementById('textContent'); + let currentFile = null; - %s + function renderFiles() { + fileList.innerHTML = files.map(f => + '<li data-file="' + f + '">' + f + '</li>' + ).join(''); + fileList.querySelectorAll('li').forEach(li => { + li.onclick = () => loadFile(li.dataset.file); + }); + } - <h2>Output</h2> - <pre class="output">%s</pre> - </div> + function loadFile(name) { + currentFile = name; + document.querySelectorAll('.file-list li').forEach(li => + li.classList.toggle('selected', li.dataset.file === name) + ); + // Update URL without reload + history.pushState(null, '', name); + + const ext = name.split('.').pop().toLowerCase(); + const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf', 'cfg']; + + if (ext === 'html' || ext === 'htm') { + content.innerHTML = '<iframe src="' + name + '"></iframe>'; + } else if (textExts.includes(ext) || !name.includes('.')) { + fetch(name).then(r => r.text()).then(text => { + content.innerHTML = '<pre>' + escapeHtml(text) + '</pre>'; + }); + } else { + // Binary - offer download + content.innerHTML = '<div style="padding:20px;text-align:center;"><a href="' + name + '" download>Download ' + name + '</a></div>'; + } + } + + function escapeHtml(t) { + return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); + } + + function toggleSidebar() { + document.getElementById('sidebar').classList.toggle('collapsed'); + } + + // Handle browser back/forward + window.onpopstate = () => { + const path = location.pathname.split('/').pop(); + if (path && path !== '' && files.includes(path)) { + loadFile(path); + } + }; + + // Init + renderFiles(); + // Load run.output.txt by default if it exists + const defaultFile = files.find(f => f === 'run.output.txt') || files[0]; + if (defaultFile) loadFile(defaultFile); + </script> </body> </html> -`, commit[:12], status, statusText, commit, escapeHTML(commitMsg), escapeHTML(commitAuthor), commitDate, repoRoot, time.Now().Format(time.RFC3339), filesHTML, escapeHTML(outputContent)) +`, commit[:7], escapeHTML(commitMsg), status, statusIcon, commit[:7], escapeHTML(commitMsg), escapeHTML(commitAuthor), commitDate, filesJSON) indexPath := filepath.Join(outputDir, "index.html") return os.WriteFile(indexPath, []byte(html), 0644) diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -225,10 +225,9 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { font-size: 12px; background: #1a1a1a; color: #e0e0e0; - display: flex; - flex-direction: column; - height: 100vh; } + a { color: #58a6ff; text-decoration: none; } + a:hover { text-decoration: underline; } .header { display: flex; align-items: center; @@ -236,8 +235,12 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { padding: 4px 8px; background: #252525; border-bottom: 1px solid #333; + position: sticky; + top: 0; + z-index: 100; } - .header h1 { font-size: 12px; font-weight: 600; color: #888; } + .header h1 { font-size: 12px; font-weight: 600; } + .header h1 a { color: #888; } .branch-selector { padding: 2px 6px; font-size: 12px; @@ -247,30 +250,15 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { color: #fff; cursor: pointer; } - .branch-selector:focus { outline: none; border-color: #58a6ff; } - .container { - display: flex; - flex: 1; - overflow: hidden; - } - .sidebar { - width: 280px; - background: #1e1e1e; - border-right: 1px solid #333; - overflow-y: auto; - flex-shrink: 0; - } - .commit-list { list-style: none; } + .commit-list { list-style: none; max-width: 800px; margin: 0 auto; } .commit-item { - padding: 4px 6px; - cursor: pointer; + padding: 4px 8px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #2a2a2a; } - .commit-item:hover { background: #2a2a2a; } - .commit-item.selected { background: #2d4a3e; } + .commit-item:hover { background: #252525; } .ci-dot { width: 8px; height: 8px; @@ -280,20 +268,8 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { .ci-dot.success { background: #3fb950; } .ci-dot.failed { background: #f85149; } .ci-dot.none { background: #484f58; } - .commit-hash { - font-family: monospace; - font-size: 11px; - color: #58a6ff; - width: 56px; - flex-shrink: 0; - } - .commit-msg { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: #aaa; - } + .commit-hash { font-size: 11px; color: #58a6ff; width: 56px; flex-shrink: 0; } + .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; } .ci-badge { font-size: 9px; padding: 1px 4px; @@ -303,49 +279,16 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { } .ci-badge.local { background: #6e4a1a; color: #f0a040; } .ci-badge.pushed { background: #1a4a2a; color: #40c060; } - .main-content { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - } - .main-content iframe { - flex: 1; - border: none; - background: #fff; - } - .no-selection { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: #666; - font-size: 13px; - } - @media (max-width: 600px) { - .container { flex-direction: column; } - .sidebar { width: 100%; height: 40%; border-right: none; border-bottom: 1px solid #333; } - .main-content { height: 60%; } - } </style> </head> <body> <div class="header"> - <h1>JCI</h1> + <h1><a href="/">JCI</a></h1> <select class="branch-selector" id="branchSelect"></select> </div> - <div class="container"> - <div class="sidebar"> - <ul class="commit-list" id="commitList"></ul> - </div> - <div class="main-content"> - <div class="no-selection" id="noSelection">Select commit</div> - <iframe id="ciFrame" style="display: none;"></iframe> - </div> - </div> + <ul class="commit-list" id="commitList"></ul> <script> - let branches = [], selectedHash = null; - + let branches = []; async function loadBranches() { const res = await fetch('/api/branches'); branches = await res.json() || []; @@ -354,7 +297,6 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { const def = branches.find(b => b.name === 'main') || branches[0]; if (def) { select.value = def.name; showBranch(def.name); } } - function showBranch(name) { const branch = branches.find(b => b.name === name); if (!branch) return; @@ -362,33 +304,15 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { list.innerHTML = (branch.commits || []).map(c => { const status = c.hasCI ? c.ciStatus : 'none'; const badge = c.hasCI ? (c.ciPushed ? '<span class="ci-badge pushed">↑</span>' : '<span class="ci-badge local">●</span>') : ''; - return '<li class="commit-item" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + + const link = c.hasCI ? '/jci/' + c.hash + '/' : '#'; + const onclick = c.hasCI ? '' : 'onclick="alert(\'No CI - run: git jci run\'); return false;"'; + return '<li class="commit-item">' + '<span class="ci-dot ' + status + '"></span>' + - '<span class="commit-hash">' + c.shortHash + '</span>' + + '<a href="' + link + '" ' + onclick + ' class="commit-hash">' + c.shortHash + '</a>' + '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + badge + '</li>'; }).join(''); - list.querySelectorAll('.commit-item').forEach(el => { - el.onclick = () => showCommit(el.dataset.hash, el.dataset.hasci === 'true'); - }); } - - function showCommit(hash, hasCI) { - selectedHash = hash; - document.querySelectorAll('.commit-item').forEach(el => el.classList.toggle('selected', el.dataset.hash === hash)); - const frame = document.getElementById('ciFrame'); - const noSel = document.getElementById('noSelection'); - if (hasCI) { - frame.src = '/jci/' + hash + '/'; - frame.style.display = 'block'; - noSel.style.display = 'none'; - } else { - frame.style.display = 'none'; - noSel.style.display = 'flex'; - noSel.textContent = 'No CI - run: git jci run'; - } - } - function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } document.getElementById('branchSelect').onchange = e => showBranch(e.target.value); loadBranches();