// — 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). // "Switch branch" is a flyout submenu populated from the repo's branches. 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' }, { sub: 'branches', label: 'Switch branch', hint: 'checkout' }, { cmd: 'newbranch', label: 'New branch…', hint: 'branch' }, { 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; #branchController = 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.#branchController?.abort(); 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; // Clamp to viewport; flip submenus leftward when near the right edge. 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'; menu.classList.toggle('flip', left + rect.width + 200 > window.innerWidth); this.#loadBranches(repo); 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); }, 0); } #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 #loadBranches(repo) { const sub = this.shadowRoot.getElementById('branches'); sub.innerHTML = `
Loading…
`; this.#branchController?.abort(); this.#branchController = new AbortController(); try { const res = await fetch(`/api/repo?path=${encodeURIComponent(repo.path)}`, { signal: this.#branchController.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const detail = await res.json(); const branches = detail.branches || []; if (branches.length === 0) { sub.innerHTML = `
No branches.
`; return; } sub.replaceChildren(...branches.map((b) => { const btn = document.createElement('button'); btn.className = 'branch' + (b.current ? ' cur' : ''); btn.textContent = (b.current ? '● ' : '') + b.name; if (b.current) { btn.disabled = true; btn.title = 'Current branch'; } else { btn.dataset.branch = b.name; } return btn; })); } catch (err) { if (err.name !== 'AbortError') sub.innerHTML = `
Couldn't load branches.
`; } } 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 'newbranch': { const b = window.prompt(`New branch name in ${name}:`); if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() }); 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 }); this.#toast(`${name} is now the active project`, 'info'); break; case 'handoff': await this.#post('/api/switch', { target: repo.path }); this.#toast(`Asked Claude to switch to ${name}`, 'info'); break; case 'copy': try { await navigator.clipboard.writeText(repo.path); this.#toast('Path copied', 'info'); } catch { this.#toast('Could not copy path', 'error'); } break; } } async #checkoutBranch(branch) { this.#hide(); await this.#git('checkout', { branch }); } #okLabel(op, extra) { switch (op) { case 'pull': return 'Got the latest'; case 'push': return 'Published'; case 'fetch': return 'Checked for updates'; case 'commit': return 'Saved your work'; case 'checkout': return `Switched to ${extra.branch}`; case 'create-branch': return `Created branch ${extra.branch}`; case 'discard': return 'Discarded changes'; default: return 'Done'; } } 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}`); this.#toast(this.#okLabel(op, extra), 'success'); } catch (err) { this.#toast(`${this.#okLabel(op, extra)} failed: ${err.message}`, 'error'); } } #toast(message, kind) { document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } })); } 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) => { if (it.sep) return '
'; if (it.sub) { return `
${it.label}
`; } return ``; }).join(''); this.shadowRoot.innerHTML = ` `; this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; } if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd); }); } #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; } } customElements.define('repo-menu', RepoMenu);