diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 7a61a6187..188aac507 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -312,8 +312,14 @@ type agentLoopDataProvider struct { loop *agent.AgentLoop workspace string - gitCache []miniapp.GitInfo - gitCacheAt time.Time + gitReposCache []miniapp.GitRepoSummary + gitReposCacheAt time.Time + gitDetailCache map[string]gitDetailEntry +} + +type gitDetailEntry struct { + info miniapp.GitInfo + at time.Time } const gitCacheTTL = 5 * time.Minute @@ -375,9 +381,9 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { return result } -func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo { - if time.Since(p.gitCacheAt) < gitCacheTTL { - return p.gitCache +func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary { + if time.Since(p.gitReposCacheAt) < gitCacheTTL { + return p.gitReposCache } if p.workspace == "" { @@ -392,7 +398,7 @@ func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo { // Scan for .git dirs up to 2 levels deep under workspace seen := map[string]bool{} - var repos []miniapp.GitInfo + var repos []miniapp.GitRepoSummary for _, pattern := range []string{ filepath.Join(p.workspace, "*", ".git"), filepath.Join(p.workspace, "*", "*", ".git"), @@ -404,15 +410,62 @@ func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo { continue } seen[repoDir] = true - repos = append(repos, collectGitRepoInfo(repoDir)) + name := filepath.Base(repoDir) + branch := "" + if out, err := exec.Command("git", "-C", repoDir, "rev-parse", "--abbrev-ref", "HEAD").Output(); err == nil { + branch = strings.TrimSpace(string(out)) + } + repos = append(repos, miniapp.GitRepoSummary{Name: name, Branch: branch}) } } - p.gitCache = repos - p.gitCacheAt = time.Now() + p.gitReposCache = repos + p.gitReposCacheAt = time.Now() return repos } +func (p *agentLoopDataProvider) GetGitRepoDetail(name string) miniapp.GitInfo { + // Path traversal prevention + if name == "" || filepath.Base(name) != name { + return miniapp.GitInfo{Name: name} + } + + // Check detail cache + if p.gitDetailCache != nil { + if entry, ok := p.gitDetailCache[name]; ok && time.Since(entry.at) < gitCacheTTL { + return entry.info + } + } + + if p.workspace == "" { + return miniapp.GitInfo{Name: name} + } + + // Resolve repo path: try 1-level and 2-level deep + var repoDir string + for _, pattern := range []string{ + filepath.Join(p.workspace, name, ".git"), + filepath.Join(p.workspace, "*", name, ".git"), + } { + matches, _ := filepath.Glob(pattern) + if len(matches) > 0 { + repoDir = filepath.Dir(matches[0]) + break + } + } + if repoDir == "" { + return miniapp.GitInfo{Name: name} + } + + info := collectGitRepoInfo(repoDir) + + if p.gitDetailCache == nil { + p.gitDetailCache = make(map[string]gitDetailEntry) + } + p.gitDetailCache[name] = gitDetailEntry{info: info, at: time.Now()} + return info +} + func collectGitRepoInfo(gitRoot string) miniapp.GitInfo { info := miniapp.GitInfo{Name: filepath.Base(gitRoot)} diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index bf5f6e86e..5c0a0d585 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -58,6 +58,12 @@ type SessionInfo struct { AgeSec int `json:"age_sec"` } +// GitRepoSummary represents a lightweight repo entry for the list view. +type GitRepoSummary struct { + Name string `json:"name"` + Branch string `json:"branch"` +} + // GitInfo represents the git repository state exposed via the API. type GitInfo struct { Name string `json:"name"` @@ -86,7 +92,8 @@ type DataProvider interface { GetPlanInfo() PlanInfo GetSessionStats() *stats.Stats GetActiveSessions() []SessionInfo - GetGitInfo() []GitInfo + GetGitRepos() []GitRepoSummary + GetGitRepoDetail(name string) GitInfo } // CommandSender injects a command into the message bus on behalf of a user. @@ -234,7 +241,12 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { } func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { - writeJSON(w, h.provider.GetGitInfo()) + repo := r.URL.Query().Get("repo") + if repo == "" { + writeJSON(w, h.provider.GetGitRepos()) + } else { + writeJSON(w, h.provider.GetGitRepoDetail(repo)) + } } func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index b2402420d..5674edd42 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -171,9 +171,12 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats { func (m *mockDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } -func (m *mockDataProvider) GetGitInfo() []GitInfo { +func (m *mockDataProvider) GetGitRepos() []GitRepoSummary { return nil } +func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo { + return GitInfo{Name: name} +} type mockSender struct{} @@ -448,9 +451,12 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil } func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } -func (m *mutatingDataProvider) GetGitInfo() []GitInfo { +func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary { return nil } +func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo { + return GitInfo{Name: name} +} // 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 { diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index e263d8967..5582d6ad7 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -594,6 +594,45 @@ .git-status-d { color: #ef5350; } .git-status-u { color: var(--hint); } + .git-repo-item { + padding: 14px 14px 14px 16px; + margin-bottom: 10px; + cursor: pointer; + transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s; + display: flex; + align-items: center; + gap: 12px; + -webkit-tap-highlight-color: transparent; + } + .git-repo-item:active { transform: scale(0.98); } + .git-repo-body { flex: 1; min-width: 0; } + .git-repo-name { font-weight: 600; font-size: 15px; margin-bottom: 4px; } + .git-repo-branch { + font-size: 13px; + color: var(--hint); + font-family: monospace; + } + .git-repo-arrow { + color: var(--hint); + font-size: 22px; + flex-shrink: 0; + } + .git-back-btn { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--btn); + font-size: 14px; + font-weight: 600; + background: none; + border: none; + cursor: pointer; + padding: 8px 0; + margin-bottom: 8px; + -webkit-tap-highlight-color: transparent; + } + .git-back-btn:active { opacity: 0.6; } +
@@ -602,10 +641,10 @@