6ef1c195e3
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>
177 lines
6.5 KiB
JavaScript
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);
|