Slice 11: branch-picker submenu in the right-click menu

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>
This commit is contained in:
2026-09-20 17:44:55 -04:00
parent ad00654487
commit 68b6c3a3f8
3 changed files with 86 additions and 29 deletions
+76 -23
View File
@@ -4,7 +4,8 @@
// `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);
// "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 = [
@@ -12,7 +13,7 @@ const ITEMS = [
{ cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' },
{ cmd: 'checkout', label: 'Switch branch', hint: 'checkout' },
{ sub: 'branches', label: 'Switch branch', hint: 'checkout' },
{ cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
{ sep: true },
{ cmd: 'active', label: 'Set as active project' },
@@ -27,6 +28,7 @@ class RepoMenu extends HTMLElement {
#onContext = null;
#onDocClick = null;
#onKey = null;
#branchController = null;
constructor() {
super();
@@ -41,6 +43,7 @@ class RepoMenu extends HTMLElement {
disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext);
this.#branchController?.abort();
this.#teardownDismiss();
}
@@ -50,25 +53,25 @@ class RepoMenu extends HTMLElement {
const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false;
// Position, clamped to the viewport.
// 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);
// 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();
@@ -80,6 +83,30 @@ class RepoMenu extends HTMLElement {
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);
@@ -93,11 +120,6 @@ class RepoMenu extends HTMLElement {
if (msg && msg.trim()) await this.#git('commit', { message: msg });
break;
}
case 'checkout': {
const b = window.prompt(`Switch ${name} to which existing branch?`);
if (b && b.trim()) await this.#git('checkout', { branch: b.trim() });
break;
}
case 'newbranch': {
const b = window.prompt(`New branch name in ${name}:`);
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() });
@@ -123,6 +145,11 @@ class RepoMenu extends HTMLElement {
}
}
async #checkoutBranch(branch) {
this.#hide();
await this.#git('checkout', { branch });
}
async #git(op, extra = {}) {
try {
const res = await fetch('/api/repo/git', {
@@ -144,11 +171,19 @@ class RepoMenu extends HTMLElement {
}
#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('');
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 {
@@ -160,25 +195,43 @@ class RepoMenu extends HTMLElement {
.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);
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 { background: var(--fill-accent); color: #071019; }
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; }
button.danger:hover code { 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) this.#dispatch(btn.dataset.cmd);
if (!btn) return;
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
});
}
+9 -5
View File
@@ -11,11 +11,13 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
- **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… / Switch branch… /
New branch… / Discard all changes… → `POST /api/repo/git {path, op,
message?, branch?}` (op: pull/push/fetch/commit/checkout/create-branch/
discard). "Save my work…" prompts for a message; "Switch branch…"/"New
branch…" prompt for the branch; "Discard all changes…" confirms (destructive).
- Get latest / Publish / Check for updates / Save my work… / New branch… /
Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
(op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
prompts for a message; "New branch…" prompts for a name; "Discard all
changes…" confirms (destructive).
- **Switch branch ▸** — a flyout submenu populated from `GET /api/repo?path=`
(the repo's branches; current one disabled). Clicking a branch → checkout.
- Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard.
@@ -26,6 +28,8 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
actions, backed by the shared service layer (same ops Claude gets via MCP).
- 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
both prompting for the branch name (slice 9).
- 2026-09-20: "Switch branch" is now a flyout submenu listing the repo's branches
(fetched from /api/repo), not a text prompt (slice 11). "New branch…" still prompts.
## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a
+1 -1
View File
@@ -55,7 +55,7 @@
<li><strong>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</li>
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
<li><strong>Switch branch</strong>move to another existing branch.</li>
<li><strong>Switch branch</strong>hover to pick from the repo's branches.</li>
<li><strong>New branch…</strong> — create a branch and switch to it.</li>
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li>