diff --git a/.env.example b/.env.example index bc9bf82..79af731 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ LISTEN_ADDR=127.0.0.1:8080 # mounted to (see docker-compose.yml). Example: /repos,/work/other 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 # container image). GIT_BIN=git diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a32605..dde5d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - **Affects:** whole repo (foundation); `components/repo-list`. +## 2026-09-19 — Repo detail panel +- **What:** Added the `` 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`. `` 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) - **Go module path** is the placeholder `gitmanager`; change it if this gets a canonical import path (e.g. a GitHub URL). diff --git a/cmd/server/main.go b/cmd/server/main.go index ed0a3e8..35cea98 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -80,6 +81,17 @@ func main() { e.GET("/api/repos", func(c echo.Context) error { 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. go func() { diff --git a/components/repo-detail/repo-detail.js b/components/repo-detail/repo-detail.js new file mode 100644 index 0000000..f087316 --- /dev/null +++ b/components/repo-detail/repo-detail.js @@ -0,0 +1,140 @@ +// — detail panel for the repository selected in . +// +// A self-contained control (AGENT.md §1.1): shadow DOM, fetches its own data, +// cleans up on disconnect. It has no reference to ; 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 = `

Loading…

`; + 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 = `

Could not load details: ${this.#esc(err.message)}

`; + } + } + } + + #body() { return this.shadowRoot.getElementById('body'); } + + #renderEmpty() { + this.#body().innerHTML = `

Select a repository to see its details.

`; + } + + #renderDetail(r) { + const remotes = (r.remoteDetails || []).map((rm) => + `
  • ${this.#esc(rm.name)} ${this.#esc(rm.url)}
  • ` + ).join('') || `
  • none
  • `; + + const branches = (r.branches || []).map((b) => + `
  • + ${b.current ? '' : ''}${this.#esc(b.name)} + ${b.upstream ? `→ ${this.#esc(b.upstream)}` : ''} +
  • ` + ).join('') || `
  • none
  • `; + + const commits = (r.commits || []).map((c) => + `
  • + ${this.#esc(c.short)} + ${this.#esc(c.subject)} + ${this.#esc(c.author)} · ${this.#esc(c.date)} +
  • ` + ).join('') || `
  • none
  • `; + + this.#body().innerHTML = ` +
    +

    ${this.#esc(r.name)}

    + ${r.dirty ? 'dirty' : 'clean'} + ${r.ahead ? `↑${r.ahead}` : ''} + ${r.behind ? `↓${r.behind}` : ''} +
    +

    ${this.#esc(r.path)}

    +

    on ${this.#esc(r.branch || '—')}

    + ${r.error ? `

    ${this.#esc(r.error)}

    ` : ''} + +

    Remotes

    +
      ${remotes}
    + +

    Branches

    +
      ${branches}
    + +

    Recent commits

    +
      ${commits}
    + `; + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + +
    + `; + } + + #esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +customElements.define('repo-detail', RepoDetail); diff --git a/components/repo-detail/repo-detail.md b/components/repo-detail/repo-detail.md new file mode 100644 index 0000000..f1d1e30 --- /dev/null +++ b/components/repo-detail/repo-detail.md @@ -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 +`` — it only listens for the `repo:select` event and fetches its own +data. + +## Public surface +- **Tag:** `` +- **Attributes/properties:** none. +- **Listens:** `repo:select` on `document` — the bubbling/composed event emitted + by ``; `event.detail.path` selects the repo. +- **Fetches:** `GET /api/repo?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. diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js index b83c1c2..bbe7661 100644 --- a/components/repo-list/repo-list.js +++ b/components/repo-list/repo-list.js @@ -9,6 +9,8 @@ class RepoList extends HTMLElement { #refreshMs = 15000; #timer = null; #controller = null; + #repos = []; + #selected = null; constructor() { super(); @@ -39,6 +41,8 @@ class RepoList extends HTMLElement { } #select(repo) { + this.#selected = repo.path; + this.#renderRepos(this.#repos); // reflect selection highlight // Cross-component communication is via events only (AGENT.md §1.1). this.dispatchEvent(new CustomEvent('repo:select', { detail: repo, bubbles: true, composed: true, @@ -59,6 +63,7 @@ class RepoList extends HTMLElement { display: flex; align-items: center; gap: 12px; } li:hover { border-color: var(--border-strong); } + li.selected { border-color: var(--fill-accent); background: var(--surface-2); } .name { font-weight: 600; } .branch { color: var(--color-fg-muted); } .spacer { margin-left: auto; } @@ -83,14 +88,16 @@ class RepoList extends HTMLElement { } #renderRepos(repos) { + this.#repos = repos || []; const body = this.shadowRoot.getElementById('body'); - if (!repos || repos.length === 0) { + if (this.#repos.length === 0) { body.innerHTML = `

    No repositories found. Check GIT_REPO_ROOTS.

    `; return; } const ul = document.createElement('ul'); - for (const r of repos) { + for (const r of this.#repos) { const li = document.createElement('li'); + if (r.path === this.#selected) li.classList.add('selected'); li.innerHTML = ` ${this.#esc(r.name)} ${this.#esc(r.branch || '—')} diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md index 333ed21..6f8834c 100644 --- a/components/repo-list/repo-list.md +++ b/components/repo-list/repo-list.md @@ -18,6 +18,9 @@ component pattern the rest of the UI follows. ## History - 2026-09-19: created — first component; renders name, branch, ahead/behind, and 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 `` is showing). Caches the + last `/api/repos` payload so re-selecting re-renders without a refetch. ## Notes / gotchas - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all diff --git a/internal/git/git.go b/internal/git/git.go index 5812025..8831eec 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -119,3 +119,106 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error { _, err := c.run(ctx, dir, "fetch", "--quiet", "--all") 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: "\t (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 +} diff --git a/internal/repos/detail.go b/internal/repos/detail.go new file mode 100644 index 0000000..b4163a3 --- /dev/null +++ b/internal/repos/detail.go @@ -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 +} diff --git a/web/templates/help.html b/web/templates/help.html index 40a204c..8de2d07 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -45,6 +45,14 @@
  • Ahead / behind — commits your branch leads or trails its upstream by.
  • +

    Repository details

    +

    + 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. +

    +

    Background refresh

    The dashboard refreshes on its own. By default it does not reach diff --git a/web/templates/index.html b/web/templates/index.html index a376ef8..9137875 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -8,6 +8,7 @@ + @@ -29,9 +39,8 @@

    - +