From 69d38484e87490b1431183374381b7244cf25d46 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sun, 20 Sep 2026 18:02:30 -0400 Subject: [PATCH] Slice 13: repo search + filtering (+ no-cache dev assets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gains a search box (name/path) and Dirty/Ahead-behind filter chips with an N-of-M count; filtering is client-side and the state persists in localStorage (gitmanager.repolist.filters, §4). Refresh/selection re-apply filters. cmd/server sends Cache-Control: no-cache for /components and /static so hot-reloaded asset edits show on reload. Server-verified (curl); live click-through pending an unresponsive browser pane. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++ cmd/server/main.go | 13 +++ components/repo-list/repo-list.js | 171 +++++++++++++++++++++++------- components/repo-list/repo-list.md | 8 +- web/templates/help.html | 8 ++ 5 files changed, 171 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 991b61a..53ced9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -260,3 +260,16 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. - **Why:** Immediate, legible feedback for the non-expert audience (§6 polish). - **Affects:** `components/toast-host` (new), `components/repo-menu`, `components/pr-list`, `web/templates/index.html`. + +## 2026-09-20 — Slice 13: repo search + filtering +- **What:** `` gained a search box (name/path, case-insensitive) and + "Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is + client-side over the fetched list; state persists per-viewer in + `localStorage["gitmanager.repolist.filters"]` (§4). Refresh/selection re-apply + the active filters. +- **Why:** Keep the dashboard usable as the number of repos grows. +- **Affects:** `components/repo-list`, `web/templates/help.html`. +- **Dev-server tweak:** `cmd/server` now sends `Cache-Control: no-cache` for + `/components` and `/static` so browsers revalidate assets on reload (cached ES + modules were defeating hot reload). Server serves the new component (curl- + verified); live click-through pending (browser pane was unresponsive). diff --git a/cmd/server/main.go b/cmd/server/main.go index ff3eb9d..393b245 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -109,6 +110,18 @@ func main() { e.Use(middleware.Recover()) e.Use(middleware.RequestID()) + // Ask browsers to revalidate component/static assets so edits show up on + // reload (the dev server hot-reloads; cached ES modules would defeat that). + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + p := c.Request().URL.Path + if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") { + c.Response().Header().Set("Cache-Control", "no-cache") + } + return next(c) + } + }) + // Static assets and component sources. e.Static("/static", "web/static") e.Static("/components", "components") diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js index 445d7ec..56bad45 100644 --- a/components/repo-list/repo-list.js +++ b/components/repo-list/repo-list.js @@ -2,8 +2,12 @@ // // A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a // shadow root, fetches its own data from /api/repos on connect, renders itself, -// and cleans up on disconnect. It talks to the rest of the app only via a -// bubbling/composed `repo:select` CustomEvent — no shared globals. +// and cleans up on disconnect. It talks to the rest of the app only via +// bubbling/composed CustomEvents (`repo:select`, `repo:contextmenu`) — no shared +// globals. Search + filter state is per-viewer view state kept in localStorage +// (§4); filtering is client-side over the already-fetched list. + +const FILTER_KEY = 'gitmanager.repolist.filters'; class RepoList extends HTMLElement { #refreshMs = 15000; @@ -11,6 +15,7 @@ class RepoList extends HTMLElement { #controller = null; #repos = []; #selected = null; + #filters = { q: '', dirty: false, aheadBehind: false }; constructor() { super(); @@ -18,6 +23,7 @@ class RepoList extends HTMLElement { } connectedCallback() { + this.#loadFilters(); this.#renderShell(); this.#load(); this.#timer = setInterval(() => this.#load(), this.#refreshMs); @@ -34,7 +40,8 @@ class RepoList extends HTMLElement { try { const res = await fetch('/api/repos', { signal: this.#controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - this.#renderRepos(await res.json()); + this.#repos = (await res.json()) || []; + this.#apply(); } catch (err) { if (err.name !== 'AbortError') this.#renderError(err); } @@ -42,7 +49,7 @@ class RepoList extends HTMLElement { #select(repo) { this.#selected = repo.path; - this.#renderRepos(this.#repos); // reflect selection highlight + this.#apply(); // 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, @@ -56,53 +63,46 @@ class RepoList extends HTMLElement { }).catch(() => {}); } - #renderShell() { - this.shadowRoot.innerHTML = ` - -

Loading repositories…

- `; + #loadFilters() { + try { + const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'); + this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved }; + } catch { /* ignore — use defaults */ } } - #renderError(err) { - this.shadowRoot.getElementById('body').innerHTML = - `

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

`; + #saveFilters() { + try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ } } - #renderRepos(repos) { - this.#repos = repos || []; + // #apply computes the filtered set and renders the body + count. + #apply() { const body = this.shadowRoot.getElementById('body'); + const count = this.shadowRoot.getElementById('count'); + const { q, dirty, aheadBehind } = this.#filters; + const ql = q.trim().toLowerCase(); + const filtered = this.#repos.filter((r) => { + if (ql && !(String(r.name).toLowerCase().includes(ql) || String(r.path).toLowerCase().includes(ql))) return false; + if (dirty && !r.dirty) return false; + if (aheadBehind && !((r.ahead || 0) > 0 || (r.behind || 0) > 0)) return false; + return true; + }); + + count.textContent = this.#repos.length ? `${filtered.length} of ${this.#repos.length}` : ''; + if (this.#repos.length === 0) { body.innerHTML = `

No repositories found. Check GIT_REPO_ROOTS.

`; return; } + if (filtered.length === 0) { + body.innerHTML = `

No repositories match your search.

`; + return; + } + this.#renderList(filtered, body); + } + + #renderList(repos, body) { const ul = document.createElement('ul'); - for (const r of this.#repos) { + for (const r of repos) { const li = document.createElement('li'); if (r.path === this.#selected) li.classList.add('selected'); // Right-click opens the command menu (§6) for this repo — via an event, @@ -128,6 +128,95 @@ class RepoList extends HTMLElement { body.replaceChildren(ul); } + #renderError(err) { + this.shadowRoot.getElementById('body').innerHTML = + `

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

`; + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + +
+ +
+ + + +
+
+

Loading repositories…

+ `; + + const search = this.shadowRoot.getElementById('search'); + const dirtyBtn = this.shadowRoot.getElementById('f-dirty'); + const abBtn = this.shadowRoot.getElementById('f-ab'); + // Reflect persisted state. + search.value = this.#filters.q; + dirtyBtn.classList.toggle('active', this.#filters.dirty); + abBtn.classList.toggle('active', this.#filters.aheadBehind); + + search.addEventListener('input', () => { + this.#filters.q = search.value; + this.#saveFilters(); + this.#apply(); + }); + dirtyBtn.addEventListener('click', () => { + this.#filters.dirty = !this.#filters.dirty; + dirtyBtn.classList.toggle('active', this.#filters.dirty); + this.#saveFilters(); + this.#apply(); + }); + abBtn.addEventListener('click', () => { + this.#filters.aheadBehind = !this.#filters.aheadBehind; + abBtn.classList.toggle('active', this.#filters.aheadBehind); + this.#saveFilters(); + this.#apply(); + }); + } + #esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md index 970731d..d162025 100644 --- a/components/repo-list/repo-list.md +++ b/components/repo-list/repo-list.md @@ -9,7 +9,11 @@ component pattern the rest of the UI follows. ## Public surface - **Tag:** `` -- **Attributes/properties:** none yet. +- **Attributes/properties:** none. +- **Search + filter:** a search box (matches name/path, case-insensitive) plus + "Dirty" and "Ahead/behind" toggle chips, with a "N of M" count. Filtering is + client-side over the fetched list; the state persists per-viewer in + `localStorage["gitmanager.repolist.filters"]` (§4). - **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is aborted on refresh and on disconnect). - **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail` @@ -26,6 +30,8 @@ component pattern the rest of the UI follows. readable by Claude via `get_active_project`. Fire-and-forget. - 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for `` (§6). Right-click does not change the selection/active project. +- 2026-09-20: added search + "Dirty"/"Ahead-behind" filter chips with a count, + persisted in localStorage; filtering is client-side (slice 13). ## Notes / gotchas - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all diff --git a/web/templates/help.html b/web/templates/help.html index 8e21dfa..5e4053c 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -38,6 +38,14 @@
  • Run docker compose up and open the dashboard.
  • +

    Finding a repository

    +

    + Use the search box above the list to filter by name or path, and the + Dirty and Ahead/behind chips to show only + repos with uncommitted changes or commits to sync. The count shows how many + match. Your search and filters are remembered on this device. +

    +

    Reading the dashboard

    • Branch — the checked-out branch (or HEAD when detached).