// — the right-click command menu (AGENT.md §6). // // A self-contained control (§1.1): shadow DOM, listens for the bubbling // `repo:contextmenu` event from , and shows a positioned menu of // PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run // on click; the destructive one ("Discard all changes") confirms first (§1.4). // It calls the same endpoints Claude uses via MCP (one service layer, §1.7); // results show up live in . const ITEMS = [ { cmd: 'pull', label: 'Get latest', hint: 'pull' }, { cmd: 'push', label: 'Publish', hint: 'push' }, { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, { cmd: 'commit', label: 'Save my work…', hint: 'commit' }, { sep: true }, { cmd: 'active', label: 'Set as active project' }, { cmd: 'handoff', label: 'Ask Claude to switch here' }, { cmd: 'copy', label: 'Copy path' }, { sep: true }, { cmd: 'discard', label: 'Discard all changes…', hint: 'reset --hard', danger: true }, ]; class RepoMenu extends HTMLElement { #repo = null; #onContext = null; #onDocClick = null; #onKey = null; constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.#renderShell(); this.#onContext = (e) => this.#open(e.detail); document.addEventListener('repo:contextmenu', this.#onContext); } disconnectedCallback() { document.removeEventListener('repo:contextmenu', this.#onContext); this.#teardownDismiss(); } #open({ repo, x, y }) { if (!repo) return; this.#repo = repo; const menu = this.shadowRoot.getElementById('menu'); this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path); menu.hidden = false; // Position, clamped to the viewport. const rect = menu.getBoundingClientRect(); const left = Math.min(x, window.innerWidth - rect.width - 8); const top = Math.min(y, window.innerHeight - rect.height - 8); menu.style.left = Math.max(8, left) + 'px'; menu.style.top = Math.max(8, top) + 'px'; // Dismiss on next outside click, Esc, or scroll. this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); }; this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); }; setTimeout(() => { document.addEventListener('click', this.#onDocClick, { once: true }); document.addEventListener('keydown', this.#onKey); window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true }); }, 0); } #hideBound() { return () => this.#hide(); } #hide() { this.shadowRoot.getElementById('menu').hidden = true; this.#teardownDismiss(); } #teardownDismiss() { if (this.#onDocClick) document.removeEventListener('click', this.#onDocClick); if (this.#onKey) document.removeEventListener('keydown', this.#onKey); this.#onDocClick = this.#onKey = null; } async #dispatch(cmd) { const repo = this.#repo; const name = this.#base(repo.path); this.#hide(); switch (cmd) { case 'pull': case 'push': case 'fetch': await this.#git(cmd); break; case 'commit': { const msg = window.prompt(`Commit message for ${name}:`); if (msg && msg.trim()) await this.#git('commit', { message: msg }); break; } case 'discard': { const ok = window.confirm( `Discard ALL uncommitted changes in ${name}?\n\n` + `This resets tracked files to the last commit and cannot be undone.` ); if (ok) await this.#git('discard'); break; } case 'active': await this.#post('/api/active-project', { path: repo.path }); break; case 'handoff': await this.#post('/api/switch', { target: repo.path }); break; case 'copy': try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ } break; } } async #git(op, extra = {}) { try { const res = await fetch('/api/repo/git', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: this.#repo.path, op, ...extra }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); } catch (err) { window.alert(`${op} failed: ${err.message}`); } } async #post(url, body) { try { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch { /* ignore */ } } #renderShell() { const rows = ITEMS.map((it) => it.sep ? '
' : ``).join(''); this.shadowRoot.innerHTML = ` `; this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { const btn = e.target.closest('button'); if (btn) this.#dispatch(btn.dataset.cmd); }); } #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; } } customElements.define('repo-menu', RepoMenu);