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:
parent
326bf5d4dd
commit
89c8cfa5bc
4 changed files with 186 additions and 44 deletions
|
|
@ -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)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,35 +1176,64 @@ 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) + '">' +
|
||||||
html += '<div class="card glass"><div class="card-title">' +
|
'<div class="git-repo-body">' +
|
||||||
escapeHtml(repo.name) + ' — ' + escapeHtml(repo.branch || '?') + '</div>';
|
'<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('');
|
||||||
|
}
|
||||||
|
|
||||||
if (repo.modified && repo.modified.length > 0) {
|
document.getElementById('git-content').addEventListener('click', function(e) {
|
||||||
html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Changes (' + repo.modified.length + ')</div>';
|
var item = e.target.closest('.git-repo-item');
|
||||||
repo.modified.forEach(function(f) {
|
if (!item) return;
|
||||||
html += '<div class="git-commit">' +
|
loadGitDetail(item.dataset.repo);
|
||||||
'<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) {
|
function loadGitDetail(name) {
|
||||||
html += '<div style="padding:4px 12px 8px;font-size:12px;color:var(--hint)">Commits</div>';
|
gitSelectedRepo = name;
|
||||||
repo.commits.forEach(function(c) {
|
return loadTab('git-loading', 'git-content', name,
|
||||||
html += '<div class="git-commit">' +
|
function() { return apiFetch('/miniapp/api/git?repo=' + encodeURIComponent(name)); },
|
||||||
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
|
renderGitDetail);
|
||||||
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
|
}
|
||||||
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
|
|
||||||
'</div>';
|
function renderGitDetail(repo) {
|
||||||
});
|
var loading = document.getElementById('git-loading');
|
||||||
} else {
|
var el = document.getElementById('git-content');
|
||||||
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
|
loading.classList.add('hidden');
|
||||||
}
|
el.classList.remove('hidden');
|
||||||
html += '</div>';
|
|
||||||
});
|
var html = '<button class="git-back-btn" onclick="loadGit()">\u2190 ' + escapeHtml(repo.name || gitSelectedRepo) + '</button>';
|
||||||
|
|
||||||
|
html += '<div class="card glass"><div class="card-title">' +
|
||||||
|
escapeHtml(repo.name) + ' — ' + 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>' +
|
||||||
|
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue