Files
GitManager/components/repo-list/repo-list.js
T
TBNilles 32ae17cc9f Config: .env = projects root only; derive each project's forge from git
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:52:03 -04:00

231 lines
8.8 KiB
JavaScript

// <repo-list> — 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 = `<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 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 <repo-menu> 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 = `
<span class="name">${this.#esc(r.name)}</span>
${r.forge ? `<span class="forge">${this.#esc(r.forge)}</span>` : ''}
<span class="branch">${this.#esc(r.branch || '—')}</span>
<span class="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
`;
li.addEventListener('click', () => this.#select(r));
ul.appendChild(li);
}
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; }
.forge { font-size: 11px; color: var(--fill-accent); border: 1px solid var(--border-strong);
border-radius: 999px; padding: 0 8px; }
.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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-list', RepoList);