Add repo-detail panel and /api/repo endpoint
New <repo-detail> component listens for repo:select and shows a repo's remotes, local branches (current + upstream), and recent commits. Backed by GET /api/repo (restricted to indexed repos) and read-only git readers LocalBranches/RecentCommits/RemoteDetails via repos.BuildDetail. <repo-list> highlights the selection; page docks the two panels left/right. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,11 @@ LISTEN_ADDR=127.0.0.1:8080
|
|||||||
# mounted to (see docker-compose.yml). Example: /repos,/work/other
|
# mounted to (see docker-compose.yml). Example: /repos,/work/other
|
||||||
GIT_REPO_ROOTS=/repos
|
GIT_REPO_ROOTS=/repos
|
||||||
|
|
||||||
|
# DOCKER ONLY: the HOST folder that holds your repositories. docker-compose
|
||||||
|
# mounts it to /repos inside the container (which GIT_REPO_ROOTS points at).
|
||||||
|
# Ignored when running the binary directly. Example: C:/Users/you/Projects
|
||||||
|
REPOS_HOST_PATH=./repos
|
||||||
|
|
||||||
# Path to the git binary. "git" resolves it from PATH (git is installed in the
|
# Path to the git binary. "git" resolves it from PATH (git is installed in the
|
||||||
# container image).
|
# container image).
|
||||||
GIT_BIN=git
|
GIT_BIN=git
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
|||||||
- **Why:** Stand up the architecture defined in AGENT.md so feature work can begin.
|
- **Why:** Stand up the architecture defined in AGENT.md so feature work can begin.
|
||||||
- **Affects:** whole repo (foundation); `components/repo-list`.
|
- **Affects:** whole repo (foundation); `components/repo-list`.
|
||||||
|
|
||||||
|
## 2026-09-19 — Repo detail panel
|
||||||
|
- **What:** Added the `<repo-detail>` component (right dock) that listens for
|
||||||
|
`repo:select` and shows a repo's remotes, local branches (current + upstream),
|
||||||
|
and 20 most recent commits. Backed by a new `GET /api/repo?path=` endpoint
|
||||||
|
(restricted to indexed repos) and new read-only git readers
|
||||||
|
(`LocalBranches`, `RecentCommits`, `RemoteDetails`) plus `repos.BuildDetail`
|
||||||
|
and `Index.Get`. `<repo-list>` now highlights the selected repo; `index.html`
|
||||||
|
lays the two panels out left/right; help page documents the detail view.
|
||||||
|
- **Why:** Make the dashboard drill into a single repository (the detail half of
|
||||||
|
the list+detail default in AGENT.md §4).
|
||||||
|
- **Affects:** `components/repo-detail`, `components/repo-list`,
|
||||||
|
`internal/git`, `internal/repos`, `cmd/server`, `web/templates`.
|
||||||
|
|
||||||
### Notes to confirm (from AGENT.md §11)
|
### Notes to confirm (from AGENT.md §11)
|
||||||
- **Go module path** is the placeholder `gitmanager`; change it if this gets a
|
- **Go module path** is the placeholder `gitmanager`; change it if this gets a
|
||||||
canonical import path (e.g. a GitHub URL).
|
canonical import path (e.g. a GitHub URL).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -80,6 +81,17 @@ func main() {
|
|||||||
e.GET("/api/repos", func(c echo.Context) error {
|
e.GET("/api/repos", func(c echo.Context) error {
|
||||||
return c.JSON(http.StatusOK, scanner.Index.List())
|
return c.JSON(http.StatusOK, scanner.Index.List())
|
||||||
})
|
})
|
||||||
|
e.GET("/api/repo", func(c echo.Context) error {
|
||||||
|
// Only serve details for a repo we already discovered — never run git
|
||||||
|
// against an arbitrary path supplied in the query string. Clean the
|
||||||
|
// input so separator style (/, \) doesn't defeat the exact-match lookup.
|
||||||
|
path := filepath.Clean(c.QueryParam("path"))
|
||||||
|
base, ok := scanner.Index.Get(path)
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, repos.BuildDetail(c.Request().Context(), g, base))
|
||||||
|
})
|
||||||
|
|
||||||
// Serve with graceful shutdown.
|
// Serve with graceful shutdown.
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// <repo-detail> — detail panel for the repository selected in <repo-list>.
|
||||||
|
//
|
||||||
|
// A self-contained control (AGENT.md §1.1): shadow DOM, fetches its own data,
|
||||||
|
// cleans up on disconnect. It has no reference to <repo-list>; it listens on the
|
||||||
|
// document for the bubbling/composed `repo:select` event (the sanctioned
|
||||||
|
// cross-component channel) and fetches `/api/repo?path=…` for the chosen repo.
|
||||||
|
|
||||||
|
class RepoDetail extends HTMLElement {
|
||||||
|
#controller = null;
|
||||||
|
#onSelect = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#renderShell();
|
||||||
|
this.#renderEmpty();
|
||||||
|
this.#onSelect = (e) => this.#load(e.detail?.path);
|
||||||
|
document.addEventListener('repo:select', this.#onSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener('repo:select', this.#onSelect);
|
||||||
|
this.#controller?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #load(path) {
|
||||||
|
if (!path) return;
|
||||||
|
this.#controller?.abort();
|
||||||
|
this.#controller = new AbortController();
|
||||||
|
this.#body().innerHTML = `<p class="muted">Loading…</p>`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/repo?path=${encodeURIComponent(path)}`, {
|
||||||
|
signal: this.#controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.#renderDetail(await res.json());
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
this.#body().innerHTML = `<p class="error">Could not load details: ${this.#esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#body() { return this.shadowRoot.getElementById('body'); }
|
||||||
|
|
||||||
|
#renderEmpty() {
|
||||||
|
this.#body().innerHTML = `<p class="muted">Select a repository to see its details.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderDetail(r) {
|
||||||
|
const remotes = (r.remoteDetails || []).map((rm) =>
|
||||||
|
`<li><span class="k">${this.#esc(rm.name)}</span> <span class="url">${this.#esc(rm.url)}</span></li>`
|
||||||
|
).join('') || `<li class="muted">none</li>`;
|
||||||
|
|
||||||
|
const branches = (r.branches || []).map((b) =>
|
||||||
|
`<li class="${b.current ? 'cur' : ''}">
|
||||||
|
${b.current ? '<span class="dot">●</span>' : ''}${this.#esc(b.name)}
|
||||||
|
${b.upstream ? `<span class="up">→ ${this.#esc(b.upstream)}</span>` : ''}
|
||||||
|
</li>`
|
||||||
|
).join('') || `<li class="muted">none</li>`;
|
||||||
|
|
||||||
|
const commits = (r.commits || []).map((c) =>
|
||||||
|
`<li>
|
||||||
|
<code>${this.#esc(c.short)}</code>
|
||||||
|
<span class="subject">${this.#esc(c.subject)}</span>
|
||||||
|
<span class="meta">${this.#esc(c.author)} · ${this.#esc(c.date)}</span>
|
||||||
|
</li>`
|
||||||
|
).join('') || `<li class="muted">none</li>`;
|
||||||
|
|
||||||
|
this.#body().innerHTML = `
|
||||||
|
<div class="head">
|
||||||
|
<h2>${this.#esc(r.name)}</h2>
|
||||||
|
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
|
||||||
|
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
|
||||||
|
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<p class="path">${this.#esc(r.path)}</p>
|
||||||
|
<p class="branch">on <strong>${this.#esc(r.branch || '—')}</strong></p>
|
||||||
|
${r.error ? `<p class="error">${this.#esc(r.error)}</p>` : ''}
|
||||||
|
|
||||||
|
<h3>Remotes</h3>
|
||||||
|
<ul class="remotes">${remotes}</ul>
|
||||||
|
|
||||||
|
<h3>Branches</h3>
|
||||||
|
<ul class="branches">${branches}</ul>
|
||||||
|
|
||||||
|
<h3>Recent commits</h3>
|
||||||
|
<ul class="commits">${commits}</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderShell() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
#body {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
h2 { margin: 0; font-size: 16px; }
|
||||||
|
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
color: var(--color-fg-muted); margin: 20px 0 6px; }
|
||||||
|
.path { color: var(--color-fg-muted); font-size: 12px; margin: 6px 0 0;
|
||||||
|
word-break: break-all; }
|
||||||
|
.branch { margin: 4px 0 0; }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
|
||||||
|
li { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||||
|
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
|
||||||
|
.k { font-weight: 600; }
|
||||||
|
.url, .up, .meta { color: var(--color-fg-muted); font-size: 12px; }
|
||||||
|
.subject { flex: 1; }
|
||||||
|
.branches .cur { color: var(--git-ahead); font-weight: 600; }
|
||||||
|
.dot { color: var(--git-ahead); }
|
||||||
|
.muted { color: var(--color-fg-muted); }
|
||||||
|
.error { color: var(--color-danger); }
|
||||||
|
.badge { font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); }
|
||||||
|
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
|
||||||
|
.clean { color: var(--git-clean); border-color: var(--git-clean); }
|
||||||
|
.ahead { color: var(--git-ahead); }
|
||||||
|
.behind { color: var(--git-behind); }
|
||||||
|
</style>
|
||||||
|
<div id="body"></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('repo-detail', RepoDetail);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# repo-detail
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
The detail panel for the repository selected in the dashboard. It fulfills the
|
||||||
|
right-docked "detail panel" role (AGENT.md §4) and demonstrates cross-component
|
||||||
|
communication done the sanctioned way (AGENT.md §1.1): it holds no reference to
|
||||||
|
`<repo-list>` — it only listens for the `repo:select` event and fetches its own
|
||||||
|
data.
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
- **Tag:** `<repo-detail>`
|
||||||
|
- **Attributes/properties:** none.
|
||||||
|
- **Listens:** `repo:select` on `document` — the bubbling/composed event emitted
|
||||||
|
by `<repo-list>`; `event.detail.path` selects the repo.
|
||||||
|
- **Fetches:** `GET /api/repo?path=<abs path>` (in-flight request aborted on the
|
||||||
|
next selection and on disconnect). The endpoint only serves repos already in
|
||||||
|
the scanner index — it never runs git against an arbitrary query path.
|
||||||
|
- **Renders:** name + status badges, path, current branch, remotes (name + URL),
|
||||||
|
local branches (current flagged, upstream shown), and the 20 most recent
|
||||||
|
commits.
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-19: created — first detail panel; consumes `repo:select`, backed by the
|
||||||
|
new `/api/repo` endpoint and `internal/git` branch/commit/remote readers.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
- Server output is escaped before insertion (`#esc`); branch names, commit
|
||||||
|
subjects, and remote URLs all originate from repo contents — treat as untrusted.
|
||||||
|
- Uses shared design tokens for all colors/radii — no hardcoded hex (AGENT.md §1.1).
|
||||||
|
- Data is fetched on selection only (no polling); it will not auto-refresh while a
|
||||||
|
repo stays selected. A push/refresh signal can be added without changing the
|
||||||
|
public surface.
|
||||||
@@ -9,6 +9,8 @@ class RepoList extends HTMLElement {
|
|||||||
#refreshMs = 15000;
|
#refreshMs = 15000;
|
||||||
#timer = null;
|
#timer = null;
|
||||||
#controller = null;
|
#controller = null;
|
||||||
|
#repos = [];
|
||||||
|
#selected = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -39,6 +41,8 @@ class RepoList extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#select(repo) {
|
#select(repo) {
|
||||||
|
this.#selected = repo.path;
|
||||||
|
this.#renderRepos(this.#repos); // reflect selection highlight
|
||||||
// Cross-component communication is via events only (AGENT.md §1.1).
|
// Cross-component communication is via events only (AGENT.md §1.1).
|
||||||
this.dispatchEvent(new CustomEvent('repo:select', {
|
this.dispatchEvent(new CustomEvent('repo:select', {
|
||||||
detail: repo, bubbles: true, composed: true,
|
detail: repo, bubbles: true, composed: true,
|
||||||
@@ -59,6 +63,7 @@ class RepoList extends HTMLElement {
|
|||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
}
|
}
|
||||||
li:hover { border-color: var(--border-strong); }
|
li:hover { border-color: var(--border-strong); }
|
||||||
|
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
|
||||||
.name { font-weight: 600; }
|
.name { font-weight: 600; }
|
||||||
.branch { color: var(--color-fg-muted); }
|
.branch { color: var(--color-fg-muted); }
|
||||||
.spacer { margin-left: auto; }
|
.spacer { margin-left: auto; }
|
||||||
@@ -83,14 +88,16 @@ class RepoList extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#renderRepos(repos) {
|
#renderRepos(repos) {
|
||||||
|
this.#repos = repos || [];
|
||||||
const body = this.shadowRoot.getElementById('body');
|
const body = this.shadowRoot.getElementById('body');
|
||||||
if (!repos || repos.length === 0) {
|
if (this.#repos.length === 0) {
|
||||||
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
|
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ul = document.createElement('ul');
|
const ul = document.createElement('ul');
|
||||||
for (const r of repos) {
|
for (const r of this.#repos) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
|
if (r.path === this.#selected) li.classList.add('selected');
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="name">${this.#esc(r.name)}</span>
|
<span class="name">${this.#esc(r.name)}</span>
|
||||||
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ component pattern the rest of the UI follows.
|
|||||||
## History
|
## History
|
||||||
- 2026-09-19: created — first component; renders name, branch, ahead/behind, and
|
- 2026-09-19: created — first component; renders name, branch, ahead/behind, and
|
||||||
a clean/dirty badge; establishes the shadow-DOM + self-fetch + event pattern.
|
a clean/dirty badge; establishes the shadow-DOM + self-fetch + event pattern.
|
||||||
|
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
|
||||||
|
accent border/background (the item that `<repo-detail>` is showing). Caches the
|
||||||
|
last `/api/repos` payload so re-selecting re-renders without a refetch.
|
||||||
|
|
||||||
## Notes / gotchas
|
## Notes / gotchas
|
||||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||||
|
|||||||
@@ -119,3 +119,106 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
|
|||||||
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
|
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Branch is a local branch and its upstream, if any.
|
||||||
|
type Branch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
Upstream string `json:"upstream,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit is a single log entry.
|
||||||
|
type Commit struct {
|
||||||
|
Short string `json:"short"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote is a named remote and its fetch URL.
|
||||||
|
type Remote struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalBranches lists local branches (refs/heads), flagging the current one and
|
||||||
|
// including each branch's upstream when set.
|
||||||
|
func (c *CLI) LocalBranches(ctx context.Context, dir string) ([]Branch, error) {
|
||||||
|
const format = "%(refname:short)%09%(HEAD)%09%(upstream:short)"
|
||||||
|
out, err := c.run(ctx, dir, "for-each-ref", "--format="+format, "refs/heads")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var branches []Branch
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
f := strings.Split(line, "\t")
|
||||||
|
if len(f) < 1 || f[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := Branch{Name: f[0]}
|
||||||
|
if len(f) > 1 {
|
||||||
|
b.Current = f[1] == "*"
|
||||||
|
}
|
||||||
|
if len(f) > 2 {
|
||||||
|
b.Upstream = f[2]
|
||||||
|
}
|
||||||
|
branches = append(branches, b)
|
||||||
|
}
|
||||||
|
return branches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentCommits returns the newest n commits reachable from HEAD.
|
||||||
|
func (c *CLI) RecentCommits(ctx context.Context, dir string, n int) ([]Commit, error) {
|
||||||
|
// Fields separated by TAB (%x09); records by newline.
|
||||||
|
const format = "%h%x09%an%x09%ad%x09%s"
|
||||||
|
out, err := c.run(ctx, dir, "log", "-n", strconv.Itoa(n), "--date=short", "--pretty=format:"+format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var commits []Commit
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
f := strings.SplitN(line, "\t", 4)
|
||||||
|
if len(f) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commits = append(commits, Commit{Short: f[0], Author: f[1], Date: f[2], Subject: f[3]})
|
||||||
|
}
|
||||||
|
return commits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteDetails returns each remote with its fetch URL.
|
||||||
|
func (c *CLI) RemoteDetails(ctx context.Context, dir string) ([]Remote, error) {
|
||||||
|
out, err := c.run(ctx, dir, "remote", "-v")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var remotes []Remote
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
// Format: "<name>\t<url> (fetch|push)"
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 3 || f[2] != "(fetch)" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[f[0]]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[f[0]] = struct{}{}
|
||||||
|
remotes = append(remotes, Remote{Name: f[0], URL: f[1]})
|
||||||
|
}
|
||||||
|
return remotes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLines splits on newlines, dropping empty lines.
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if line != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitmanager/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Detail is the enriched view of a single repository shown in the detail panel.
|
||||||
|
// It embeds the cached State and adds data fetched on demand via the git
|
||||||
|
// boundary (all read-only — AGENT.md §1.3).
|
||||||
|
type Detail struct {
|
||||||
|
State
|
||||||
|
Branches []git.Branch `json:"branches"`
|
||||||
|
Commits []git.Commit `json:"commits"`
|
||||||
|
RemoteDetails []git.Remote `json:"remoteDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the cached State for a repo path, or false if it is not indexed.
|
||||||
|
func (i *Index) Get(path string) (State, bool) {
|
||||||
|
i.mu.RLock()
|
||||||
|
defer i.mu.RUnlock()
|
||||||
|
s, ok := i.byKey[path]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDetail enriches a cached State with branches, recent commits, and remote
|
||||||
|
// URLs. Errors on the enriching calls are non-fatal: whatever succeeds is
|
||||||
|
// returned, and the partial failure is recorded on Detail.Error.
|
||||||
|
func BuildDetail(ctx context.Context, g *git.CLI, base State) Detail {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
d := Detail{State: base}
|
||||||
|
|
||||||
|
if branches, err := g.LocalBranches(ctx, base.Path); err == nil {
|
||||||
|
d.Branches = branches
|
||||||
|
} else {
|
||||||
|
d.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if commits, err := g.RecentCommits(ctx, base.Path, 20); err == nil {
|
||||||
|
d.Commits = commits
|
||||||
|
} else if d.Error == "" {
|
||||||
|
d.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if remotes, err := g.RemoteDetails(ctx, base.Path); err == nil {
|
||||||
|
d.RemoteDetails = remotes
|
||||||
|
} else if d.Error == "" {
|
||||||
|
d.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -45,6 +45,14 @@
|
|||||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>Repository details</h2>
|
||||||
|
<p>
|
||||||
|
Click any repository in the list to open its details on the right: its
|
||||||
|
full path and current branch, its remotes and their URLs, every local
|
||||||
|
branch (with the one you're on marked and its upstream shown), and the 20
|
||||||
|
most recent commits.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Background refresh</h2>
|
<h2>Background refresh</h2>
|
||||||
<p>
|
<p>
|
||||||
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<!-- Web components declare themselves; the page does not micro-manage them
|
<!-- Web components declare themselves; the page does not micro-manage them
|
||||||
(AGENT.md §1.1). Each fetches its own data on connect. -->
|
(AGENT.md §1.1). Each fetches its own data on connect. -->
|
||||||
<script type="module" src="/components/repo-list/repo-list.js"></script>
|
<script type="module" src="/components/repo-list/repo-list.js"></script>
|
||||||
|
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -19,7 +20,16 @@
|
|||||||
}
|
}
|
||||||
header h1 { margin: 0; font-size: 16px; }
|
header h1 { margin: 0; font-size: 16px; }
|
||||||
header nav { margin-left: auto; }
|
header nav { margin-left: auto; }
|
||||||
main { padding: 20px; }
|
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
|
||||||
|
A real dockable layout is layered on later; this is the static default. */
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -29,9 +39,8 @@
|
|||||||
<nav><a href="/help">Help</a></nav>
|
<nav><a href="/help">Help</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<!-- The repo list docks LEFT by default (AGENT.md §4). Docking is layered on
|
|
||||||
later; for now the list stands alone. -->
|
|
||||||
<repo-list></repo-list>
|
<repo-list></repo-list>
|
||||||
|
<repo-detail></repo-detail>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user