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:
| M | internal/jci/run.go | | | 265 | +++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- |
| M | internal/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,'&').replace(/</g,'<').replace(/>/g,'>');
+ }
+
+ 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();