Slice 13: repo search + filtering (+ no-cache dev assets)
<repo-list> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:** `<repo-list>` 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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
li {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
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; }
|
||||
.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); }
|
||||
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
<div id="body"><p class="empty">Loading repositories…</p></div>
|
||||
`;
|
||||
#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 =
|
||||
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
||||
#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 = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
body.innerHTML = `<p class="empty">No repositories match your search.</p>`;
|
||||
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 =
|
||||
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.controls { display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
#search {
|
||||
width: 100%; box-sizing: border-box; font: inherit;
|
||||
background: var(--surface-1); color: var(--color-fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 7px 10px;
|
||||
}
|
||||
#search:focus { outline: none; border-color: var(--fill-accent); }
|
||||
.chips { display: flex; align-items: center; gap: 6px; }
|
||||
.chip {
|
||||
font: inherit; font-size: 12px; cursor: pointer; padding: 3px 10px;
|
||||
border-radius: 999px; border: 1px solid var(--border-strong);
|
||||
background: transparent; color: var(--color-fg-muted);
|
||||
}
|
||||
.chip.active { border-color: var(--fill-accent); color: var(--fill-accent);
|
||||
background: var(--surface-2); }
|
||||
.count { margin-left: auto; color: var(--color-fg-muted); font-size: 12px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
li {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
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; }
|
||||
.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); }
|
||||
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
<div class="controls">
|
||||
<input id="search" type="search" placeholder="Search repositories…" autocomplete="off" />
|
||||
<div class="chips">
|
||||
<button id="f-dirty" class="chip" type="button">Dirty</button>
|
||||
<button id="f-ab" class="chip" type="button">Ahead/behind</button>
|
||||
<span id="count" class="count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="body"><p class="empty">Loading repositories…</p></div>
|
||||
`;
|
||||
|
||||
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]
|
||||
|
||||
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<repo-list>`
|
||||
- **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
|
||||
`<repo-menu>` (§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
|
||||
|
||||
@@ -38,6 +38,14 @@
|
||||
<li>Run <code>docker compose up</code> and open the dashboard.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Finding a repository</h2>
|
||||
<p>
|
||||
Use the search box above the list to filter by name or path, and the
|
||||
<strong>Dirty</strong> and <strong>Ahead/behind</strong> 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.
|
||||
</p>
|
||||
|
||||
<h2>Reading the dashboard</h2>
|
||||
<ul>
|
||||
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
|
||||
|
||||
Reference in New Issue
Block a user