Merge branch 'feature/miniapp-git-tab'
This commit is contained in:
commit
0a3459c078
4 changed files with 186 additions and 44 deletions
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -602,10 +641,10 @@
|
|||
<div class="tabs-inner">
|
||||
<div class="tab-indicator"></div>
|
||||
<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="session">Session</button>
|
||||
<button class="tab" data-panel="config">Config</button>
|
||||
<button class="tab" data-panel="git">Git</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1117,13 +1156,16 @@ function escapeAttr(s) {
|
|||
}
|
||||
|
||||
// ── Git tab ──
|
||||
var gitSelectedRepo = null;
|
||||
|
||||
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'); },
|
||||
renderGitFromData);
|
||||
renderGitRepos);
|
||||
}
|
||||
|
||||
function renderGitFromData(repos) {
|
||||
function renderGitRepos(repos) {
|
||||
var loading = document.getElementById('git-loading');
|
||||
var el = document.getElementById('git-content');
|
||||
loading.classList.add('hidden');
|
||||
|
|
@ -1134,8 +1176,38 @@ function renderGitFromData(repos) {
|
|||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
repos.forEach(function(repo) {
|
||||
el.innerHTML = repos.map(function(r) {
|
||||
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">' +
|
||||
escapeHtml(repo.name) + ' — ' + 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>';
|
||||
});
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue