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:
| M | internal/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>';
}
};