feat: workspace git detection with branch/status badge

When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.

Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
  commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
  untracked, ahead, behind

Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes

Inspired by PR #75 (@MartinNielsenDev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-04 14:08:25 -07:00
parent e2d24f57ac
commit d8e6079a2c
5 changed files with 79 additions and 0 deletions

View File

@@ -214,6 +214,18 @@ def handle_get(handler, parsed):
if parsed.path == '/api/list': if parsed.path == '/api/list':
return _handle_list_dir(handler, parsed) return _handle_list_dir(handler, parsed)
if parsed.path == '/api/git-info':
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
if not sid:
return bad(handler, 'session_id required')
s = get_session(sid)
if not s:
return bad(handler, 'Session not found', 404)
from api.workspace import git_info_for_workspace
info = git_info_for_workspace(Path(s.workspace))
return j(handler, {'git': info})
if parsed.path == '/api/chat/stream/status': if parsed.path == '/api/chat/stream/status':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0] stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id}) return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})

View File

@@ -9,6 +9,7 @@ paths are used as fallback when no profile module is available.
""" """
import json import json
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from api.config import ( from api.config import (
@@ -243,3 +244,43 @@ def read_file_content(workspace: Path, rel: str):
raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})") raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
content = target.read_text(encoding='utf-8', errors='replace') content = target.read_text(encoding='utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1} return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
# ── Git detection ──────────────────────────────────────────────────────────
def _run_git(args, cwd, timeout=3):
"""Run a git command and return stdout, or None on failure."""
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
)
return r.stdout.strip() if r.returncode == 0 else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
def git_info_for_workspace(workspace: Path) -> dict:
"""Return git info for a workspace directory, or None if not a git repo."""
if not (workspace / '.git').exists():
return None
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace)
if branch is None:
return None
# Status counts
status_out = _run_git(['status', '--porcelain'], workspace) or ''
modified = sum(1 for l in status_out.splitlines() if l and l[0:2].strip() in ('M', 'MM', 'AM'))
untracked = sum(1 for l in status_out.splitlines() if l.startswith('??'))
dirty = len(status_out.splitlines()) if status_out else 0
# Ahead/behind
ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
return {
'branch': branch,
'dirty': dirty,
'modified': modified,
'untracked': untracked,
'ahead': int(ahead) if ahead and ahead.isdigit() else 0,
'behind': int(behind) if behind and behind.isdigit() else 0,
'is_git': True,
}

View File

@@ -278,6 +278,7 @@
<div class="resize-handle" id="rightpanelResize"></div> <div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header"> <div class="panel-header">
<span>Workspace</span> <span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions"> <div class="panel-actions">
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">&#8593;</button> <button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">&#8593;</button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">&#43;</button> <button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">&#43;</button>

View File

@@ -204,6 +204,8 @@
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;} .upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;} .rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;} .panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
.panel-actions{display:flex;gap:4px;} .panel-actions{display:flex;gap:4px;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;} .panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);} .panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}

View File

@@ -59,9 +59,32 @@ async function loadDir(path){
clearPreview(); clearPreview();
} }
} }
// Fetch git info for workspace root (non-blocking)
if(!path||path==='.') _refreshGitBadge();
}catch(e){console.warn('loadDir',e);} }catch(e){console.warn('loadDir',e);}
} }
async function _refreshGitBadge(){
const badge=$('gitBadge');
if(!badge||!S.session)return;
try{
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
if(data.git&&data.git.is_git){
const g=data.git;
let text=g.branch||'git';
if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta
if(g.behind>0) text+=` \u2193${g.behind}`;
if(g.ahead>0) text+=` \u2191${g.ahead}`;
badge.textContent=text;
badge.className='git-badge'+(g.dirty>0?' dirty':'');
badge.style.display='';
} else {
badge.style.display='none';
badge.textContent='';
}
}catch(e){badge.style.display='none';}
}
function navigateUp(){ function navigateUp(){
if(!S.session||S.currentDir==='.')return; if(!S.session||S.currentDir==='.')return;
const parts=S.currentDir.split('/'); const parts=S.currentDir.split('/');