Jaypore CI

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

commit 87072a6aa1e230cf0443521845fdab924cc60cc4
parent e342ba7781c898f7d0137105e5fb643b9641e9c8
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 06:30:36 +0000

Improve URL scheme and add download button

URL changes:
- /jci/<commit> - shows UI with commit selected
- /jci/<commit>/<artifact> - shows UI with artifact selected
- /jci/<commit>/<artifact>/raw - serves raw artifact content

UI improvements:
- Added download button to content header bar (right side)
- Binary files show 'Binary file. Download <name>' instead of garbage
- Only known text/HTML/image files are displayed inline
- URLs are stable on page reload

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

Diffstat:
Minternal/jci/web.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
1 file changed, 77 insertions(+), 34 deletions(-)

diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -52,12 +52,6 @@ func Web(args []string) error { func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { path := r.URL.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 - } - // API endpoint for branch data if path == "/api/branches" { serveBranchesAPI(w) @@ -71,19 +65,20 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { 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 := "" - if len(parts) > 1 { - filePath = parts[1] - } - if filePath == "" { - showMainPage(w, r) + // /jci/<commit>/<file>/raw - serve raw file + if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") { + trimmed := strings.TrimPrefix(path, "/jci/") + trimmed = strings.TrimSuffix(trimmed, "/raw") + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) == 2 && parts[1] != "" { + serveFromRef(w, parts[0], parts[1]) return } - serveFromRef(w, commit, filePath) + } + + // Root or /jci/... - show main SPA (UI handles routing) + if path == "/" || strings.HasPrefix(path, "/jci/") { + showMainPage(w, r) return } @@ -435,7 +430,22 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { font-size: 12px; color: #57606a; font-family: monospace; + display: flex; + align-items: center; + justify-content: space-between; } + .content-header .filename { flex: 1; } + .download-btn { + padding: 3px 8px; + font-size: 11px; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 4px; + color: #24292f; + cursor: pointer; + text-decoration: none; + } + .download-btn:hover { background: #f6f8fa; text-decoration: none; } .content-body { flex: 1; overflow: auto; @@ -501,7 +511,7 @@ 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); } - // Check URL for initial commit + // Check URL for initial commit and file const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/); if (m) selectCommitByHash(m[1]); } @@ -551,6 +561,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { async function selectCommit(hash, hasCI) { currentCommit = hash; + currentFile = null; document.querySelectorAll('.commit-item').forEach(el => el.classList.toggle('selected', el.dataset.hash === hash) ); @@ -561,14 +572,17 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { if (!hasCI) { filesPanel.classList.add('hidden'); - contentHeader.textContent = ''; + contentHeader.innerHTML = ''; 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 + '/'); + // Only update URL to commit if not already on a file URL for this commit + if (!location.pathname.startsWith('/jci/' + hash)) { + history.pushState(null, '', '/jci/' + hash); + } // Load commit info and files try { @@ -596,15 +610,20 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { el.onclick = () => loadFile(el.dataset.file); }); - // Load default file - const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; - if (defaultFile) loadFile(defaultFile); + // Check URL for initial file, otherwise load default + const urlMatch = location.pathname.match(/^\/jci\/[a-f0-9]+\/(.+)$/); + if (urlMatch && urlMatch[1]) { + loadFile(urlMatch[1], true); + } else { + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile, true); + } } catch (e) { contentBody.innerHTML = '<div class="empty-state">Failed to load</div>'; } } - function loadFile(name) { + function loadFile(name, skipHistory) { currentFile = name; document.querySelectorAll('.file-item').forEach(el => el.classList.toggle('selected', el.dataset.file === name) @@ -612,22 +631,29 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { const contentHeader = document.getElementById('contentHeader'); const contentBody = document.getElementById('contentBody'); - contentHeader.textContent = name; + const rawUrl = '/jci/' + currentCommit + '/' + name + '/raw'; - history.pushState(null, '', '/jci/' + currentCommit + '/' + name); + contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' + + '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>'; - const ext = name.split('.').pop().toLowerCase(); + if (!skipHistory) { + history.pushState(null, '', '/jci/' + currentCommit + '/' + name); + } + + const ext = name.includes('.') ? 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; + const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico']; 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 = '<iframe src="' + rawUrl + '"></iframe>'; + } else if (imageExts.includes(ext)) { + contentBody.innerHTML = '<div style="padding: 12px; text-align: center;"><img src="' + rawUrl + '" style="max-width: 100%; max-height: 100%;"></div>'; + } else if (textExts.includes(ext)) { + fetch(rawUrl).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>'; + contentBody.innerHTML = '<div class="empty-state">Binary file. <a href="' + rawUrl + '" target="_blank">Download ' + escapeHtml(name) + '</a></div>'; } } @@ -639,8 +665,25 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { 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]); + const commit = m[1]; + const file = m[2] || null; + if (commit !== currentCommit) { + selectCommitByHash(commit); + } else if (file && file !== currentFile) { + loadFile(file, true); + } else if (!file && currentFile) { + // Went back to commit-only URL + currentFile = null; + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile, true); + } + } else if (location.pathname === '/') { + currentCommit = null; + currentFile = null; + document.querySelectorAll('.commit-item').forEach(el => el.classList.remove('selected')); + document.getElementById('filesPanel').classList.add('hidden'); + document.getElementById('contentHeader').innerHTML = ''; + document.getElementById('contentBody').innerHTML = '<div class="empty-state">Select a commit</div>'; } };