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>
This commit is contained in:
@@ -105,6 +105,15 @@ class RepoList extends HTMLElement {
|
||||
for (const r of this.#repos) {
|
||||
const li = document.createElement('li');
|
||||
if (r.path === this.#selected) li.classList.add('selected');
|
||||
// Right-click opens the command menu (§6) for this repo — via an event,
|
||||
// so <repo-menu> stays decoupled from this component (§1.1).
|
||||
li.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('repo:contextmenu', {
|
||||
detail: { repo: r, x: e.clientX, y: e.clientY },
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
});
|
||||
li.innerHTML = `
|
||||
<span class="name">${this.#esc(r.name)}</span>
|
||||
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
||||
|
||||
@@ -24,6 +24,8 @@ component pattern the rest of the UI follows.
|
||||
- 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it
|
||||
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
|
||||
readable by Claude via `get_active_project`. Fire-and-forget.
|
||||
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
|
||||
`<repo-menu>` (§6). Right-click does not change the selection/active project.
|
||||
|
||||
## Notes / gotchas
|
||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// <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);
|
||||
@@ -0,0 +1,32 @@
|
||||
# repo-menu
|
||||
|
||||
## Intent
|
||||
The right-click command menu (AGENT.md §6) — the app's reason for being: run git
|
||||
in **plain language** ("Get latest", "Publish", "Save my work…") without a
|
||||
terminal. A self-contained overlay control (§1.1) that any list can summon via an
|
||||
event. Safe commands run on click; the destructive one confirms first (§1.4).
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<repo-menu>` (place once, near the end of the page).
|
||||
- **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }`
|
||||
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
|
||||
- **Commands → endpoints:**
|
||||
- Get latest / Publish / Check for updates / Save my work… / Discard all
|
||||
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/
|
||||
commit/discard). "Save my work…" prompts for a message; "Discard all
|
||||
changes…" confirms (destructive).
|
||||
- Set as active project → `POST /api/active-project`.
|
||||
- Ask Claude to switch here → `POST /api/switch` (the handoff request).
|
||||
- Copy path → clipboard.
|
||||
- Dismisses on outside click, Esc, or scroll.
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 6; plain-language git commands + coordination
|
||||
actions, backed by the shared service layer (same ops Claude gets via MCP).
|
||||
|
||||
## Notes / gotchas
|
||||
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a
|
||||
`git-*` event with ok/failed), and errors raise a browser alert.
|
||||
- Network ops (pull/push/fetch) need git credentials reachable from the server;
|
||||
inside Docker that means a mounted SSH agent / credential helper (§11) — until
|
||||
then they'll report an auth error. commit/discard are local and always work.
|
||||
Reference in New Issue
Block a user