Files
GitManager/components/repo-menu/repo-menu.js
T
TBNilles 6ef1c195e3 Slice 6: right-click command menu + git write actions
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 09:12:18 -04:00

177 lines
6.5 KiB
JavaScript

// <repo-menu> — the right-click command menu (AGENT.md §6).
//
// A self-contained control (§1.1): shadow DOM, listens for the bubbling
// `repo:contextmenu` event from <repo-list>, 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 <activity-feed>.
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
? '<hr>'
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
</button>`).join('');
this.shadowRoot.innerHTML = `
<style>
#menu {
position: fixed; z-index: 1000; min-width: 220px;
background: var(--surface-2); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 8px 28px rgba(0,0,0,.45);
}
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit;
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; }
button:hover { background: var(--fill-accent); color: #071019; }
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; }
button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; }
button.danger:hover code { color: #fff; }
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
</style>
<div id="menu" hidden>
<div class="hdr" id="hdr"></div>
${rows}
</div>`;
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);