68b6c3a3f8
Replace the Switch branch text prompt with a flyout submenu populated from GET /api/repo (the repo's branches; current one disabled); clicking a branch checks it out. Flips leftward near the viewport edge. New branch still prompts. Verified live: submenu lists branches and switches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
9.5 KiB
JavaScript
242 lines
9.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).
|
|
// "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 <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' },
|
|
{ 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 = `<div class="note">Loading…</div>`;
|
|
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 = `<div class="note">No branches.</div>`; 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 = `<div class="note err">Couldn't load branches.</div>`;
|
|
}
|
|
}
|
|
|
|
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 });
|
|
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 #checkoutBranch(branch) {
|
|
this.#hide();
|
|
await this.#git('checkout', { branch });
|
|
}
|
|
|
|
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) => {
|
|
if (it.sep) return '<hr>';
|
|
if (it.sub) {
|
|
return `<div class="item has-sub" tabindex="0">
|
|
<span>${it.label}</span><span class="arrow">▸</span>
|
|
<div class="submenu" id="${it.sub}"></div>
|
|
</div>`;
|
|
}
|
|
return `<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, .item { display: flex; align-items: center; gap: 10px; width: 100%;
|
|
box-sizing: border-box; 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, .item:hover, .item:focus { 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; }
|
|
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
|
.has-sub { position: relative; }
|
|
.has-sub .arrow { margin-left: auto; color: var(--color-fg-muted); }
|
|
.has-sub:hover .arrow, .has-sub:focus .arrow, .has-sub:focus-within .arrow { color: #071019; }
|
|
.submenu {
|
|
position: absolute; left: 100%; top: -5px; display: none;
|
|
min-width: 180px; max-height: 260px; overflow-y: auto;
|
|
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);
|
|
}
|
|
#menu.flip .submenu { left: auto; right: 100%; }
|
|
.has-sub:hover .submenu, .has-sub:focus-within .submenu { display: block; }
|
|
.submenu .branch { color: var(--color-fg); }
|
|
.submenu .branch.cur { color: var(--color-fg-muted); cursor: default; }
|
|
.submenu .branch:disabled { background: none; color: var(--color-fg-muted); }
|
|
.submenu .note { padding: 6px 10px; color: var(--color-fg-muted); font-size: 12px; }
|
|
.submenu .note.err { color: var(--color-danger); }
|
|
</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) 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);
|