feat: add drill-down navigation to MiniApp Git tab

Split Git tab into repo list and detail views to avoid running
git log/status on all repositories upfront. The lightweight list
only fetches branch names; detail (commits + changes) loads on tap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 16:20:05 +09:00
parent e46c17e0a6
commit c78d399592
4 changed files with 186 additions and 44 deletions

View file

@ -312,8 +312,14 @@ type agentLoopDataProvider struct {
loop *agent.AgentLoop loop *agent.AgentLoop
workspace string workspace string
gitCache []miniapp.GitInfo gitReposCache []miniapp.GitRepoSummary
gitCacheAt time.Time gitReposCacheAt time.Time
gitDetailCache map[string]gitDetailEntry
}
type gitDetailEntry struct {
info miniapp.GitInfo
at time.Time
} }
const gitCacheTTL = 5 * time.Minute const gitCacheTTL = 5 * time.Minute
@ -375,9 +381,9 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
return result return result
} }
func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo { func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary {
if time.Since(p.gitCacheAt) < gitCacheTTL { if time.Since(p.gitReposCacheAt) < gitCacheTTL {
return p.gitCache return p.gitReposCache
} }
if p.workspace == "" { if p.workspace == "" {
@ -392,7 +398,7 @@ func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo {
// Scan for .git dirs up to 2 levels deep under workspace // Scan for .git dirs up to 2 levels deep under workspace
seen := map[string]bool{} seen := map[string]bool{}
var repos []miniapp.GitInfo var repos []miniapp.GitRepoSummary
for _, pattern := range []string{ for _, pattern := range []string{
filepath.Join(p.workspace, "*", ".git"), filepath.Join(p.workspace, "*", ".git"),
filepath.Join(p.workspace, "*", "*", ".git"), filepath.Join(p.workspace, "*", "*", ".git"),
@ -404,15 +410,62 @@ func (p *agentLoopDataProvider) GetGitInfo() []miniapp.GitInfo {
continue continue
} }
seen[repoDir] = true 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.gitReposCache = repos
p.gitCacheAt = time.Now() p.gitReposCacheAt = time.Now()
return repos 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 { func collectGitRepoInfo(gitRoot string) miniapp.GitInfo {
info := miniapp.GitInfo{Name: filepath.Base(gitRoot)} info := miniapp.GitInfo{Name: filepath.Base(gitRoot)}

View file

@ -58,6 +58,12 @@ type SessionInfo struct {
AgeSec int `json:"age_sec"` 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. // GitInfo represents the git repository state exposed via the API.
type GitInfo struct { type GitInfo struct {
Name string `json:"name"` Name string `json:"name"`
@ -86,7 +92,8 @@ type DataProvider interface {
GetPlanInfo() PlanInfo GetPlanInfo() PlanInfo
GetSessionStats() *stats.Stats GetSessionStats() *stats.Stats
GetActiveSessions() []SessionInfo GetActiveSessions() []SessionInfo
GetGitInfo() []GitInfo GetGitRepos() []GitRepoSummary
GetGitRepoDetail(name string) GitInfo
} }
// CommandSender injects a command into the message bus on behalf of a user. // 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) { 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) { func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {

View file

@ -171,9 +171,12 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats {
func (m *mockDataProvider) GetActiveSessions() []SessionInfo { func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{} return []SessionInfo{}
} }
func (m *mockDataProvider) GetGitInfo() []GitInfo { func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
return nil return nil
} }
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
return GitInfo{Name: name}
}
type mockSender struct{} type mockSender struct{}
@ -448,9 +451,12 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
return []SessionInfo{} return []SessionInfo{}
} }
func (m *mutatingDataProvider) GetGitInfo() []GitInfo { func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
return nil 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. // 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 { func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {

View file

@ -594,6 +594,45 @@
.git-status-d { color: #ef5350; } .git-status-d { color: #ef5350; }
.git-status-u { color: var(--hint); } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -602,10 +641,10 @@
<div class="tabs-inner"> <div class="tabs-inner">
<div class="tab-indicator"></div> <div class="tab-indicator"></div>
<button class="tab active" data-panel="plan">Plan</button> <button class="tab active" data-panel="plan">Plan</button>
<button class="tab" data-panel="git">Git</button>
<button class="tab" data-panel="skills">Skills</button> <button class="tab" data-panel="skills">Skills</button>
<button class="tab" data-panel="session">Session</button> <button class="tab" data-panel="session">Session</button>
<button class="tab" data-panel="config">Config</button> <button class="tab" data-panel="config">Config</button>
<button class="tab" data-panel="git">Git</button>
</div> </div>
</div> </div>
@ -1117,13 +1156,16 @@ function escapeAttr(s) {
} }
// ── Git tab ── // ── Git tab ──
var gitSelectedRepo = null;
function loadGit() { function loadGit() {
return loadTab('git-loading', 'git-content', 'git log', gitSelectedRepo = null;
return loadTab('git-loading', 'git-content', 'repositories',
function() { return apiFetch('/miniapp/api/git'); }, function() { return apiFetch('/miniapp/api/git'); },
renderGitFromData); renderGitRepos);
} }
function renderGitFromData(repos) { function renderGitRepos(repos) {
var loading = document.getElementById('git-loading'); var loading = document.getElementById('git-loading');
var el = document.getElementById('git-content'); var el = document.getElementById('git-content');
loading.classList.add('hidden'); loading.classList.add('hidden');
@ -1134,8 +1176,38 @@ function renderGitFromData(repos) {
return; return;
} }
var html = ''; el.innerHTML = repos.map(function(r) {
repos.forEach(function(repo) { return '<div class="git-repo-item glass glass-interactive" data-repo="' + escapeAttr(r.name) + '">' +
'<div class="git-repo-body">' +
'<div class="git-repo-name">' + escapeHtml(r.name) + '</div>' +
'<div class="git-repo-branch">' + escapeHtml(r.branch || '?') + '</div>' +
'</div>' +
'<span class="git-repo-arrow">\u203A</span>' +
'</div>';
}).join('');
}
document.getElementById('git-content').addEventListener('click', function(e) {
var item = e.target.closest('.git-repo-item');
if (!item) return;
loadGitDetail(item.dataset.repo);
});
function loadGitDetail(name) {
gitSelectedRepo = name;
return loadTab('git-loading', 'git-content', name,
function() { return apiFetch('/miniapp/api/git?repo=' + encodeURIComponent(name)); },
renderGitDetail);
}
function renderGitDetail(repo) {
var loading = document.getElementById('git-loading');
var el = document.getElementById('git-content');
loading.classList.add('hidden');
el.classList.remove('hidden');
var html = '<button class="git-back-btn" onclick="loadGit()">\u2190 ' + escapeHtml(repo.name || gitSelectedRepo) + '</button>';
html += '<div class="card glass"><div class="card-title">' + html += '<div class="card glass"><div class="card-title">' +
escapeHtml(repo.name) + ' &mdash; ' + escapeHtml(repo.branch || '?') + '</div>'; escapeHtml(repo.name) + ' &mdash; ' + escapeHtml(repo.branch || '?') + '</div>';
@ -1162,7 +1234,6 @@ function renderGitFromData(repos) {
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>'; html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
} }
html += '</div>'; html += '</div>';
});
el.innerHTML = html; el.innerHTML = html;
} }