diff --git a/.qwen/e2e-tests/electron-desktop/branch-creation.md b/.qwen/e2e-tests/electron-desktop/branch-creation.md new file mode 100644 index 000000000..352194a1c --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/branch-creation.md @@ -0,0 +1,92 @@ +# Electron Desktop E2E: Safe Topbar Branch Creation + +Date: 2026-04-26 + +## Slice + +Safe Topbar Branch Creation. + +## 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 and assert the compact create form is present. +5. Assert empty branch creation is disabled. +6. Create `desktop-e2e/new-branch-from-menu`. +7. Assert the topbar branch label and real repository branch switch to the new + branch, while dirty status remains visible. +8. Reopen the menu, assert the new branch is marked current, then switch back + to `main` through the existing dirty-worktree confirmation path. +9. Continue the existing review, discard-cancel, commit, settings, terminal, + and terminal-attachment smoke paths. + +## Assertions + +- Branch creation stays inside the slim topbar branch menu. +- The create form, branch rows, and menu stay width-bounded. +- Empty branch creation is disabled before request submission. +- Server branch creation validates names and rejects duplicates/invalid refs in + focused tests. +- Successful creation runs through the token-protected desktop server route, + updates renderer state, refreshes diff/status, and switches the actual 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-47-05-265Z/ +``` + +Important artifacts: + +- `branch-create-menu.json` +- `branch-create-menu.png` +- `branch-create-validation.json` +- `branch-create-result.json` +- `branch-switch-menu.json` +- `branch-switch-result.json` +- `summary.json` +- `electron.log` + +Failed diagnostic artifact retained for the fixed harness readiness issue: + +```text +.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-38-07-376Z/ +``` + +Intermediate passing artifact before the stale-row product cleanup: + +```text +.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-39-03-398Z/ +``` + +## Known Uncovered Risk + +The real Electron path covers disabled empty submission and successful branch +creation. Duplicate and malformed branch names are covered by server tests but +not yet by an inline CDP error-state assertion. diff --git a/.qwen/e2e-tests/electron-desktop/branch-switching.md b/.qwen/e2e-tests/electron-desktop/branch-switching.md index 487f7cca3..95d4b40a9 100644 --- a/.qwen/e2e-tests/electron-desktop/branch-switching.md +++ b/.qwen/e2e-tests/electron-desktop/branch-switching.md @@ -70,5 +70,6 @@ Important artifacts: ## Known Uncovered Risk -This slice covers local branch list and checkout only. Branch creation, remote -branches, and checkout conflict copy remain future work. +This slice covers local branch list and checkout only. Remote branches and +checkout conflict copy remain future work. Branch creation is now covered by +`.qwen/e2e-tests/electron-desktop/branch-creation.md`. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 541494a14..a852d2f06 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,135 @@ execution order, verification, decisions, and remaining work. ## Codex Alignment Progress +### Completed Slice: Safe Topbar Branch Creation + +Status: completed in iteration 21. + +Goal: extend the compact topbar branch menu so users can create and switch to a +new local branch from the current project without leaving the conversation +workbench. + +User-visible value: users can stay in the default "open project -> ask agent -> +review changes" flow while preparing a clean branch for the task. The branch +control remains visible and compact in the first viewport, with validation and +dirty-worktree messaging handled in the menu. + +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/styles.css` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/branch-creation.md` +- `design/qwen-code-electron-desktop-implementation-plan.md` + +Acceptance criteria: + +- The branch menu includes a compact create-branch form beneath the local branch + list, without turning the topbar into a Git dashboard. +- Branch names are validated before Git runs: empty names, whitespace, + path-traversal-looking names, option-looking names, lock suffixes, invalid Git + ref names, and duplicate local branch names are rejected with clear messages. +- Creating a branch calls a token-protected desktop server route, creates and + switches to the branch, refreshes Git status and review diff, closes the menu, + and updates the topbar branch label. +- Dirty worktrees are called out in the menu; creation keeps local changes in + the worktree and relies on Git to reject conflicting state. +- Long branch names stay contained in the menu and topbar. + +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, create `desktop-e2e/new-branch-from-menu`, assert the topbar + and actual repo branch switch to the new branch, assert the dirty status is + preserved, reopen the menu and continue the existing dirty branch-switch path + back to `main`, then continue the existing review, settings, terminal, + discard safety, and commit workflows. +- E2E assertions: create form is bounded inside the menu; invalid empty branch + submission is disabled or rejected before a request; created branch appears in + Git and topbar; no menu row or create form overflows; no console errors or + failed local requests are recorded. +- Diagnostic artifacts: `branch-create-menu.json`, + `branch-create-validation.json`, `branch-create-result.json`, + `branch-create-menu.png`, Electron log, and summary JSON under + `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for prototype-constrained compact + branch-control design; `electron-desktop-dev` for server/renderer changes + verified through real Electron CDP. + +Notes and decisions: + +- The prototype keeps branch state as slim workbench chrome. Creation therefore + belongs as an inline branch-menu affordance rather than a persistent review or + Git management panel. +- This slice creates and switches local branches only. Remote tracking, + publishing, and branch deletion are out of scope for this iteration. +- Server-side validation rejects whitespace/control characters, option-looking + names, path-traversal-looking names, lock suffixes, duplicate local branch + names, and names that fail `git check-ref-format --branch` before running + `git switch -c`. +- The first CDP run exposed an async harness timing issue after branch creation: + the reopened menu briefly rendered the previous branch-list state before the + server reload marked the new branch current. The harness now waits for the + expected current row before snapshotting menu geometry. +- Self-review promoted that timing issue into a small product fix: branch rows + are cleared at the start of each branch-list load, so reopening the menu shows + loading state instead of stale branch rows. + +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. +- After replacing the control-character validation regex with an explicit + helper, `cd packages/desktop && SHELL=/bin/bash npx vitest run src/server/index.test.ts` + passed again. +- `cd packages/desktop && npm run typecheck` passed. +- `cd packages/desktop && npm run lint` first failed on ESLint + `no-control-regex`; after the helper cleanup, `cd packages/desktop && npm run lint` + passed. +- `cd packages/desktop && npm run build` passed. +- `cd packages/desktop && npm run e2e:cdp` first failed at + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-38-07-376Z/` + because the new branch-switch assertion sampled stale branch rows immediately + after reopening the menu. +- After the readiness wait, `cd packages/desktop && npm run e2e:cdp` passed + through real Electron with branch creation, dirty branch switching, review, + settings, terminal, discard safety, and commit workflows. +- After the stale-row product fix, `cd packages/desktop && npm run build` and + `cd packages/desktop && npm run e2e:cdp` passed again. +- Passing artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T02-47-05-265Z/`. +- Key recorded metrics: create menu width `320`, create form width `298`, no + escaped branch rows, empty create action disabled, created branch + `desktop-e2e/new-branch-from-menu` became the actual repo branch, topbar + branch text updated to that branch, dirty status stayed + `1 modified · 0 staged · 1 untracked`, and no console errors or failed local + requests were recorded. + +Next work: + +- Add a compact branch-create conflict/error path to the CDP harness by trying + a duplicate or invalid branch name in the real menu and asserting the inline + validation message stays contained. +- Continue prototype fidelity by reducing the remaining composer height and + changed-files summary weight visible in the latest screenshots. + ### Completed Slice: Safe Topbar Branch Switching Status: completed in iteration 20. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 2b4c8c627..3b9d33b4e 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -29,6 +29,7 @@ const defaultWindowBounds = { width: 1240, height: 820 }; const compactWindowBounds = { width: 960, height: 640 }; const longBranchName = 'desktop-e2e/very-long-branch-name-for-topbar-overflow-check'; +const createdBranchName = 'desktop-e2e/new-branch-from-menu'; const consoleErrors = []; const failedRequests = []; @@ -109,7 +110,16 @@ async function main() { await clickButton('Branch'); await waitForSelector('[data-testid="branch-menu"]'); await waitForSelector('[data-testid="branch-menu-row"]'); - await assertBranchSwitchMenu('branch-switch-menu.json'); + await assertBranchSwitchMenu('branch-create-menu.json', longBranchName); + await assertBranchCreateValidation('branch-create-validation.json'); + await saveScreenshot('branch-create-menu.png'); + await setFieldByAriaLabel('New branch name', createdBranchName); + await clickButton('Create Branch'); + await assertBranchCreateResult('branch-create-result.json'); + await clickButton('Branch'); + await waitForSelector('[data-testid="branch-menu"]'); + await waitForSelector('[data-testid="branch-menu-row"]'); + await assertBranchSwitchMenu('branch-switch-menu.json', createdBranchName); await saveScreenshot('branch-switch-menu.png'); await clickButton('Switch to branch main'); await waitForSelector('[data-testid="branch-switch-confirmation"]'); @@ -1028,7 +1038,24 @@ async function assertTopbarContextFidelity(fileName) { } } -async function assertBranchSwitchMenu(fileName) { +async function assertBranchSwitchMenu(fileName, expectedCurrentBranch) { + await waitFor( + `branch menu current row ${expectedCurrentBranch}`, + async () => + evaluate(`(() => { + const currentRow = [...document.querySelectorAll( + '[data-testid="branch-menu-row"]' + )].find((row) => row.getAttribute('aria-checked') === 'true'); + return Boolean( + currentRow && + currentRow.textContent.includes(${JSON.stringify( + expectedCurrentBranch, + )}) + ); + })()`), + 15_000, + ); + const snapshot = await evaluate(`(() => { const rectFor = (element) => { if (!element) { @@ -1045,9 +1072,12 @@ async function assertBranchSwitchMenu(fileName) { }; }; const menu = document.querySelector('[data-testid="branch-menu"]'); + const createForm = document.querySelector('[data-testid="branch-create-form"]'); 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 createButton = [...document.querySelectorAll('button')] + .find((button) => button.textContent.trim().includes('Create Branch')); const rowSnapshots = rows.map((row) => ({ label: row.getAttribute('aria-label') || '', checked: row.getAttribute('aria-checked'), @@ -1079,9 +1109,14 @@ async function assertBranchSwitchMenu(fileName) { hasLongBranch: rowSnapshots.some((row) => row.text.includes(${JSON.stringify(longBranchName)}) ), + hasCreatedBranch: rowSnapshots.some((row) => + row.text.includes(${JSON.stringify(createdBranchName)}) + ), hasMain: rowSnapshots.some((row) => row.text.includes('main')), currentRows: rowSnapshots.filter((row) => row.checked === 'true'), escapedRows: rowSnapshots.filter(rowEscapesMenu), + createForm: rectFor(createForm), + createButtonDisabled: createButton?.disabled ?? null, menuContained: Boolean( menuRect && menuRect.left >= 0 && @@ -1130,13 +1165,39 @@ async function assertBranchSwitchMenu(fileName) { ); } + if (snapshot.currentRows.length !== 1) { + throw new Error( + `Branch menu should mark one branch current: ${JSON.stringify( + snapshot.currentRows, + )}`, + ); + } + + if (!snapshot.currentRows[0].text.includes(expectedCurrentBranch)) { + throw new Error( + `Branch menu should mark ${expectedCurrentBranch} current: ${JSON.stringify( + snapshot.currentRows, + )}`, + ); + } + + if (!snapshot.createForm) { + throw new Error('Branch menu is missing the create-branch form.'); + } + + if (!snapshot.createButtonDisabled) { + throw new Error('Empty branch creation should be disabled.'); + } + if ( - snapshot.currentRows.length !== 1 || - !snapshot.currentRows[0].text.includes(longBranchName) + snapshot.createForm && + snapshot.menu && + (snapshot.createForm.left < snapshot.menu.left - 1 || + snapshot.createForm.right > snapshot.menu.right + 1) ) { throw new Error( - `Branch menu should mark the long branch current: ${JSON.stringify( - snapshot.currentRows, + `Branch create form escaped the menu: ${JSON.stringify( + snapshot.createForm, )}`, ); } @@ -1148,6 +1209,110 @@ async function assertBranchSwitchMenu(fileName) { } } +async function assertBranchCreateValidation(fileName) { + const snapshot = await evaluate(`(() => { + const form = document.querySelector('[data-testid="branch-create-form"]'); + const input = document.querySelector('[aria-label="New branch name"]'); + const button = [...document.querySelectorAll('button')] + .find((candidate) => candidate.textContent.trim().includes('Create Branch')); + return { + formText: form?.textContent.trim() ?? '', + inputValue: input?.value ?? null, + buttonDisabled: button?.disabled ?? null + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.inputValue !== '') { + throw new Error( + `New branch input should start empty: ${JSON.stringify(snapshot)}`, + ); + } + + if (snapshot.buttonDisabled !== true) { + throw new Error( + `Create Branch should be disabled while the branch name is empty: ${JSON.stringify( + snapshot, + )}`, + ); + } +} + +async function assertBranchCreateResult(fileName) { + await waitFor( + 'branch creation from menu', + 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(createdBranchName) && + !ui.menuOpen && + stdout.trim() === createdBranchName + ); + }, + 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() ?? + '' + }; + })()`), + 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 !== createdBranchName) { + throw new Error( + `Expected Git branch ${createdBranchName}, got ${snapshot.branch}`, + ); + } + + if (!snapshot.ui.gitStatusText.includes('1 modified')) { + throw new Error( + `Branch creation should preserve dirty status in the topbar: ${snapshot.ui.gitStatusText}`, + ); + } +} + async function assertBranchSwitchConfirmation(fileName) { const snapshot = await evaluate(`(() => { const confirmation = document.querySelector( diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 0fb9c390e..4d5a6f946 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -18,6 +18,7 @@ import { authenticateDesktop, checkoutDesktopProjectBranch, commitDesktopProjectChanges, + createDesktopProjectGitBranch, createDesktopSession, getDesktopProjectGitDiff, getDesktopProjectGitStatus, @@ -586,6 +587,28 @@ export function App() { [activeProject, applyReviewMutation, loadState], ); + const createProjectBranch = useCallback( + async (branchName: string): Promise => { + if (loadState.state !== 'ready' || !activeProject) { + return; + } + + try { + const result = await createDesktopProjectGitBranch( + 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) { @@ -1032,6 +1055,7 @@ export function App() { onRefreshProjectGitStatus={refreshProjectGitStatus} onListProjectBranches={listProjectBranches} onCheckoutProjectBranch={checkoutProjectBranch} + onCreateProjectBranch={createProjectBranch} 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 faf37d6c7..2501c8231 100644 --- a/packages/desktop/src/renderer/api/client.ts +++ b/packages/desktop/src/renderer/api/client.ts @@ -303,6 +303,20 @@ export async function checkoutDesktopProjectBranch( ); } +export async function createDesktopProjectGitBranch( + serverInfo: DesktopServerInfo, + projectId: string, + branchName: string, +): Promise { + return writeJson( + serverInfo, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + 'POST', + { branchName }, + isGitReviewMutation, + ); +} + export async function stageDesktopProjectChanges( serverInfo: DesktopServerInfo, projectId: string, diff --git a/packages/desktop/src/renderer/components/layout/TopBar.tsx b/packages/desktop/src/renderer/components/layout/TopBar.tsx index b853a2d82..07d087b18 100644 --- a/packages/desktop/src/renderer/components/layout/TopBar.tsx +++ b/packages/desktop/src/renderer/components/layout/TopBar.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState } from 'react'; +import { useState, type FormEvent } from 'react'; import type { DesktopGitBranch, DesktopProject } from '../../api/client.js'; import { formatGitStatus } from './formatters.js'; import { @@ -24,6 +24,7 @@ export function TopBar({ isReviewOpen, loadState, onCheckoutBranch, + onCreateBranch, onListBranches, onRefreshGitStatus, onShowReview, @@ -37,6 +38,7 @@ export function TopBar({ isReviewOpen: boolean; loadState: LoadState; onCheckoutBranch: (branchName: string) => Promise; + onCreateBranch: (branchName: string) => Promise; onListBranches: () => Promise; onRefreshGitStatus: () => void; onShowReview: () => void; @@ -86,6 +88,7 @@ export function TopBar({ canSwitchBranch={canSwitchBranch} isDirty={Boolean(activeProject && !activeProject.gitStatus.clean)} onCheckoutBranch={onCheckoutBranch} + onCreateBranch={onCreateBranch} onListBranches={onListBranches} /> Promise; + onCreateBranch: (branchName: string) => Promise; onListBranches: () => Promise; }) { const [isOpen, setIsOpen] = useState(false); const [branches, setBranches] = useState([]); + const [newBranchName, setNewBranchName] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [error, setError] = useState(null); const [pendingBranch, setPendingBranch] = useState(null); @@ -190,11 +197,13 @@ function BranchMenu({ const closeMenu = () => { setIsOpen(false); setPendingBranch(null); + setNewBranchName(''); setError(null); }; const loadBranches = async () => { setIsLoading(true); + setBranches([]); setError(null); try { setBranches(await onListBranches()); @@ -245,6 +254,25 @@ function BranchMenu({ } }; + const createBranch = async (event: FormEvent) => { + event.preventDefault(); + const branchName = newBranchName; + if (branchName.trim().length === 0 || isCreating || isSwitching) { + return; + } + + setIsCreating(true); + setError(null); + try { + await onCreateBranch(branchName); + closeMenu(); + } catch (createError) { + setError(getErrorMessage(createError)); + } finally { + setIsCreating(false); + } + }; + return ( + + + )} diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx index 1bfce98b1..283c910f1 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx @@ -286,6 +286,48 @@ describe('WorkspacePage', () => { ).toBeNull(); }); + it('creates branches from the compact topbar menu', async () => { + const branches: DesktopGitBranch[] = [ + { name: 'main', current: true }, + { name: 'feature/safe-switch', current: false }, + ]; + const onListProjectBranches = vi.fn(async () => branches); + const onCreateProjectBranch = vi.fn(async () => undefined); + const renderedContainer = renderWorkspace({ + onListProjectBranches, + onCreateProjectBranch, + }); + + await act(async () => { + clickButton(renderedContainer, 'Branch main'); + }); + + const createForm = renderedContainer.querySelector( + '[data-testid="branch-create-form"]', + ); + const input = createForm?.querySelector( + 'input[aria-label="New branch name"]', + ); + const createButton = createForm?.querySelector('button[type="submit"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + expect(createButton).toBeInstanceOf(HTMLButtonElement); + expect((createButton as HTMLButtonElement).disabled).toBe(true); + + await act(async () => { + setInputValue(input as HTMLInputElement, 'feature/new-task'); + }); + expect((createButton as HTMLButtonElement).disabled).toBe(false); + + await act(async () => { + (createButton as HTMLButtonElement).click(); + }); + + expect(onCreateProjectBranch).toHaveBeenCalledWith('feature/new-task'); + 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, @@ -726,6 +768,7 @@ function renderWorkspace( onCommitMessageChange: vi.fn(), onCopyMessage: vi.fn(), onCopyTerminalOutput: vi.fn(), + onCreateProjectBranch: vi.fn(async () => undefined), onCreateSession: vi.fn(), onKillTerminal: vi.fn(), onMessageTextChange: vi.fn(), @@ -795,6 +838,16 @@ function clickButton(container: HTMLElement, text: string): void { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } +function setInputValue(input: HTMLInputElement, value: string): void { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +} + const project: DesktopProject = { id: 'project-1', name: 'example-workspace', diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx index 5843ad7f9..49b42313a 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx @@ -61,6 +61,7 @@ export function WorkspacePage({ onCommitMessageChange, onCopyMessage, onCopyTerminalOutput, + onCreateProjectBranch, onCreateSession, onKillTerminal, onMessageTextChange, @@ -118,6 +119,7 @@ export function WorkspacePage({ onCommitMessageChange: (message: string) => void; onCopyMessage: (message: string) => void; onCopyTerminalOutput: () => void; + onCreateProjectBranch: (branchName: string) => Promise; onCreateSession: () => void; onKillTerminal: () => void; onMessageTextChange: (message: string) => void; @@ -198,6 +200,7 @@ export function WorkspacePage({ loadState={loadState} statusLabel={statusLabel} onCheckoutBranch={onCheckoutProjectBranch} + onCreateBranch={onCreateProjectBranch} onRefreshGitStatus={onRefreshProjectGitStatus} onListBranches={onListProjectBranches} onShowReview={toggleReview} diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 58936124a..2e1b6e90c 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -680,6 +680,59 @@ summary:focus-visible { color: #d8ebff; } +.branch-create-form { + display: grid; + min-width: 0; + padding-top: 8px; + border-top: 1px solid rgba(213, 224, 255, 0.09); +} + +.branch-create-label { + display: grid; + min-width: 0; + gap: 6px; + color: rgba(240, 242, 238, 0.82); + font-size: 11px; + font-weight: 800; + letter-spacing: 0; +} + +.branch-create-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + gap: 7px; +} + +.branch-create-input { + width: 100%; + min-width: 0; + height: 30px; + padding: 0 8px; + border: 1px solid rgba(213, 224, 255, 0.13); + border-radius: 6px; + outline: none; + background: rgba(238, 244, 239, 0.045); + color: rgba(238, 243, 255, 0.92); + font-size: 12px; + font-weight: 650; +} + +.branch-create-input::placeholder { + color: rgba(147, 160, 178, 0.72); +} + +.branch-create-input:focus { + border-color: rgba(99, 190, 255, 0.48); + box-shadow: 0 0 0 2px rgba(99, 190, 255, 0.12); +} + +.branch-create-row .secondary-button { + min-height: 30px; + padding: 0 9px; + font-size: 11.5px; +} + .branch-switch-confirmation { display: grid; gap: 8px; diff --git a/packages/desktop/src/server/index.test.ts b/packages/desktop/src/server/index.test.ts index a13546ec7..f533befa7 100644 --- a/packages/desktop/src/server/index.test.ts +++ b/packages/desktop/src/server/index.test.ts @@ -377,6 +377,108 @@ describe('DesktopServer', () => { }); }); + it('creates and switches to validated local branches', async () => { + const projectPath = await createCommittedGitProject(); + const storePath = join( + await createTempDirectory('qwen-desktop-store-'), + 'desktop-projects.json', + ); + 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 created = await postJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + { branchName: 'feature/desktop-branch-create' }, + ); + const listed = await getJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + { + Authorization: 'Bearer test-token', + }, + ); + const duplicate = await postJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + { branchName: 'feature/desktop-branch-create' }, + ); + + expect(created.status).toBe(200); + expect(created.body).toMatchObject({ + ok: true, + status: { + branch: 'feature/desktop-branch-create', + modified: 1, + }, + diff: { + files: [expect.objectContaining({ path: 'tracked.txt' })], + }, + }); + await expect( + runGitOutput(projectPath, ['branch', '--show-current']), + ).resolves.toBe('feature/desktop-branch-create'); + expect(listed.body).toMatchObject({ + ok: true, + current: 'feature/desktop-branch-create', + dirty: true, + branches: expect.arrayContaining([ + { name: 'feature/desktop-branch-create', current: true }, + ]), + }); + expect(duplicate.status).toBe(400); + expect(duplicate.body).toMatchObject({ + ok: false, + code: 'git_branch_exists', + }); + }); + + it('rejects invalid branch creation names before checkout', async () => { + const projectPath = await createCommittedGitProject(); + const initialBranch = await runGitOutput(projectPath, [ + 'branch', + '--show-current', + ]); + const storePath = join( + await createTempDirectory('qwen-desktop-store-'), + 'desktop-projects.json', + ); + + const server = await createTestServer(undefined, undefined, storePath); + const opened = await postJson(server, '/api/projects/open', { + path: projectPath, + }); + const projectId = getProjectId(opened.body); + + for (const branchName of [ + ' feature/leading-space', + 'feature/has space', + '../escape', + '-option-looking', + 'feature/name.lock', + ]) { + const rejected = await postJson( + server, + `/api/projects/${encodeURIComponent(projectId)}/git/branches`, + { branchName }, + ); + + expect(rejected.status).toBe(400); + expect(rejected.body).toMatchObject({ + ok: false, + code: 'git_branch_invalid', + }); + } + + await expect( + runGitOutput(projectPath, ['branch', '--show-current']), + ).resolves.toBe(initialBranch); + }); + 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 48105d6e9..98e8e7ff7 100644 --- a/packages/desktop/src/server/index.ts +++ b/packages/desktop/src/server/index.ts @@ -586,6 +586,22 @@ async function handleProjectGitBranchesRoute( return; } + if (request.method === 'POST') { + const body = await readObjectBody(request); + const branchName = getRequiredString(body, 'branchName'); + const status = await context.projectService.createProjectGitBranch( + 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); } diff --git a/packages/desktop/src/server/services/projectService.ts b/packages/desktop/src/server/services/projectService.ts index effb0e43a..44b44a368 100644 --- a/packages/desktop/src/server/services/projectService.ts +++ b/packages/desktop/src/server/services/projectService.ts @@ -134,6 +134,29 @@ export class DesktopProjectService { return readGitStatus(project.path); } + async createProjectGitBranch( + projectId: string, + branchName: string, + ): Promise { + const project = await this.getStoredProject(projectId); + const validatedBranchName = await validateNewBranchName( + project.path, + branchName, + ); + + try { + await runGit(project.path, ['switch', '-c', validatedBranchName]); + } catch (error) { + throw new DesktopHttpError( + 400, + 'git_error', + error instanceof Error ? error.message : 'Git branch creation failed.', + ); + } + + return readGitStatus(project.path); + } + async getProjectPath(projectId: string): Promise { const project = await this.getStoredProject(projectId); return project.path; @@ -273,6 +296,70 @@ async function readGitBranches( } } +async function validateNewBranchName( + projectPath: string, + branchName: string, +): Promise { + const trimmed = branchName.trim(); + if ( + trimmed.length === 0 || + trimmed !== branchName || + trimmed.length > 160 || + hasWhitespaceOrControl(trimmed) || + trimmed.startsWith('-') || + trimmed.startsWith('/') || + trimmed.endsWith('/') || + trimmed.includes('//') || + trimmed.includes('..') || + trimmed.includes('@{') || + trimmed.includes('\\') || + trimmed.endsWith('.lock') + ) { + throw new DesktopHttpError( + 400, + 'git_branch_invalid', + 'Branch name is not a valid local branch name.', + ); + } + + try { + await runGit(projectPath, ['check-ref-format', '--branch', trimmed]); + } catch { + throw new DesktopHttpError( + 400, + 'git_branch_invalid', + 'Branch name is not a valid local branch name.', + ); + } + + const branches = await readGitBranches(projectPath); + if (branches.some((branch) => branch.name === trimmed)) { + throw new DesktopHttpError( + 400, + 'git_branch_exists', + 'A local branch with that name already exists.', + ); + } + + return trimmed; +} + +function hasWhitespaceOrControl(value: string): boolean { + for (const char of value) { + const codePoint = char.codePointAt(0); + if ( + char.trim().length === 0 || + codePoint === undefined || + codePoint < 32 || + codePoint === 127 + ) { + return true; + } + } + + return false; +} + function runGit(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(