diff --git a/components/repo-menu/repo-menu.js b/components/repo-menu/repo-menu.js index 747d36c..8d468e1 100644 --- a/components/repo-menu/repo-menu.js +++ b/components/repo-menu/repo-menu.js @@ -4,7 +4,8 @@ // `repo:contextmenu` event from , 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 . 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 = `
Loading…
`; + 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 = `
No branches.
`; 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 = `
Couldn't load branches.
`; + } + } + 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 - ? '
' - : ``).join(''); + const rows = ITEMS.map((it) => { + if (it.sep) return '
'; + if (it.sub) { + return `
+ ${it.label} + +
`; + } + return ``; + }).join(''); + this.shadowRoot.innerHTML = ` `; + 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); }); } diff --git a/components/repo-menu/repo-menu.md b/components/repo-menu/repo-menu.md index 0839fd4..adac693 100644 --- a/components/repo-menu/repo-menu.md +++ b/components/repo-menu/repo-menu.md @@ -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 `` 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 `` (each op records a diff --git a/web/templates/help.html b/web/templates/help.html index 3f60225..8e21dfa 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -55,7 +55,7 @@
  • Publish — push your commits.
  • Check for updates — fetch without changing your files.
  • Save my work… — commit everything (asks for a message).
  • -
  • Switch branch… — move to another existing branch.
  • +
  • Switch branch ▸ — hover to pick from the repo's branches.
  • New branch… — create a branch and switch to it.
  • Set as active project / Ask Claude to switch here.
  • Copy path.