Jaypore CI

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

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

Three-panel layout: commits | files | content

- Left panel: always-visible commit list with branch selector
- Middle panel: file tree with commit info header (author, date, status)
- Right panel: content viewer for selected file
- Single page app - no iframe, proper URL routing
- /api/commit/<hash> endpoint for commit details and file list
- Simplified standalone index.html for direct file access

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

Diffstat:
Minternal/jci/run.go | 222+++++++------------------------------------------------------------------------
Minternal/jci/web.go | 360++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 330 insertions(+), 252 deletions(-)

diff --git a/internal/jci/run.go b/internal/jci/run.go @@ -116,232 +116,48 @@ func runCI(repoRoot string, outputDir string, commit string) error { return runErr } -// generateIndexHTML creates an index.html with CI results +// generateIndexHTML creates a minimal index.html for standalone viewing +// The main UI is served by the web server; this is for direct file access func generateIndexHTML(outputDir string, commit string, ciErr error) error { - // Get commit info commitMsg, _ := git("log", "-1", "--format=%s", commit) - commitAuthor, _ := git("log", "-1", "--format=%an", commit) - commitDate, _ := git("log", "-1", "--format=%cr", commit) // relative date - - // List files in output directory - var files []string - entries, _ := os.ReadDir(outputDir) - for _, e := range entries { - if !e.IsDir() && e.Name() != "index.html" { - files = append(files, e.Name()) - } - } status := "success" - statusIcon := "✓" + statusIcon := "✓ PASSED" if ciErr != nil { status = "failed" - statusIcon = "✗" + statusIcon = "✗ FAILED" } - // Build files list JSON for JS - filesJSON := "[" - for i, f := range files { - if i > 0 { - filesJSON += "," - } - filesJSON += fmt.Sprintf("%q", f) + // Read output for standalone view + outputContent := "" + outputFile := filepath.Join(outputDir, "run.output.txt") + if data, err := os.ReadFile(outputFile); err == nil { + outputContent = string(data) } - filesJSON += "]" html := fmt.Sprintf(`<!DOCTYPE html> <html> <head> <meta charset="utf-8"> - <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, monospace; - 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; - gap: 8px; - padding: 4px 8px; - background: #252525; - border-bottom: 1px solid #333; - flex-shrink: 0; - } - .header a.back { color: #888; margin-right: 4px; } - .badge { - padding: 2px 6px; - border-radius: 3px; - font-weight: 600; - font-size: 11px; - } - .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; - } - .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; - } - .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; - } - .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; } - } + body { font-family: monospace; font-size: 12px; background: #1a1a1a; color: #e0e0e0; padding: 8px; } + .header { margin-bottom: 8px; } + .%s { color: %s; font-weight: bold; } + pre { white-space: pre-wrap; } </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"> - <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> + <span class="%s">%s</span> %s %s </div> - <script> - const files = %s; - const fileList = document.getElementById('fileList'); - const content = document.getElementById('content'); - const textContent = document.getElementById('textContent'); - let currentFile = null; - - 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); - }); - } - - 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> + <pre>%s</pre> </body> </html> -`, commit[:7], escapeHTML(commitMsg), status, statusIcon, commit[:7], escapeHTML(commitMsg), escapeHTML(commitAuthor), commitDate, filesJSON) +`, commit[:7], escapeHTML(commitMsg), + status, map[string]string{"success": "#3fb950", "failed": "#f85149"}[status], + status, statusIcon, commit[:7], escapeHTML(commitMsg), + escapeHTML(outputContent)) 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 @@ -52,8 +52,8 @@ func Web(args []string) error { func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { path := r.URL.Path - // Root: show branch/commit view - if path == "/" { + // Root or /jci/... without file: show main SPA + if path == "/" || (strings.HasPrefix(path, "/jci/") && !strings.Contains(strings.TrimPrefix(path, "/jci/"), ".")) { showMainPage(w, r) return } @@ -64,14 +64,25 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { return } - // /jci/<commit>/... - serve files from that commit's CI results + // API endpoint for commit info + if strings.HasPrefix(path, "/api/commit/") { + commit := strings.TrimPrefix(path, "/api/commit/") + serveCommitAPI(w, commit) + return + } + + // /jci/<commit>/<file> - serve files from that commit's CI results if strings.HasPrefix(path, "/jci/") { parts := strings.SplitN(strings.TrimPrefix(path, "/jci/"), "/", 2) commit := parts[0] - filePath := "index.html" - if len(parts) > 1 && parts[1] != "" { + filePath := "" + if len(parts) > 1 { filePath = parts[1] } + if filePath == "" { + showMainPage(w, r) + return + } serveFromRef(w, commit, filePath) return } @@ -185,6 +196,51 @@ func getCIStatus(commit string) string { return "" } +// CommitDetail holds detailed commit info for the API +type CommitDetail struct { + Hash string `json:"hash"` + Author string `json:"author"` + Date string `json:"date"` + Status string `json:"status"` + Files []string `json:"files"` +} + +// serveCommitAPI returns commit details and file list +func serveCommitAPI(w http.ResponseWriter, commit string) { + ref := "refs/jci/" + commit + if !RefExists(ref) { + http.Error(w, "not found", 404) + return + } + + // Get commit info + author, _ := git("log", "-1", "--format=%an", commit) + date, _ := git("log", "-1", "--format=%cr", commit) + status := getCIStatus(commit) + + // List files in the CI ref + filesOut, err := git("ls-tree", "--name-only", ref) + var files []string + if err == nil && filesOut != "" { + for _, f := range strings.Split(filesOut, "\n") { + if f != "" && f != "index.html" { + files = append(files, f) + } + } + } + + detail := CommitDetail{ + Hash: commit, + Author: author, + Date: date, + Status: status, + Files: files, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(detail) +} + // serveBranchesAPI returns branch/commit data as JSON func serveBranchesAPI(w http.ResponseWriter) { branches, err := getLocalBranches() @@ -225,70 +281,172 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { font-size: 12px; background: #1a1a1a; color: #e0e0e0; + display: flex; + height: 100vh; + overflow: hidden; } a { color: #58a6ff; text-decoration: none; } a:hover { text-decoration: underline; } - .header { + + /* Left panel - commits */ + .commits-panel { + width: 240px; + background: #1e1e1e; + border-right: 1px solid #333; display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; + flex-direction: column; + flex-shrink: 0; + } + .panel-header { + padding: 6px 8px; background: #252525; border-bottom: 1px solid #333; - position: sticky; - top: 0; - z-index: 100; + display: flex; + align-items: center; + gap: 6px; } - .header h1 { font-size: 12px; font-weight: 600; } - .header h1 a { color: #888; } + .panel-header h1 { font-size: 12px; font-weight: 600; color: #888; } .branch-selector { - padding: 2px 6px; - font-size: 12px; + flex: 1; + padding: 2px 4px; + font-size: 11px; border: 1px solid #444; border-radius: 3px; background: #2a2a2a; color: #fff; - cursor: pointer; } - .commit-list { list-style: none; max-width: 800px; margin: 0 auto; } + .commit-list { + list-style: none; + overflow-y: auto; + flex: 1; + } .commit-item { - padding: 4px 8px; + padding: 3px 6px; + cursor: pointer; display: flex; align-items: center; - gap: 6px; - border-bottom: 1px solid #2a2a2a; - } - .commit-item:hover { background: #252525; } - .ci-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; + gap: 4px; + border-bottom: 1px solid #252525; } + .commit-item:hover { background: #2a2a2a; } + .commit-item.selected { background: #2d4a3e; } + .commit-item.no-ci { opacity: 0.5; } + .ci-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .ci-dot.success { background: #3fb950; } .ci-dot.failed { background: #f85149; } .ci-dot.none { background: #484f58; } - .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; - border-radius: 3px; - font-weight: 500; + .commit-hash { font-size: 10px; color: #58a6ff; flex-shrink: 0; } + .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-size: 11px; } + .ci-push-badge { font-size: 8px; color: #666; } + .ci-push-badge.pushed { color: #3fb950; } + + /* Middle panel - files */ + .files-panel { + width: 180px; + background: #1e1e1e; + border-right: 1px solid #333; + display: flex; + flex-direction: column; flex-shrink: 0; } - .ci-badge.local { background: #6e4a1a; color: #f0a040; } - .ci-badge.pushed { background: #1a4a2a; color: #40c060; } + .files-panel.hidden { display: none; } + .commit-info { + padding: 6px 8px; + background: #252525; + border-bottom: 1px solid #333; + font-size: 11px; + } + .commit-info .status { font-weight: 600; } + .commit-info .status.success { color: #3fb950; } + .commit-info .status.failed { color: #f85149; } + .commit-info .hash { color: #58a6ff; } + .commit-info .meta { color: #666; margin-top: 2px; } + .file-list { + list-style: none; + overflow-y: auto; + flex: 1; + } + .file-item { + padding: 3px 8px; + cursor: pointer; + border-bottom: 1px solid #252525; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + } + .file-item:hover { background: #2a2a2a; } + .file-item.selected { background: #2d4a3e; } + + /* Right panel - content */ + .content-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: #1a1a1a; + } + .content-header { + padding: 4px 8px; + background: #252525; + border-bottom: 1px solid #333; + font-size: 11px; + color: #888; + } + .content-body { + flex: 1; + overflow: auto; + } + .content-body pre { + padding: 8px; + font-family: "Monaco", "Menlo", monospace; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + } + .content-body iframe { + width: 100%; + height: 100%; + border: none; + background: #fff; + } + .empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + } + + @media (max-width: 700px) { + .commits-panel { width: 180px; } + .files-panel { width: 140px; } + } </style> </head> <body> - <div class="header"> - <h1><a href="/">JCI</a></h1> - <select class="branch-selector" id="branchSelect"></select> + <div class="commits-panel"> + <div class="panel-header"> + <h1>JCI</h1> + <select class="branch-selector" id="branchSelect"></select> + </div> + <ul class="commit-list" id="commitList"></ul> + </div> + <div class="files-panel hidden" id="filesPanel"> + <div class="commit-info" id="commitInfo"></div> + <ul class="file-list" id="fileList"></ul> + </div> + <div class="content-panel"> + <div class="content-header" id="contentHeader"></div> + <div class="content-body" id="contentBody"> + <div class="empty-state">Select a commit</div> + </div> </div> - <ul class="commit-list" id="commitList"></ul> + <script> - let branches = []; + let branches = [], currentCommit = null, currentFiles = [], currentFile = null; + async function loadBranches() { const res = await fetch('/api/branches'); branches = await res.json() || []; @@ -296,24 +454,128 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join(''); const def = branches.find(b => b.name === 'main') || branches[0]; if (def) { select.value = def.name; showBranch(def.name); } + + // Check URL for initial commit + const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/); + if (m) selectCommitByHash(m[1]); } + function showBranch(name) { const branch = branches.find(b => b.name === name); if (!branch) return; const list = document.getElementById('commitList'); 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>') : ''; - 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">' + + const pushIcon = c.hasCI ? (c.ciPushed ? '↑' : '○') : ''; + const pushClass = c.ciPushed ? 'pushed' : ''; + const noCiClass = c.hasCI ? '' : 'no-ci'; + return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + '<span class="ci-dot ' + status + '"></span>' + - '<a href="' + link + '" ' + onclick + ' class="commit-hash">' + c.shortHash + '</a>' + + '<span class="commit-hash">' + c.shortHash + '</span>' + '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + - badge + '</li>'; + '<span class="ci-push-badge ' + pushClass + '">' + pushIcon + '</span></li>'; }).join(''); + list.querySelectorAll('.commit-item').forEach(el => { + el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true'); + }); + } + + function selectCommitByHash(hash) { + // Find full hash from branches + for (const b of branches) { + const c = (b.commits || []).find(c => c.hash.startsWith(hash)); + if (c) { selectCommit(c.hash, c.hasCI); return; } + } + } + + async function selectCommit(hash, hasCI) { + currentCommit = hash; + document.querySelectorAll('.commit-item').forEach(el => + el.classList.toggle('selected', el.dataset.hash === hash) + ); + + const filesPanel = document.getElementById('filesPanel'); + const contentBody = document.getElementById('contentBody'); + const contentHeader = document.getElementById('contentHeader'); + + if (!hasCI) { + filesPanel.classList.add('hidden'); + contentHeader.textContent = ''; + contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>'; + history.pushState(null, '', '/'); + return; + } + + filesPanel.classList.remove('hidden'); + history.pushState(null, '', '/jci/' + hash + '/'); + + // Load commit info and files + try { + const infoRes = await fetch('/api/commit/' + hash); + const info = await infoRes.json(); + + document.getElementById('commitInfo').innerHTML = + '<div><span class="status ' + info.status + '">' + (info.status === 'success' ? '✓' : '✗') + '</span> ' + + '<span class="hash">' + hash.slice(0,7) + '</span></div>' + + '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>'; + + currentFiles = info.files || []; + const fileList = document.getElementById('fileList'); + fileList.innerHTML = currentFiles.map(f => + '<li class="file-item" data-file="' + f + '">' + f + '</li>' + ).join(''); + fileList.querySelectorAll('.file-item').forEach(el => { + el.onclick = () => loadFile(el.dataset.file); + }); + + // Load default file + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile); + } catch (e) { + contentBody.innerHTML = '<div class="empty-state">Failed to load</div>'; + } + } + + function loadFile(name) { + currentFile = name; + document.querySelectorAll('.file-item').forEach(el => + el.classList.toggle('selected', el.dataset.file === name) + ); + + const contentHeader = document.getElementById('contentHeader'); + const contentBody = document.getElementById('contentBody'); + contentHeader.textContent = name; + + history.pushState(null, '', '/jci/' + currentCommit + '/' + name); + + const ext = name.split('.').pop().toLowerCase(); + const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf']; + const url = '/jci/' + currentCommit + '/' + name; + + if (ext === 'html' || ext === 'htm') { + contentBody.innerHTML = '<iframe src="' + url + '"></iframe>'; + } else if (textExts.includes(ext) || !name.includes('.')) { + fetch(url).then(r => r.text()).then(text => { + contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>'; + }); + } else { + contentBody.innerHTML = '<div class="empty-state"><a href="' + url + '" download>Download ' + name + '</a></div>'; + } } - function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } + + function escapeHtml(t) { + if (!t) return ''; + return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); + } + + window.onpopstate = () => { + const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/); + if (m) { + if (m[1] !== currentCommit) selectCommitByHash(m[1]); + else if (m[2] && m[2] !== currentFile) loadFile(m[2]); + } + }; + document.getElementById('branchSelect').onchange = e => showBranch(e.target.value); loadBranches(); </script>