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