feat: add Git tab to MiniApp for commit history and uncommitted changes
Show branch name, recent 20 commits, and working tree changes (modified/untracked files) in a new Git tab. Data is fetched on-demand via HTTP API with 30s TTL cache to keep load low on SBC hardware. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
552c819d55
commit
ce2ab80dc7
4 changed files with 185 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -231,7 +232,7 @@ func gatewayCmd() {
|
|||
}
|
||||
|
||||
if webAppURL != "" {
|
||||
provider := &agentLoopDataProvider{loop: agentLoop}
|
||||
provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
|
||||
sender := &telegramCommandSender{bus: msgBus}
|
||||
miniappNotifier = miniapp.NewStateNotifier()
|
||||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
|
||||
|
|
@ -309,8 +310,14 @@ func setupCronTool(
|
|||
// agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface.
|
||||
type agentLoopDataProvider struct {
|
||||
loop *agent.AgentLoop
|
||||
workspace string
|
||||
|
||||
gitCache miniapp.GitInfo
|
||||
gitCacheAt time.Time
|
||||
}
|
||||
|
||||
const gitCacheTTL = 30 * time.Second
|
||||
|
||||
func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
|
||||
return p.loop.ListSkills()
|
||||
}
|
||||
|
|
@ -368,6 +375,68 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
|||
return result
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo {
|
||||
if time.Since(p.gitCacheAt) < gitCacheTTL {
|
||||
return p.gitCache
|
||||
}
|
||||
|
||||
info := miniapp.GitInfo{}
|
||||
if p.workspace == "" {
|
||||
return info
|
||||
}
|
||||
|
||||
// Detect git root from workspace
|
||||
topOut, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return info // not a git repo
|
||||
}
|
||||
gitRoot := strings.TrimSpace(string(topOut))
|
||||
|
||||
// Exclude ~/.picoclaw/workspace* (picoclaw's own managed workspace)
|
||||
home, _ := os.UserHomeDir()
|
||||
picoDir := filepath.Join(home, ".picoclaw")
|
||||
if strings.HasPrefix(gitRoot, picoDir) {
|
||||
return info
|
||||
}
|
||||
|
||||
// Current branch
|
||||
out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err == nil {
|
||||
info.Branch = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Recent commits (20 entries)
|
||||
out, err = exec.Command("git", "-C", gitRoot, "log", "--pretty=format:%h\x1f%s\x1f%an\x1f%cr", "-20").Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
parts := strings.SplitN(line, "\x1f", 4)
|
||||
if len(parts) == 4 {
|
||||
info.Commits = append(info.Commits, miniapp.GitCommit{
|
||||
Hash: parts[0], Subject: parts[1], Author: parts[2], Date: parts[3],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modified/untracked files (git status --porcelain)
|
||||
out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
info.Modified = append(info.Modified, miniapp.GitChange{
|
||||
Status: strings.TrimSpace(line[:2]),
|
||||
Path: line[3:],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
p.gitCache = info
|
||||
p.gitCacheAt = time.Now()
|
||||
return info
|
||||
}
|
||||
|
||||
// telegramCommandSender injects Mini App commands into the message bus.
|
||||
type telegramCommandSender struct {
|
||||
bus *bus.MessageBus
|
||||
|
|
|
|||
|
|
@ -58,12 +58,34 @@ type SessionInfo struct {
|
|||
AgeSec int `json:"age_sec"`
|
||||
}
|
||||
|
||||
// GitInfo represents the git repository state exposed via the API.
|
||||
type GitInfo struct {
|
||||
Branch string `json:"branch"`
|
||||
Commits []GitCommit `json:"commits"`
|
||||
Modified []GitChange `json:"modified"`
|
||||
}
|
||||
|
||||
// GitCommit represents a single commit entry.
|
||||
type GitCommit struct {
|
||||
Hash string `json:"hash"`
|
||||
Subject string `json:"subject"`
|
||||
Author string `json:"author"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// GitChange represents a modified/untracked file entry.
|
||||
type GitChange struct {
|
||||
Status string `json:"status"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DataProvider is the read-only interface to agent state for the Mini App API.
|
||||
type DataProvider interface {
|
||||
ListSkills() []skills.SkillInfo
|
||||
GetPlanInfo() PlanInfo
|
||||
GetSessionStats() *stats.Stats
|
||||
GetActiveSessions() []SessionInfo
|
||||
GetGitInfo() GitInfo
|
||||
}
|
||||
|
||||
// CommandSender injects a command into the message bus on behalf of a user.
|
||||
|
|
@ -154,6 +176,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +232,10 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, s)
|
||||
}
|
||||
|
||||
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, h.provider.GetGitInfo())
|
||||
}
|
||||
|
||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
|||
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
||||
return []SessionInfo{}
|
||||
}
|
||||
func (m *mockDataProvider) GetGitInfo() GitInfo {
|
||||
return GitInfo{}
|
||||
}
|
||||
|
||||
type mockSender struct{}
|
||||
|
||||
|
|
@ -445,6 +448,9 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
|
|||
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
||||
return []SessionInfo{}
|
||||
}
|
||||
func (m *mutatingDataProvider) GetGitInfo() GitInfo {
|
||||
return GitInfo{}
|
||||
}
|
||||
|
||||
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
||||
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: calc(25% - 2px);
|
||||
width: calc(20% - 2px);
|
||||
border-radius: 8px;
|
||||
background: var(--tab-pill-bg);
|
||||
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
|
||||
|
|
@ -564,6 +564,36 @@
|
|||
animation: none;
|
||||
}
|
||||
|
||||
/* Git log */
|
||||
.git-commit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.git-commit:last-child { border-bottom: none; }
|
||||
.git-hash {
|
||||
font-family: monospace;
|
||||
color: var(--btn);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.git-subject { flex: 1; color: var(--text); }
|
||||
.git-meta { color: var(--hint); font-size: 11px; flex-shrink: 0; text-align: right; }
|
||||
.git-status {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.git-status-m { color: #e2b93d; }
|
||||
.git-status-a { color: #4caf50; }
|
||||
.git-status-d { color: #ef5350; }
|
||||
.git-status-u { color: var(--hint); }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -575,6 +605,7 @@
|
|||
<button class="tab" data-panel="skills">Skills</button>
|
||||
<button class="tab" data-panel="session">Session</button>
|
||||
<button class="tab" data-panel="config">Config</button>
|
||||
<button class="tab" data-panel="git">Git</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -611,6 +642,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="git" class="panel">
|
||||
<div class="loading" id="git-loading">Loading git log...</div>
|
||||
<div id="git-content" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="send-bar hidden" id="send-bar">
|
||||
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
|
||||
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
|
||||
|
|
@ -649,6 +685,7 @@ tabs.forEach((tab, index) => {
|
|||
if (p === 'plan' && !fresh) loadPlan();
|
||||
if (p === 'skills' && !fresh) loadSkills();
|
||||
if (p === 'session' && !fresh) loadSession();
|
||||
if (p === 'git') loadGit();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1079,6 +1116,49 @@ function escapeAttr(s) {
|
|||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Git tab ──
|
||||
function loadGit() {
|
||||
return loadTab('git-loading', 'git-content', 'git log',
|
||||
function() { return apiFetch('/miniapp/api/git'); },
|
||||
renderGitFromData);
|
||||
}
|
||||
|
||||
function renderGitFromData(data) {
|
||||
var loading = document.getElementById('git-loading');
|
||||
var el = document.getElementById('git-content');
|
||||
loading.classList.add('hidden');
|
||||
el.classList.remove('hidden');
|
||||
|
||||
var html = '<div class="card glass"><div class="card-title">Branch: ' +
|
||||
escapeHtml(data.branch || 'unknown') + '</div>';
|
||||
|
||||
if (data.commits && data.commits.length > 0) {
|
||||
data.commits.forEach(function(c) {
|
||||
html += '<div class="git-commit">' +
|
||||
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
|
||||
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
|
||||
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
} else {
|
||||
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (data.modified && data.modified.length > 0) {
|
||||
html += '<div class="card glass" style="margin-top:12px"><div class="card-title">Changes (' + data.modified.length + ')</div>';
|
||||
data.modified.forEach(function(f) {
|
||||
html += '<div class="git-commit">' +
|
||||
'<span class="git-status git-status-' + (f.status === '??' ? 'u' : f.status.toLowerCase()) + '">' + escapeHtml(f.status) + '</span>' +
|
||||
'<span class="git-subject">' + escapeHtml(f.path) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── SSE real-time updates ──
|
||||
var eventSource = null;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue