From 5b4d11aee4a1b53771baaea04839d4c4e984df01 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Sun, 26 Apr 2026 10:26:24 +0800 Subject: [PATCH] feat(desktop): add safe branch switching --- .../electron-desktop/branch-switching.md | 74 ++++++ ...de-electron-desktop-implementation-plan.md | 124 +++++++++ packages/desktop/scripts/e2e-cdp-smoke.mjs | 249 ++++++++++++++++++ packages/desktop/src/renderer/App.tsx | 41 +++ packages/desktop/src/renderer/api/client.ts | 63 +++++ .../components/layout/SidebarIcons.tsx | 24 ++ .../src/renderer/components/layout/TopBar.tsx | 211 ++++++++++++++- .../components/layout/WorkspacePage.test.tsx | 49 ++++ .../components/layout/WorkspacePage.tsx | 7 + packages/desktop/src/renderer/styles.css | 170 +++++++++++- packages/desktop/src/server/index.test.ts | 68 +++++ packages/desktop/src/server/index.ts | 77 ++++++ .../src/server/services/projectService.ts | 73 +++++ 13 files changed, 1221 insertions(+), 9 deletions(-) create mode 100644 .qwen/e2e-tests/electron-desktop/branch-switching.md diff --git a/.qwen/e2e-tests/electron-desktop/branch-switching.md b/.qwen/e2e-tests/electron-desktop/branch-switching.md new file mode 100644 index 000000000..487f7cca3 --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/branch-switching.md @@ -0,0 +1,74 @@ +# Electron Desktop E2E: Safe Topbar Branch Switching + +Date: 2026-04-26 + +## Slice + +Safe Topbar Branch Switching. + +## Executable Coverage + +- Harness: `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- Server/component tests: + `packages/desktop/src/server/index.test.ts` and + `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` + +## Scenario + +1. Launch real Electron with isolated HOME, runtime, user-data, and fake ACP. +2. Open the fake Git project on + `desktop-e2e/very-long-branch-name-for-topbar-overflow-check`. +3. Send a prompt and approve the fake command request. +4. Open the topbar branch menu. +5. Assert the current long branch and `main` are listed, the current branch is + marked, the menu and rows are width-bounded, and the worktree is marked + dirty. +6. Choose `main`, assert dirty-worktree confirmation, then confirm. +7. Assert the topbar branch label and actual repository branch update to + `main`, while dirty status remains visible. +8. Continue the existing review, discard-cancel, commit, settings, terminal, + and terminal-attachment smoke paths. + +## Assertions + +- Branch menu opens from the slim topbar branch control. +- Menu width stays compact and inside the viewport. +- Long branch rows truncate inside the menu; `escapedRows` must be empty. +- Dirty branch switching requires explicit confirmation. +- Confirmed checkout updates renderer state and the real Git branch. +- No unexpected console errors or failed local requests are recorded. + +## Commands + +```bash +cd packages/desktop && SHELL=/bin/bash npx vitest run src/server/index.test.ts src/renderer/components/layout/WorkspacePage.test.tsx +node --check packages/desktop/scripts/e2e-cdp-smoke.mjs +cd packages/desktop && npm run typecheck +cd packages/desktop && npm run lint +cd packages/desktop && npm run build +cd packages/desktop && npm run e2e:cdp +``` + +## Result + +Pass. + +Passing artifact directory: + +```text +.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-25-14-829Z/ +``` + +Important artifacts: + +- `branch-switch-menu.json` +- `branch-switch-menu.png` +- `branch-switch-confirmation.json` +- `branch-switch-result.json` +- `summary.json` +- `electron.log` + +## Known Uncovered Risk + +This slice covers local branch list and checkout only. Branch creation, remote +branches, and checkout conflict copy remain future work. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 368cac321..541494a14 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,130 @@ execution order, verification, decisions, and remaining work. ## Codex Alignment Progress +### Completed Slice: Safe Topbar Branch Switching + +Status: completed in iteration 20. + +Goal: turn the slim topbar branch context into a compact branch menu that lists +local branches, switches branches through the desktop server, and protects +dirty worktrees with an explicit confirmation before checkout. + +User-visible value: users can answer and change "which branch am I on?" from +the main workbench without leaving the conversation-first viewport, while +uncommitted changes are called out before a branch change. + +Expected files: + +- `packages/desktop/src/server/services/projectService.ts` +- `packages/desktop/src/server/index.ts` +- `packages/desktop/src/server/index.test.ts` +- `packages/desktop/src/renderer/api/client.ts` +- `packages/desktop/src/renderer/App.tsx` +- `packages/desktop/src/renderer/components/layout/TopBar.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` +- `packages/desktop/src/renderer/components/layout/SidebarIcons.tsx` +- `packages/desktop/src/renderer/styles.css` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/branch-switching.md` +- `design/qwen-code-electron-desktop-implementation-plan.md` + +Acceptance criteria: + +- The topbar branch context is a compact accessible control that opens a local + branch menu without reintroducing heavy pill styling. +- The menu lists local branches, marks the current branch, truncates long branch + names, and remains contained in the slim topbar area. +- Choosing a different branch while the project is dirty shows a confirmation + explaining that uncommitted changes will remain in the worktree unless Git + rejects the checkout. +- Confirming a switch calls the server checkout route, refreshes Git status and + review diff, closes the menu, and updates the topbar branch label. +- Server branch routes are token protected through the existing local-server + auth layer, list only local branch names, validate checkout targets against + that local list, and reject unknown branch names. + +Verification: + +- Unit/server test command: + `cd packages/desktop && SHELL=/bin/bash npx vitest run src/server/index.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` +- Syntax command: `node --check packages/desktop/scripts/e2e-cdp-smoke.mjs` +- Build/typecheck/lint commands: + `cd packages/desktop && npm run typecheck && npm run lint && npm run build` +- Real Electron harness: + `cd packages/desktop && npm run e2e:cdp` +- Harness path: `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- E2E scenario steps: launch real Electron with isolated HOME/runtime/user-data + and fake ACP, open the fake Git project on the deliberately long branch, + open the branch menu, assert the long branch and `main` are listed, choose + `main`, confirm the dirty-worktree branch switch, assert the topbar updates + to `main`, assert Git status and diff remain coherent, then continue the + existing prompt, approval, review, settings, terminal, discard safety, and + commit workflows. +- E2E assertions: branch menu geometry is bounded; the current branch is marked; + dirty confirmation appears before checkout; confirmed checkout updates the + topbar and actual repo branch; no console errors or failed local requests are + recorded. +- Diagnostic artifacts: `branch-switch-menu.json`, + `branch-switch-confirmation.json`, `branch-switch-result.json`, + `branch-switch-menu.png`, Electron log, and summary JSON under + `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for prototype-constrained compact + topbar menu design; `electron-desktop-dev` for server/preload/renderer changes + verified in the real Electron app; `brainstorming` applied by choosing the + smallest continuation from the prior topbar slice instead of expanding into + branch creation or full Git management. + +Notes and decisions: + +- This slice intentionally supports local branch list and checkout only. Branch + creation is left for a later workflow slice so the menu stays focused and the + server can validate checkout targets against known local branches. +- Dirty-state protection is a renderer confirmation before checkout. The server + still relies on Git to reject unsafe checkout conflicts and returns the + existing `git_error` response if Git cannot switch. +- The branch menu belongs in the topbar context row because `home.jpg` keeps + branch state as compact chrome, not as a large Git dashboard. +- The first real Electron run exposed a harness timing issue: the menu shell + opened before async branch rows loaded. The harness now waits for branch rows + before measuring menu geometry. +- The next passing artifact exposed a real visual issue: the long branch row + escaped the 320 px menu. The row CSS now forces width containment, and the CDP + harness records `escapedRows: []`. + +Verification results: + +- `node --check packages/desktop/scripts/e2e-cdp-smoke.mjs` passed. +- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/server/index.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` + passed. +- `cd packages/desktop && npm run typecheck` passed. +- `cd packages/desktop && npm run lint` passed. +- `cd packages/desktop && npm run build` passed. +- `cd packages/desktop && npm run e2e:cdp` first failed because the new branch + menu assertion ran before branch rows loaded, producing diagnostics at + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-17-48-367Z/`. +- After waiting for branch rows, `cd packages/desktop && npm run e2e:cdp` + passed but artifact review showed the long branch row escaped the menu. The + CSS and harness were tightened instead of accepting the visual drift. +- After rebuilding, `cd packages/desktop && npm run e2e:cdp` passed with the + safe branch-switch path and the existing prompt, approval, review, settings, + terminal, discard safety, and commit workflows. A final rebuild and CDP pass + after the branch-name parser cleanup and final renderer readiness guard also + passed. +- Passing artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-25-14-829Z/`. +- Key recorded metrics: branch menu width `320`, row widths `298`, no escaped + rows, current long branch marked, `main` listed as switch target, dirty + confirmation shown, actual repository branch switched to `main`, and dirty + status remained `1 modified · 0 staged · 1 untracked`. + +Next work: + +- Add branch creation from the topbar menu or command palette with the same + dirty-worktree protection and server-side branch-name validation. +- Continue prototype fidelity by reducing the remaining composer height and + density drift visible in the branch-switch screenshot. + ### Completed Slice: Slim Topbar Context Prototype Fidelity Status: completed in iteration 19. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index de16274b1..2b4c8c627 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -106,6 +106,16 @@ async function main() { await assertSidebarAppRail('sidebar-app-rail.json'); await assertTopbarContextFidelity('topbar-context-fidelity.json'); await saveScreenshot('topbar-context-fidelity.png'); + await clickButton('Branch'); + await waitForSelector('[data-testid="branch-menu"]'); + await waitForSelector('[data-testid="branch-menu-row"]'); + await assertBranchSwitchMenu('branch-switch-menu.json'); + await saveScreenshot('branch-switch-menu.png'); + await clickButton('Switch to branch main'); + await waitForSelector('[data-testid="branch-switch-confirmation"]'); + await assertBranchSwitchConfirmation('branch-switch-confirmation.json'); + await clickButton('Confirm Branch Switch'); + await assertBranchSwitchResult('branch-switch-result.json'); await clickButton('Review Changes'); await waitForText('README.md'); @@ -271,6 +281,7 @@ async function createGitWorkspace() { cwd: dir, }); await execFileP('git', ['config', 'user.name', 'Desktop E2E'], { cwd: dir }); + await execFileP('git', ['checkout', '-B', 'main'], { cwd: dir }); await execFileP('git', ['add', '.'], { cwd: dir }); await execFileP('git', ['commit', '-m', 'initial commit'], { cwd: dir }); await execFileP('git', ['checkout', '-b', longBranchName], { cwd: dir }); @@ -1017,6 +1028,244 @@ async function assertTopbarContextFidelity(fileName) { } } +async function assertBranchSwitchMenu(fileName) { + const snapshot = await evaluate(`(() => { + const rectFor = (element) => { + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height + }; + }; + const menu = document.querySelector('[data-testid="branch-menu"]'); + const trigger = document.querySelector('[data-testid="topbar-branch-trigger"]'); + const topbar = document.querySelector('[data-testid="workspace-topbar"]'); + const rows = [...document.querySelectorAll('[data-testid="branch-menu-row"]')]; + const rowSnapshots = rows.map((row) => ({ + label: row.getAttribute('aria-label') || '', + checked: row.getAttribute('aria-checked'), + disabled: row.disabled, + text: row.textContent.trim(), + rect: rectFor(row) + })); + const menuRect = rectFor(menu); + const rowEscapesMenu = (row) => + Boolean( + row.rect && + menuRect && + (row.rect.left < menuRect.left - 1 || + row.rect.right > menuRect.right + 1) + ); + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight + }, + document: { + bodyScrollWidth: document.body.scrollWidth + }, + triggerText: trigger?.textContent.trim() ?? '', + triggerExpanded: trigger?.getAttribute('aria-expanded'), + menu: menuRect, + topbar: rectFor(topbar), + rows: rowSnapshots, + hasLongBranch: rowSnapshots.some((row) => + row.text.includes(${JSON.stringify(longBranchName)}) + ), + hasMain: rowSnapshots.some((row) => row.text.includes('main')), + currentRows: rowSnapshots.filter((row) => row.checked === 'true'), + escapedRows: rowSnapshots.filter(rowEscapesMenu), + menuContained: Boolean( + menuRect && + menuRect.left >= 0 && + menuRect.right <= window.innerWidth && + menuRect.top >= 0 && + menuRect.bottom <= window.innerHeight + ) + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.triggerExpanded !== 'true') { + throw new Error('Branch trigger should be expanded while the menu is open.'); + } + + if (!snapshot.menu || snapshot.menu.width > 330) { + throw new Error( + `Branch menu should stay compact: ${JSON.stringify(snapshot.menu)}`, + ); + } + + if (!snapshot.menuContained) { + throw new Error( + `Branch menu escaped the viewport: ${JSON.stringify(snapshot.menu)}`, + ); + } + + if (!snapshot.hasLongBranch || !snapshot.hasMain) { + throw new Error( + `Branch menu did not list expected branches: ${JSON.stringify( + snapshot.rows, + )}`, + ); + } + + if (snapshot.escapedRows.length > 0) { + throw new Error( + `Branch menu rows escaped the menu: ${JSON.stringify( + snapshot.escapedRows, + )}`, + ); + } + + if ( + snapshot.currentRows.length !== 1 || + !snapshot.currentRows[0].text.includes(longBranchName) + ) { + throw new Error( + `Branch menu should mark the long branch current: ${JSON.stringify( + snapshot.currentRows, + )}`, + ); + } + + if (snapshot.document.bodyScrollWidth > snapshot.viewport.width + 4) { + throw new Error( + `Branch menu caused body overflow: ${JSON.stringify(snapshot.document)}`, + ); + } +} + +async function assertBranchSwitchConfirmation(fileName) { + const snapshot = await evaluate(`(() => { + const confirmation = document.querySelector( + '[data-testid="branch-switch-confirmation"]' + ); + const buttons = [...document.querySelectorAll( + '[data-testid="branch-menu"] button' + )].map((button) => ({ + label: button.getAttribute('aria-label') || button.textContent.trim(), + disabled: button.disabled + })); + return { + text: confirmation?.textContent.trim() ?? '', + buttons, + hasMenu: document.querySelector('[data-testid="branch-menu"]') !== null + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (!snapshot.hasMenu) { + throw new Error('Branch confirmation should remain inside the branch menu.'); + } + + if ( + !snapshot.text.includes('Switch branch with local changes?') || + !snapshot.text.includes('Uncommitted changes') + ) { + throw new Error( + `Branch dirty confirmation copy is missing: ${snapshot.text}`, + ); + } + + const buttonLabels = snapshot.buttons.map((button) => button.label); + for (const expected of ['Cancel Branch Switch', 'Confirm Branch Switch']) { + if (!buttonLabels.some((label) => label.includes(expected))) { + throw new Error( + `Branch confirmation missing ${expected}: ${buttonLabels.join(', ')}`, + ); + } + } +} + +async function assertBranchSwitchResult(fileName) { + await waitFor( + 'branch switch to main', + async () => { + const ui = await evaluate(`(() => { + const trigger = document.querySelector( + '[data-testid="topbar-branch-trigger"]' + ); + return { + branchText: trigger?.textContent.trim() ?? '', + menuOpen: document.querySelector('[data-testid="branch-menu"]') !== null, + gitStatusText: + document.querySelector('[aria-label^="Git status"]')?.textContent.trim() ?? + '' + }; + })()`); + const { stdout } = await execFileP('git', [ + '-C', + workspaceDir, + 'branch', + '--show-current', + ]); + return ( + ui.branchText.includes('main') && + !ui.menuOpen && + stdout.trim() === 'main' + ); + }, + 15_000, + ); + + const [ui, branch, status] = await Promise.all([ + evaluate(`(() => { + const trigger = document.querySelector('[data-testid="topbar-branch-trigger"]'); + return { + branchText: trigger?.textContent.trim() ?? '', + menuOpen: document.querySelector('[data-testid="branch-menu"]') !== null, + gitStatusText: + document.querySelector('[aria-label^="Git status"]')?.textContent.trim() ?? + '', + bodyHasLongBranch: document.body.innerText.includes( + ${JSON.stringify(longBranchName)} + ) + }; + })()`), + execFileP('git', ['-C', workspaceDir, 'branch', '--show-current']), + execFileP('git', ['-C', workspaceDir, 'status', '--porcelain=v1']), + ]); + const snapshot = { + ui, + branch: branch.stdout.trim(), + status: status.stdout, + }; + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.branch !== 'main') { + throw new Error(`Expected Git branch main, got ${snapshot.branch}`); + } + + if (!snapshot.ui.gitStatusText.includes('1 modified')) { + throw new Error( + `Branch switch should preserve dirty status in the topbar: ${snapshot.ui.gitStatusText}`, + ); + } +} + async function assertConversationChangesSummary(fileName) { await waitForSelector('[data-testid="conversation-changes-summary"]'); const snapshot = await evaluate(`(() => { diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 34d4dfe17..0fb9c390e 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -16,6 +16,7 @@ import { } from 'react'; import { authenticateDesktop, + checkoutDesktopProjectBranch, commitDesktopProjectChanges, createDesktopSession, getDesktopProjectGitDiff, @@ -25,6 +26,7 @@ import { getDesktopTerminal, getDesktopUserSettings, killDesktopTerminal, + listDesktopProjectGitBranches, listDesktopProjects, listDesktopSessions, loadDesktopSession, @@ -37,6 +39,7 @@ import { stageDesktopProjectChanges, updateDesktopUserSettings, writeDesktopTerminalInput, + type DesktopGitBranch, type DesktopGitDiff, type DesktopGitReviewTarget, type DesktopProject, @@ -547,6 +550,42 @@ export function App() { [activeProject], ); + const listProjectBranches = useCallback(async (): Promise< + DesktopGitBranch[] + > => { + if (loadState.state !== 'ready' || !activeProject) { + return []; + } + + const result = await listDesktopProjectGitBranches( + loadState.status.serverInfo, + activeProject.id, + ); + return result.branches; + }, [activeProject, loadState]); + + const checkoutProjectBranch = useCallback( + async (branchName: string): Promise => { + if (loadState.state !== 'ready' || !activeProject) { + return; + } + + try { + const result = await checkoutDesktopProjectBranch( + loadState.status.serverInfo, + activeProject.id, + branchName, + ); + applyReviewMutation(result.status, result.diff); + setSessionError(null); + } catch (error) { + setSessionError(getErrorMessage(error)); + throw error; + } + }, + [activeProject, applyReviewMutation, loadState], + ); + const stageReviewTarget = useCallback( async (target: DesktopGitReviewTarget) => { if (loadState.state !== 'ready' || !activeProject) { @@ -991,6 +1030,8 @@ export function App() { onOpenFileReference={openReviewFile} onPermissionResponse={respondToPermission} onRefreshProjectGitStatus={refreshProjectGitStatus} + onListProjectBranches={listProjectBranches} + onCheckoutProjectBranch={checkoutProjectBranch} onOpenReviewFile={openReviewFile} onRevertReviewTarget={revertReviewTarget} onRunTerminalCommand={runTerminalCommand} diff --git a/packages/desktop/src/renderer/api/client.ts b/packages/desktop/src/renderer/api/client.ts index 18e342812..faf37d6c7 100644 --- a/packages/desktop/src/renderer/api/client.ts +++ b/packages/desktop/src/renderer/api/client.ts @@ -50,6 +50,18 @@ export interface DesktopProjectList { projects: DesktopProject[]; } +export interface DesktopGitBranch { + name: string; + current: boolean; +} + +export interface DesktopGitBranchList { + ok: true; + branches: DesktopGitBranch[]; + current: string | null; + dirty: boolean; +} + export type DesktopGitChangeStatus = | 'added' | 'copied' @@ -255,6 +267,17 @@ export async function getDesktopProjectGitStatus( return response.status; } +export async function listDesktopProjectGitBranches( + serverInfo: DesktopServerInfo, + projectId: string, +): Promise { + return getJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + isGitBranchList, + ); +} + export async function getDesktopProjectGitDiff( serverInfo: DesktopServerInfo, projectId: string, @@ -266,6 +289,20 @@ export async function getDesktopProjectGitDiff( ); } +export async function checkoutDesktopProjectBranch( + serverInfo: DesktopServerInfo, + projectId: string, + branchName: string, +): Promise { + return writeJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/checkout`, + 'POST', + { branchName }, + isGitReviewMutation, + ); +} + export async function stageDesktopProjectChanges( serverInfo: DesktopServerInfo, projectId: string, @@ -849,6 +886,32 @@ function isGitStatus(value: unknown): value is DesktopGitStatus { ); } +function isGitBranch(value: unknown): value is DesktopGitBranch { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.name === 'string' && typeof candidate.current === 'boolean' + ); +} + +function isGitBranchList(value: unknown): value is DesktopGitBranchList { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return ( + candidate.ok === true && + Array.isArray(candidate.branches) && + candidate.branches.every(isGitBranch) && + (typeof candidate.current === 'string' || candidate.current === null) && + typeof candidate.dirty === 'boolean' + ); +} + function isGitChangedFile(value: unknown): value is DesktopGitChangedFile { if (!value || typeof value !== 'object') { return false; diff --git a/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx b/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx index b5273cd4d..1f01fc76c 100644 --- a/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx +++ b/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx @@ -176,6 +176,30 @@ export function DiffIcon(props: SidebarIconProps) { ); } +export function BranchIcon(props: SidebarIconProps) { + return ( + + ); +} + export function RefreshIcon(props: SidebarIconProps) { return ( Promise; + onListBranches: () => Promise; onRefreshGitStatus: () => void; onShowReview: () => void; onShowChat: () => void; @@ -46,6 +52,9 @@ export function TopBar({ : 'No project'; const changedCount = activeProject ? getChangedCount(activeProject) : 0; const reviewLabel = isReviewOpen ? 'Close Changes' : 'Open Changes'; + const canSwitchBranch = + loadState.state === 'ready' && + Boolean(activeProject?.gitStatus.isRepository); return ( {statusLabel} - - {branchLabel} - + Promise; + onListBranches: () => Promise; +}) { + const [isOpen, setIsOpen] = useState(false); + const [branches, setBranches] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [error, setError] = useState(null); + const [pendingBranch, setPendingBranch] = useState(null); + + const closeMenu = () => { + setIsOpen(false); + setPendingBranch(null); + setError(null); + }; + + const loadBranches = async () => { + setIsLoading(true); + setError(null); + try { + setBranches(await onListBranches()); + } catch (loadError) { + setError(getErrorMessage(loadError)); + } finally { + setIsLoading(false); + } + }; + + const toggleMenu = () => { + if (!canSwitchBranch) { + return; + } + + if (isOpen) { + closeMenu(); + return; + } + + setIsOpen(true); + void loadBranches(); + }; + + const requestCheckout = (branchName: string) => { + if (branchName === activeBranch || isSwitching) { + return; + } + + if (isDirty) { + setPendingBranch(branchName); + return; + } + + void checkoutBranch(branchName); + }; + + const checkoutBranch = async (branchName: string) => { + setIsSwitching(true); + setError(null); + try { + await onCheckoutBranch(branchName); + closeMenu(); + } catch (checkoutError) { + setError(getErrorMessage(checkoutError)); + } finally { + setIsSwitching(false); + } + }; + + return ( + + + + {isOpen ? ( +
+ {pendingBranch ? ( +
+ Switch branch with local changes? +

+ Uncommitted changes will stay in the worktree. Git will stop the + switch if they conflict. +

+
+ + +
+
+ ) : ( + <> +
+ Switch branch + {isDirty ? dirty worktree : clean} +
+ {isLoading ? ( +

Loading branches...

+ ) : null} + {!isLoading && branches.length === 0 ? ( +

No local branches found.

+ ) : null} +
+ {branches.map((branch) => ( + + ))} +
+ + )} + + {error ?

{error}

: null} +
+ ) : null} +
+ ); +} + function getTopBarTitle( activeView: 'chat' | 'settings', activeSessionTitle: string | null, @@ -171,3 +362,7 @@ function getChangedCount(project: DesktopProject): number { const status = project.gitStatus; return status.modified + status.staged + status.untracked; } + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Branch operation failed.'; +} diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx index c8917802c..1bfce98b1 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx @@ -11,6 +11,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { DesktopConnectionStatus, + DesktopGitBranch, DesktopGitDiff, DesktopProject, DesktopSessionSummary, @@ -97,6 +98,9 @@ describe('WorkspacePage', () => { expect( topbarContext?.querySelectorAll('.topbar-context-item'), ).toHaveLength(3); + expect( + renderedContainer.querySelector('[data-testid="topbar-branch-trigger"]'), + ).toBeTruthy(); expect(topbarContext?.textContent).toContain('Connected'); expect(topbarContext?.textContent).toContain('main'); expect(topbarContext?.textContent).toContain('1 modified'); @@ -239,6 +243,49 @@ describe('WorkspacePage', () => { expect(advancedDiagnostics?.textContent).toContain(session.sessionId); }); + it('confirms dirty branch switches from the topbar menu', async () => { + const branches: DesktopGitBranch[] = [ + { name: 'main', current: true }, + { name: 'feature/safe-switch', current: false }, + ]; + const onListProjectBranches = vi.fn(async () => branches); + const onCheckoutProjectBranch = vi.fn(async () => undefined); + const renderedContainer = renderWorkspace({ + onListProjectBranches, + onCheckoutProjectBranch, + }); + + await act(async () => { + clickButton(renderedContainer, 'Branch main'); + }); + + expect(onListProjectBranches).toHaveBeenCalledTimes(1); + expect( + renderedContainer.querySelector('[data-testid="branch-menu"]'), + ).toBeTruthy(); + expect(renderedContainer.textContent).toContain('feature/safe-switch'); + + await act(async () => { + clickButton(renderedContainer, 'Switch to branch feature/safe-switch'); + }); + + expect( + renderedContainer.querySelector( + '[data-testid="branch-switch-confirmation"]', + ), + ).toBeTruthy(); + expect(onCheckoutProjectBranch).not.toHaveBeenCalled(); + + await act(async () => { + clickButton(renderedContainer, 'Confirm Branch Switch'); + }); + + expect(onCheckoutProjectBranch).toHaveBeenCalledWith('feature/safe-switch'); + expect( + renderedContainer.querySelector('[data-testid="branch-menu"]'), + ).toBeNull(); + }); + it('keeps the composer enabled for an active project with no thread', () => { const renderedContainer = renderWorkspace({ activeSessionId: null, @@ -687,6 +734,8 @@ function renderWorkspace( onOpenFileReference: vi.fn(), onPermissionResponse: vi.fn(), onRefreshProjectGitStatus: vi.fn(), + onListProjectBranches: vi.fn(async () => []), + onCheckoutProjectBranch: vi.fn(async () => undefined), onOpenReviewFile: vi.fn(), onRevertReviewTarget: vi.fn(), onRunTerminalCommand: vi.fn(), diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx index 22e0ece15..5843ad7f9 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx @@ -6,6 +6,7 @@ import { useState, type Dispatch, type FormEvent } from 'react'; import type { + DesktopGitBranch, DesktopGitDiff, DesktopGitReviewTarget, DesktopProject, @@ -54,6 +55,7 @@ export function WorkspacePage({ onAskUserQuestionResponse, onAuthenticate, onChooseWorkspace, + onCheckoutProjectBranch, onClearTerminal, onCommit, onCommitMessageChange, @@ -67,6 +69,7 @@ export function WorkspacePage({ onOpenFileReference, onPermissionResponse, onRefreshProjectGitStatus, + onListProjectBranches, onOpenReviewFile, onRevertReviewTarget, onRunTerminalCommand, @@ -109,6 +112,7 @@ export function WorkspacePage({ onAskUserQuestionResponse: (requestId: string, optionId: string) => void; onAuthenticate: (methodId: string) => void; onChooseWorkspace: () => void; + onCheckoutProjectBranch: (branchName: string) => Promise; onClearTerminal: () => void; onCommit: () => void; onCommitMessageChange: (message: string) => void; @@ -122,6 +126,7 @@ export function WorkspacePage({ onOpenFileReference: (filePath: string) => void; onPermissionResponse: (requestId: string, optionId: string) => void; onRefreshProjectGitStatus: () => void; + onListProjectBranches: () => Promise; onOpenReviewFile: (filePath: string) => void; onRevertReviewTarget: (target: DesktopGitReviewTarget) => void; onRunTerminalCommand: () => void; @@ -192,7 +197,9 @@ export function WorkspacePage({ isReviewOpen={!isSettingsOpen && isReviewOpen} loadState={loadState} statusLabel={statusLabel} + onCheckoutBranch={onCheckoutProjectBranch} onRefreshGitStatus={onRefreshProjectGitStatus} + onListBranches={onListProjectBranches} onShowReview={toggleReview} onShowChat={showConversation} onShowSettings={showSettingsPage} diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 10c25f9c5..58936124a 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -496,7 +496,25 @@ summary:focus-visible { .topbar-context { justify-content: flex-start; min-width: 0; - overflow: hidden; + overflow: visible; +} + +.topbar-branch-control { + position: relative; + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 186px; + gap: 7px; +} + +.topbar-branch-control::before { + content: ''; + flex: 0 0 auto; + width: 3px; + height: 3px; + border-radius: 999px; + background: rgba(213, 224, 255, 0.28); } .topbar-context-item { @@ -513,6 +531,36 @@ summary:focus-visible { white-space: nowrap; } +.topbar-branch-trigger { + max-width: 170px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.topbar-branch-trigger:not(:disabled):hover { + color: rgba(231, 239, 255, 0.84); +} + +.topbar-branch-trigger svg { + flex: 0 0 auto; + width: 13px; + height: 13px; + opacity: 0.72; +} + +.topbar-context-caret { + flex: 0 0 auto; + width: 5px; + height: 5px; + margin-left: 1px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: translateY(-1px) rotate(45deg); + opacity: 0.72; +} + .topbar-context-item:not(:first-child)::before { content: ''; flex: 0 0 auto; @@ -548,6 +596,126 @@ summary:focus-visible { white-space: nowrap; } +.topbar-branch-menu { + position: absolute; + top: 26px; + left: 7px; + z-index: 30; + display: grid; + width: min(320px, calc(100vw - 340px)); + min-width: 260px; + max-height: min(430px, calc(100vh - 92px)); + gap: 8px; + overflow: auto; + padding: 10px; + border: 1px solid rgba(213, 224, 255, 0.13); + border-radius: 8px; + background: rgba(13, 17, 28, 0.98); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34); +} + +.branch-menu-header, +.branch-menu-row, +.branch-menu-actions { + display: flex; + align-items: center; +} + +.branch-menu-header { + justify-content: space-between; + gap: 12px; + color: rgba(240, 242, 238, 0.9); + font-size: 11px; + font-weight: 820; + letter-spacing: 0; + text-transform: uppercase; +} + +.branch-menu-header em, +.branch-menu-row em { + flex: 0 0 auto; + color: rgba(147, 160, 178, 0.88); + font-size: 10.5px; + font-style: normal; + font-weight: 760; + text-transform: none; +} + +.branch-menu-list { + display: grid; + min-width: 0; + gap: 2px; +} + +.branch-menu-row { + justify-content: space-between; + width: 100%; + min-width: 0; + min-height: 30px; + gap: 10px; + overflow: hidden; + padding: 0 8px; + border-radius: 6px; + background: transparent; + color: rgba(226, 234, 246, 0.78); + font-size: 12px; + font-weight: 720; + text-align: left; +} + +.branch-menu-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.branch-menu-row:not(:disabled):hover { + background: rgba(85, 166, 255, 0.1); + color: #d8ebff; +} + +.branch-menu-row-current { + background: rgba(85, 166, 255, 0.08); + color: #d8ebff; +} + +.branch-switch-confirmation { + display: grid; + gap: 8px; +} + +.branch-switch-confirmation strong { + color: rgba(246, 248, 255, 0.94); + font-size: 12.5px; + line-height: 1.25; +} + +.branch-switch-confirmation p, +.branch-menu-status, +.branch-menu-error { + margin: 0; + color: rgba(194, 202, 215, 0.76); + font-size: 12px; + line-height: 1.45; +} + +.branch-menu-error { + color: #ffb3a5; +} + +.branch-menu-actions { + justify-content: flex-end; + gap: 7px; +} + +.branch-menu-actions .primary-button, +.branch-menu-actions .secondary-button { + min-height: 30px; + padding: 0 10px; + font-size: 11.5px; +} + .topbar-actions { flex-wrap: nowrap; justify-content: flex-end; diff --git a/packages/desktop/src/server/index.test.ts b/packages/desktop/src/server/index.test.ts index 254a1b6fc..a13546ec7 100644 --- a/packages/desktop/src/server/index.test.ts +++ b/packages/desktop/src/server/index.test.ts @@ -309,6 +309,74 @@ describe('DesktopServer', () => { ).resolves.toBe('test commit'); }); + it('lists local branches and checks out a validated branch', async () => { + const projectPath = await createCommittedGitProject(); + const initialBranch = await runGitOutput(projectPath, [ + 'branch', + '--show-current', + ]); + const featureBranch = 'feature/desktop-branch-switch'; + const storePath = join( + await createTempDirectory('qwen-desktop-store-'), + 'desktop-projects.json', + ); + await runGit(projectPath, ['checkout', '-b', featureBranch]); + await writeFile(join(projectPath, 'tracked.txt'), 'dirty\n', 'utf8'); + + const server = await createTestServer(undefined, undefined, storePath); + const opened = await postJson(server, '/api/projects/open', { + path: projectPath, + }); + const projectId = getProjectId(opened.body); + const branches = await getJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + { + Authorization: 'Bearer test-token', + }, + ); + const switched = await postJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/checkout`, + { branchName: initialBranch }, + ); + const rejected = await postJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/checkout`, + { branchName: 'missing/local-branch' }, + ); + + expect(branches.status).toBe(200); + expect(branches.body).toMatchObject({ + ok: true, + current: featureBranch, + dirty: true, + branches: [ + { name: featureBranch, current: true }, + { name: initialBranch, current: false }, + ], + }); + expect(switched.status).toBe(200); + expect(switched.body).toMatchObject({ + ok: true, + status: { + branch: initialBranch, + modified: 1, + }, + diff: { + files: [expect.objectContaining({ path: 'tracked.txt' })], + }, + }); + await expect( + runGitOutput(projectPath, ['branch', '--show-current']), + ).resolves.toBe(initialBranch); + expect(rejected.status).toBe(400); + expect(rejected.body).toMatchObject({ + ok: false, + code: 'git_branch_not_found', + }); + }); + it('returns hunk metadata and can stage or revert individual hunks', async () => { const projectPath = await createMultiHunkGitProject(); const storePath = join( diff --git a/packages/desktop/src/server/index.ts b/packages/desktop/src/server/index.ts index 03946161e..48105d6e9 100644 --- a/packages/desktop/src/server/index.ts +++ b/packages/desktop/src/server/index.ts @@ -238,6 +238,36 @@ async function handleRequest( return; } + const projectGitBranchesMatch = matchSessionRoute( + requestUrl.pathname, + /^\/api\/projects\/([^/]+)\/git\/branches$/u, + ); + if (projectGitBranchesMatch) { + await handleProjectGitBranchesRoute( + request, + response, + origin, + context, + projectGitBranchesMatch, + ); + return; + } + + const projectGitCheckoutMatch = matchSessionRoute( + requestUrl.pathname, + /^\/api\/projects\/([^/]+)\/git\/checkout$/u, + ); + if (projectGitCheckoutMatch) { + await handleProjectGitCheckoutRoute( + request, + response, + origin, + context, + projectGitCheckoutMatch, + ); + return; + } + const projectGitDiffMatch = matchSessionRoute( requestUrl.pathname, /^\/api\/projects\/([^/]+)\/git\/diff$/u, @@ -538,6 +568,53 @@ async function handleProjectGitDiffRoute( sendMethodNotAllowed(response, origin); } +async function handleProjectGitBranchesRoute( + request: IncomingMessage, + response: ServerResponse, + origin: string | undefined, + context: HandlerContext, + projectId: string, +): Promise { + if (request.method === 'GET') { + const status = await context.projectService.getProjectGitStatus(projectId); + sendJson(response, origin, 200, { + ok: true, + branches: await context.projectService.listProjectGitBranches(projectId), + current: status.branch, + dirty: !status.clean, + }); + return; + } + + sendMethodNotAllowed(response, origin); +} + +async function handleProjectGitCheckoutRoute( + request: IncomingMessage, + response: ServerResponse, + origin: string | undefined, + context: HandlerContext, + projectId: string, +): Promise { + if (request.method === 'POST') { + const body = await readObjectBody(request); + const branchName = getRequiredString(body, 'branchName'); + const status = await context.projectService.checkoutProjectGitBranch( + projectId, + branchName, + ); + const projectPath = await context.projectService.getProjectPath(projectId); + sendJson(response, origin, 200, { + ok: true, + status, + diff: await context.gitReviewService.getDiff(projectPath), + }); + return; + } + + sendMethodNotAllowed(response, origin); +} + async function handleProjectGitStageRoute( request: IncomingMessage, response: ServerResponse, diff --git a/packages/desktop/src/server/services/projectService.ts b/packages/desktop/src/server/services/projectService.ts index 589055866..effb0e43a 100644 --- a/packages/desktop/src/server/services/projectService.ts +++ b/packages/desktop/src/server/services/projectService.ts @@ -35,6 +35,11 @@ export interface DesktopProject { lastOpenedAt: number; } +export interface DesktopGitBranch { + name: string; + current: boolean; +} + interface StoredProject { id: string; name: string; @@ -97,6 +102,38 @@ export class DesktopProjectService { return readGitStatus(project.path); } + async listProjectGitBranches(projectId: string): Promise { + const project = await this.getStoredProject(projectId); + return readGitBranches(project.path); + } + + async checkoutProjectGitBranch( + projectId: string, + branchName: string, + ): Promise { + const project = await this.getStoredProject(projectId); + const branches = await readGitBranches(project.path); + if (!branches.some((branch) => branch.name === branchName)) { + throw new DesktopHttpError( + 400, + 'git_branch_not_found', + 'Branch is not a local branch in this project.', + ); + } + + try { + await runGit(project.path, ['switch', branchName]); + } catch (error) { + throw new DesktopHttpError( + 400, + 'git_error', + error instanceof Error ? error.message : 'Git checkout failed.', + ); + } + + return readGitStatus(project.path); + } + async getProjectPath(projectId: string): Promise { const project = await this.getStoredProject(projectId); return project.path; @@ -200,6 +237,42 @@ async function readGitStatus(projectPath: string): Promise { } } +async function readGitBranches( + projectPath: string, +): Promise { + try { + const [status, stdout] = await Promise.all([ + readGitStatus(projectPath), + runGit(projectPath, ['branch', '--format=%(refname:short)']), + ]); + const branches = stdout + .split(/\r?\n/u) + .filter((branch) => branch.length > 0) + .map((name) => ({ + name, + current: name === status.branch, + })); + + return branches.sort((left, right) => { + if (left.current) { + return -1; + } + + if (right.current) { + return 1; + } + + return left.name.localeCompare(right.name); + }); + } catch (error) { + throw new DesktopHttpError( + 400, + 'git_error', + error instanceof Error ? error.message : 'Git branch list failed.', + ); + } +} + function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(