// — the dashboard's list of discovered repositories. // // 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 // 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; #timer = null; #controller = null; #repos = []; #selected = null; #filters = { q: '', dirty: false, aheadBehind: false }; constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.#loadFilters(); this.#renderShell(); this.#load(); this.#timer = setInterval(() => this.#load(), this.#refreshMs); } disconnectedCallback() { clearInterval(this.#timer); this.#controller?.abort(); } async #load() { this.#controller?.abort(); this.#controller = new AbortController(); try { const res = await fetch('/api/repos', { signal: this.#controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); this.#repos = (await res.json()) || []; this.#apply(); } catch (err) { if (err.name !== 'AbortError') this.#renderError(err); } } #select(repo) { this.#selected = repo.path; 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, })); // Selecting a repo makes it the active project (a user action, §8.2). Fire // and forget — the SSE feed reflects the change; failure just skips it. fetch('/api/active-project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: repo.path }), }).catch(() => {}); } #loadFilters() { try { const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'); this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved }; } catch { /* ignore — use defaults */ } } #saveFilters() { try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ } } // #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 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, // so stays decoupled from this component (§1.1). li.addEventListener('contextmenu', (e) => { e.preventDefault(); this.dispatchEvent(new CustomEvent('repo:contextmenu', { detail: { repo: r, x: e.clientX, y: e.clientY }, bubbles: true, composed: true, })); }); li.innerHTML = ` ${this.#esc(r.name)} ${this.#esc(r.branch || '—')} ${r.ahead ? `↑${r.ahead}` : ''} ${r.behind ? `↓${r.behind}` : ''} ${r.dirty ? 'dirty' : 'clean'} `; li.addEventListener('click', () => this.#select(r)); ul.appendChild(li); } 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] )); } } customElements.define('repo-list', RepoList);