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:
@@ -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})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">↑</button>
|
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">↑</button>
|
||||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||||
|
|||||||
@@ -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);}
|
||||||
|
|||||||
@@ -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('/');
|
||||||
|
|||||||
Reference in New Issue
Block a user