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 // `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 // 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). // 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>. // results show up live in <activity-feed>.
const ITEMS = [ const ITEMS = [
@@ -12,7 +13,7 @@ const ITEMS = [
{ cmd: 'push', label: 'Publish', hint: 'push' }, { cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' }, { 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' }, { cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
{ sep: true }, { sep: true },
{ cmd: 'active', label: 'Set as active project' }, { cmd: 'active', label: 'Set as active project' },
@@ -27,6 +28,7 @@ class RepoMenu extends HTMLElement {
#onContext = null; #onContext = null;
#onDocClick = null; #onDocClick = null;
#onKey = null; #onKey = null;
#branchController = null;
constructor() { constructor() {
super(); super();
@@ -41,6 +43,7 @@ class RepoMenu extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext); document.removeEventListener('repo:contextmenu', this.#onContext);
this.#branchController?.abort();
this.#teardownDismiss(); this.#teardownDismiss();
} }
@@ -50,25 +53,25 @@ class RepoMenu extends HTMLElement {
const menu = this.shadowRoot.getElementById('menu'); const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path); this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false; menu.hidden = false;
// Position, clamped to the viewport.
// Clamp to viewport; flip submenus leftward when near the right edge.
const rect = menu.getBoundingClientRect(); const rect = menu.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8); const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8); const top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(8, left) + 'px'; menu.style.left = Math.max(8, left) + 'px';
menu.style.top = Math.max(8, top) + '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.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); }; this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true }); document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey); document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0); }, 0);
} }
#hideBound() { return () => this.#hide(); }
#hide() { #hide() {
this.shadowRoot.getElementById('menu').hidden = true; this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss(); this.#teardownDismiss();
@@ -80,6 +83,30 @@ class RepoMenu extends HTMLElement {
this.#onDocClick = this.#onKey = null; 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) { async #dispatch(cmd) {
const repo = this.#repo; const repo = this.#repo;
const name = this.#base(repo.path); const name = this.#base(repo.path);
@@ -93,11 +120,6 @@ class RepoMenu extends HTMLElement {
if (msg && msg.trim()) await this.#git('commit', { message: msg }); if (msg && msg.trim()) await this.#git('commit', { message: msg });
break; 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': { case 'newbranch': {
const b = window.prompt(`New branch name in ${name}:`); const b = window.prompt(`New branch name in ${name}:`);
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() }); 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 = {}) { async #git(op, extra = {}) {
try { try {
const res = await fetch('/api/repo/git', { const res = await fetch('/api/repo/git', {
@@ -144,11 +171,19 @@ class RepoMenu extends HTMLElement {
} }
#renderShell() { #renderShell() {
const rows = ITEMS.map((it) => it.sep const rows = ITEMS.map((it) => {
? '<hr>' if (it.sep) return '<hr>';
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}"> if (it.sub) {
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''} return `<div class="item has-sub" tabindex="0">
</button>`).join(''); <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 = ` this.shadowRoot.innerHTML = `
<style> <style>
#menu { #menu {
@@ -160,25 +195,43 @@ class RepoMenu extends HTMLElement {
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px; .hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px; border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%; button, .item { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit; box-sizing: border-box; background: none; border: none; color: var(--color-fg);
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm); font: inherit; text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; } 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 code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; } button:hover code { color: #071019; }
button.danger { color: var(--color-danger); } button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; } 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; } 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> </style>
<div id="menu" hidden> <div id="menu" hidden>
<div class="hdr" id="hdr"></div> <div class="hdr" id="hdr"></div>
${rows} ${rows}
</div>`; </div>`;
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
const btn = e.target.closest('button'); 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 }` - **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y). (dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:** - **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Switch branch… / - Get latest / Publish / Check for updates / Save my work… / New branch… /
New branch… / Discard all changes… → `POST /api/repo/git {path, op, Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
message?, branch?}` (op: pull/push/fetch/commit/checkout/create-branch/ (op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
discard). "Save my work…" prompts for a message; "Switch branch…"/"New prompts for a message; "New branch…" prompts for a name; "Discard all
branch…" prompt for the branch; "Discard all changes…" confirms (destructive). 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`. - Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request). - Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard. - 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). 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), - 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
both prompting for the branch name (slice 9). 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 ## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a - 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>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</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>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>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>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li> <li><strong>Copy path</strong>.</li>