commit 0625cbea069d463fe8fe9eb30b5abee71d5437f4
parent 76e0c02842400dabc88448d160e46940732e65a2
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date: Wed, 25 Feb 2026 05:40:42 +0000
Compact UI with CI push status
- Show CI push status (↑ pushed, ● local) instead of git commit push status
- Responsive layout: sidebar collapses on narrow screens
- Reduced padding throughout for information density
- Shorter commit hashes (7 chars)
- Removed redundant branch status bar
- Minimal header with just title and branch selector
- Dark theme with better contrast
Co-authored-by: Shelley <shelley@exe.dev>
Diffstat:
| A | .jci/run.sh | | | 35 | +++++++++++++++++++++++++++++++++++ |
| M | internal/jci/web.go | | | 362 | ++++++++++++++++++++++++++++--------------------------------------------------- |
2 files changed, 163 insertions(+), 234 deletions(-)
diff --git a/.jci/run.sh b/.jci/run.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# CI script for git-jci
+# This runs in .jci/<commit>/ directory
+# Environment variables available:
+# JCI_COMMIT - Full commit hash
+# JCI_REPO_ROOT - Repository root path
+# JCI_OUTPUT_DIR - Output directory (where artifacts should go)
+
+set -e
+
+echo "=== JCI CI Pipeline ==="
+echo "Commit: ${JCI_COMMIT:0:12}"
+echo ""
+
+cd "$JCI_REPO_ROOT"
+
+echo "Running tests..."
+go test ./...
+echo ""
+
+echo "Building static binary (CGO_ENABLED=0)..."
+CGO_ENABLED=0 go build -ldflags='-s -w -extldflags "-static"' -o "$JCI_OUTPUT_DIR/git-jci" ./cmd/git-jci
+
+# Verify it's static
+echo ""
+echo "Binary info:"
+file "$JCI_OUTPUT_DIR/git-jci"
+ls -lh "$JCI_OUTPUT_DIR/git-jci"
+
+echo ""
+echo "All steps completed successfully!"
+echo ""
+echo "=== Installation ==="
+echo "Download and install with:"
+echo " curl -fsSL \$(git jci web --url)/git-jci -o /tmp/git-jci && sudo install /tmp/git-jci /usr/local/bin/"
diff --git a/internal/jci/web.go b/internal/jci/web.go
@@ -19,12 +19,12 @@ type BranchInfo struct {
// CommitInfo holds commit data for the UI
type CommitInfo struct {
- Hash string `json:"hash"`
- ShortHash string `json:"shortHash"`
- Message string `json:"message"`
- IsPushed bool `json:"isPushed"`
- HasCI bool `json:"hasCI"`
- CIStatus string `json:"ciStatus"` // "success", "failed", or ""
+ Hash string `json:"hash"`
+ ShortHash string `json:"shortHash"`
+ Message string `json:"message"`
+ HasCI bool `json:"hasCI"`
+ CIStatus string `json:"ciStatus"` // "success", "failed", or ""
+ CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote
}
// Web starts a web server to view CI results
@@ -91,25 +91,29 @@ func getLocalBranches() ([]string, error) {
return strings.Split(out, "\n"), nil
}
-// getRemoteCommits returns a set of commits that exist on remote
-func getRemoteCommits(remote string) (map[string]bool, error) {
- remoteCommits := make(map[string]bool)
+// getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote
+func getRemoteJCIRefs(remote string) map[string]bool {
+ remoteCI := make(map[string]bool)
- // Get all remote branch tips
- out, err := git("branch", "-r", "--format=%(objectname)")
+ // Get remote JCI refs
+ out, err := git("ls-remote", "--refs", remote, "refs/jci/*")
if err != nil {
- return remoteCommits, nil // Not an error if no remotes
+ return remoteCI
}
if out == "" {
- return remoteCommits, nil
+ return remoteCI
}
- for _, hash := range strings.Split(out, "\n") {
- if hash != "" {
- remoteCommits[hash] = true
+ for _, line := range strings.Split(out, "\n") {
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ // refs/jci/<commit> -> <commit>
+ ref := parts[1]
+ commit := strings.TrimPrefix(ref, "refs/jci/")
+ remoteCI[commit] = true
}
}
- return remoteCommits, nil
+ return remoteCI
}
// getBranchCommits returns recent commits for a branch
@@ -123,10 +127,7 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
return nil, nil
}
- // Get remote commits for push status
- remoteCommits, _ := getRemoteCommits("origin")
-
- // Get JCI refs for CI status
+ // Get local JCI refs
jciRefs, _ := ListJCIRefs()
jciSet := make(map[string]bool)
for _, ref := range jciRefs {
@@ -134,6 +135,9 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
jciSet[commit] = true
}
+ // Get remote JCI refs for CI push status
+ remoteCI := getRemoteJCIRefs("origin")
+
var commits []CommitInfo
for _, line := range strings.Split(out, "\n") {
parts := strings.SplitN(line, "|", 2)
@@ -145,10 +149,10 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
commit := CommitInfo{
Hash: hash,
- ShortHash: hash[:12],
+ ShortHash: hash[:7],
Message: msg,
- IsPushed: isCommitPushed(hash, remoteCommits),
HasCI: jciSet[hash],
+ CIPushed: remoteCI[hash],
}
if commit.HasCI {
@@ -161,22 +165,6 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
return commits, nil
}
-// isCommitPushed checks if a commit is reachable from any remote branch
-func isCommitPushed(hash string, remoteCommits map[string]bool) bool {
- // Quick check: is this exact commit a remote branch tip?
- if remoteCommits[hash] {
- return true
- }
-
- // Check if commit is ancestor of any remote branch
- // Use merge-base to check reachability
- out, err := git("branch", "-r", "--contains", hash)
- if err != nil {
- return false
- }
- return strings.TrimSpace(out) != ""
-}
-
// getCIStatus returns "success" or "failed" based on CI results
func getCIStatus(commit string) string {
// Try to read the index.html and look for status
@@ -228,254 +216,168 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
<html>
<head>
<meta charset="utf-8">
- <title>JCI - CI Dashboard</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>JCI</title>
<style>
- * { box-sizing: border-box; }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- margin: 0;
- padding: 0;
- background: #f5f5f5;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
+ font-size: 12px;
+ background: #1a1a1a;
+ color: #e0e0e0;
display: flex;
- min-height: 100vh;
- }
- .sidebar {
- width: 300px;
- background: #24292e;
- color: #fff;
- padding: 20px;
- overflow-y: auto;
+ flex-direction: column;
+ height: 100vh;
}
- .sidebar h1 {
- font-size: 20px;
- margin: 0 0 20px 0;
- padding-bottom: 10px;
- border-bottom: 1px solid #444;
+ .header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ background: #252525;
+ border-bottom: 1px solid #333;
}
+ .header h1 { font-size: 12px; font-weight: 600; color: #888; }
.branch-selector {
- width: 100%;
- padding: 8px 12px;
- font-size: 14px;
+ padding: 2px 6px;
+ font-size: 12px;
border: 1px solid #444;
- border-radius: 6px;
- background: #2d333b;
+ border-radius: 3px;
+ background: #2a2a2a;
color: #fff;
- margin-bottom: 16px;
cursor: pointer;
}
- .branch-selector:focus {
- outline: none;
- border-color: #58a6ff;
+ .branch-selector:focus { outline: none; border-color: #58a6ff; }
+ .container {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
}
- .commit-list {
- list-style: none;
- padding: 0;
- margin: 0;
+ .sidebar {
+ width: 280px;
+ background: #1e1e1e;
+ border-right: 1px solid #333;
+ overflow-y: auto;
+ flex-shrink: 0;
}
+ .commit-list { list-style: none; }
.commit-item {
- padding: 10px;
- border-radius: 6px;
- margin-bottom: 4px;
+ padding: 4px 6px;
cursor: pointer;
- transition: background 0.2s;
display: flex;
- align-items: flex-start;
- gap: 10px;
- }
- .commit-item:hover {
- background: #2d333b;
+ align-items: center;
+ gap: 6px;
+ border-bottom: 1px solid #2a2a2a;
}
- .commit-status {
- width: 12px;
- height: 12px;
+ .commit-item:hover { background: #2a2a2a; }
+ .commit-item.selected { background: #2d4a3e; }
+ .ci-dot {
+ width: 8px;
+ height: 8px;
border-radius: 50%;
flex-shrink: 0;
- margin-top: 4px;
- }
- .commit-status.success { background: #3fb950; }
- .commit-status.failed { background: #f85149; }
- .commit-status.none { background: #484f58; }
- .commit-info {
- flex: 1;
- min-width: 0;
}
+ .ci-dot.success { background: #3fb950; }
+ .ci-dot.failed { background: #f85149; }
+ .ci-dot.none { background: #484f58; }
.commit-hash {
font-family: monospace;
- font-size: 12px;
+ font-size: 11px;
color: #58a6ff;
+ width: 56px;
+ flex-shrink: 0;
}
.commit-msg {
- font-size: 13px;
- color: #c9d1d9;
+ flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ color: #aaa;
}
- .commit-badges {
- display: flex;
- gap: 6px;
- margin-top: 4px;
- }
- .badge {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 10px;
+ .ci-badge {
+ font-size: 9px;
+ padding: 1px 4px;
+ border-radius: 3px;
font-weight: 500;
+ flex-shrink: 0;
}
- .badge.local {
- background: #f0883e;
- color: #000;
- }
- .badge.pushed {
- background: #238636;
- color: #fff;
- }
- .badge.ci {
- background: #388bfd;
- color: #fff;
- }
+ .ci-badge.local { background: #6e4a1a; color: #f0a040; }
+ .ci-badge.pushed { background: #1a4a2a; color: #40c060; }
.main-content {
flex: 1;
- padding: 20px;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
}
.main-content iframe {
- width: 100%;
- height: calc(100vh - 40px);
+ flex: 1;
border: none;
- border-radius: 8px;
background: #fff;
}
.no-selection {
+ flex: 1;
display: flex;
align-items: center;
justify-content: center;
- height: calc(100vh - 40px);
- background: #fff;
- border-radius: 8px;
color: #666;
- font-size: 16px;
- }
- .branch-status {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: #2d333b;
- border-radius: 6px;
- margin-bottom: 12px;
font-size: 13px;
}
- .branch-ci-status {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- }
- .loading {
- color: #8b949e;
- font-style: italic;
- padding: 20px;
+ @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="sidebar">
- <h1>🔧 JCI Dashboard</h1>
- <select class="branch-selector" id="branchSelect">
- <option value="">Loading branches...</option>
- </select>
- <div class="branch-status" id="branchStatus" style="display: none;">
- <div class="branch-ci-status" id="branchCIStatus"></div>
- <span id="branchStatusText">Branch CI Status</span>
- </div>
- <ul class="commit-list" id="commitList">
- <li class="loading">Loading...</li>
- </ul>
+ <div class="header">
+ <h1>JCI</h1>
+ <select class="branch-selector" id="branchSelect"></select>
</div>
- <div class="main-content">
- <div class="no-selection" id="noSelection">
- Select a commit to view CI results
+ <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>
- <iframe id="ciFrame" style="display: none;"></iframe>
</div>
-
<script>
- let branches = [];
- let currentBranch = '';
+ let branches = [], selectedHash = null;
async function loadBranches() {
- try {
- const res = await fetch('/api/branches');
- branches = await res.json();
-
- const select = document.getElementById('branchSelect');
- select.innerHTML = branches.map(b =>
- '"<option value="' + b.name + '">' + b.name + '</option>'
- ).join('');
-
- // Select main or first branch
- const defaultBranch = branches.find(b => b.name === 'main') || branches[0];
- if (defaultBranch) {
- select.value = defaultBranch.name;
- showBranch(defaultBranch.name);
- }
- } catch (err) {
- console.error('Failed to load branches:', err);
- }
+ const res = await fetch('/api/branches');
+ branches = await res.json() || [];
+ const select = document.getElementById('branchSelect');
+ 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); }
}
- function showBranch(branchName) {
- currentBranch = branchName;
- const branch = branches.find(b => b.name === branchName);
+ function showBranch(name) {
+ const branch = branches.find(b => b.name === name);
if (!branch) return;
-
const list = document.getElementById('commitList');
- const branchStatus = document.getElementById('branchStatus');
-
- // Determine branch CI status from first commit with CI
- const firstCICommit = branch.commits.find(c => c.hasCI);
- if (firstCICommit) {
- branchStatus.style.display = 'flex';
- const statusEl = document.getElementById('branchCIStatus');
- const textEl = document.getElementById('branchStatusText');
- if (firstCICommit.ciStatus === 'success') {
- statusEl.style.background = '#3fb950';
- textEl.textContent = 'Latest CI: Passing';
- } else if (firstCICommit.ciStatus === 'failed') {
- statusEl.style.background = '#f85149';
- textEl.textContent = 'Latest CI: Failed';
- } else {
- statusEl.style.background = '#484f58';
- textEl.textContent = 'Latest CI: Unknown';
- }
- } else {
- branchStatus.style.display = 'none';
- }
-
- list.innerHTML = branch.commits.map(c => {
- let badges = '';
- if (c.isPushed) {
- badges += '<span class="badge pushed">pushed</span>';
- } else {
- badges += '<span class="badge local">local</span>';
- }
- if (c.hasCI) {
- badges += '<span class="badge ci">CI</span>';
- }
- const statusClass = c.hasCI ? c.ciStatus : 'none';
- return '<li class="commit-item" onclick="showCommit(\'' + c.hash + '\', ' + c.hasCI + ')">' +
- '<div class="commit-status ' + statusClass + '"></div>' +
- '<div class="commit-info">' +
- '<div class="commit-hash">' + c.shortHash + '</div>' +
- '<div class="commit-msg">' + escapeHtml(c.message) + '</div>' +
- '<div class="commit-badges">' + badges + '</div>' +
- '</div></li>';
+ 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 + '">' +
+ '<span class="ci-dot ' + status + '"></span>' +
+ '<span class="commit-hash">' + c.shortHash + '</span>' +
+ '<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';
@@ -483,20 +385,12 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
} else {
frame.style.display = 'none';
noSel.style.display = 'flex';
- noSel.textContent = 'No CI results for this commit. Run: git jci run';
+ noSel.textContent = 'No CI - run: git jci run';
}
}
- function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- document.getElementById('branchSelect').addEventListener('change', (e) => {
- showBranch(e.target.value);
- });
-
+ function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
+ document.getElementById('branchSelect').onchange = e => showBranch(e.target.value);
loadBranches();
</script>
</body>