Merge branch 'feature/miniapp-git-tab'

This commit is contained in:
dj-oyu 2026-02-22 16:11:50 +09:00
commit 4666df7402
4 changed files with 70 additions and 46 deletions

View file

@ -312,7 +312,7 @@ type agentLoopDataProvider struct {
loop *agent.AgentLoop
workspace string
gitCache miniapp.GitInfo
gitCache []miniapp.GitInfo
gitCacheAt time.Time
}
@ -375,29 +375,46 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
return result
}
func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo {
func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo {
if time.Since(p.gitCacheAt) < gitCacheTTL {
return p.gitCache
}
info := miniapp.GitInfo{}
if p.workspace == "" {
return info
return nil
}
// 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
// Find workspace's own git root to exclude it
workspaceGitRoot := ""
if out, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output(); err == nil {
workspaceGitRoot = strings.TrimSpace(string(out))
}
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
// Scan for .git dirs up to 2 levels deep under workspace
seen := map[string]bool{}
var repos []miniapp.GitInfo
for _, pattern := range []string{
filepath.Join(p.workspace, "*", ".git"),
filepath.Join(p.workspace, "*", "*", ".git"),
} {
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
repoDir := filepath.Dir(m)
if repoDir == workspaceGitRoot || seen[repoDir] {
continue
}
seen[repoDir] = true
repos = append(repos, collectGitRepoInfo(repoDir))
}
}
p.gitCache = repos
p.gitCacheAt = time.Now()
return repos
}
func collectGitRepoInfo(gitRoot string) miniapp.GitInfo {
info := miniapp.GitInfo{Name: filepath.Base(gitRoot)}
// Current branch
out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
@ -418,7 +435,7 @@ func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo {
}
}
// Modified/untracked files (git status --porcelain)
// Modified/untracked files
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") {
@ -432,8 +449,6 @@ func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo {
}
}
p.gitCache = info
p.gitCacheAt = time.Now()
return info
}

View file

@ -60,6 +60,7 @@ type SessionInfo struct {
// GitInfo represents the git repository state exposed via the API.
type GitInfo struct {
Name string `json:"name"`
Branch string `json:"branch"`
Commits []GitCommit `json:"commits"`
Modified []GitChange `json:"modified"`
@ -85,7 +86,7 @@ type DataProvider interface {
GetPlanInfo() PlanInfo
GetSessionStats() *stats.Stats
GetActiveSessions() []SessionInfo
GetGitInfo() GitInfo
GetGitInfo() []GitInfo
}
// CommandSender injects a command into the message bus on behalf of a user.

View file

@ -171,8 +171,8 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats {
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{}
}
func (m *mockDataProvider) GetGitInfo() GitInfo {
return GitInfo{}
func (m *mockDataProvider) GetGitInfo() []GitInfo {
return nil
}
type mockSender struct{}
@ -448,8 +448,8 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{}
}
func (m *mutatingDataProvider) GetGitInfo() GitInfo {
return GitInfo{}
func (m *mutatingDataProvider) GetGitInfo() []GitInfo {
return nil
}
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.

View file

@ -1123,17 +1123,35 @@ function loadGit() {
renderGitFromData);
}
function renderGitFromData(data) {
function renderGitFromData(repos) {
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 (!repos || repos.length === 0) {
el.innerHTML = '<div class="empty-state">No git repositories found.</div>';
return;
}
if (data.commits && data.commits.length > 0) {
data.commits.forEach(function(c) {
var html = '';
repos.forEach(function(repo) {
html += '<div class="card glass"><div class="card-title">' +
escapeHtml(repo.name) + ' &mdash; ' + escapeHtml(repo.branch || '?') + '</div>';
if (repo.modified && repo.modified.length > 0) {
html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Changes (' + repo.modified.length + ')</div>';
repo.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>';
});
}
if (repo.commits && repo.commits.length > 0) {
html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Commits</div>';
repo.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>' +
@ -1144,17 +1162,7 @@ function renderGitFromData(data) {
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;
}