From 854bc9f5051390334d6dd99bf46aec3bdf6d0535 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sun, 5 Jul 2026 20:53:03 +0100 Subject: [PATCH 1/2] feat(git): group push menu by branch target --- e2e/file-explorer-sidebar.spec.ts | 15 ++- electron/fileViewer/gitDiffService.ts | 37 +++++++ src/App.css | 10 ++ src/App.tsx | 145 +++++++++++++++++++------- src/components/ContextMenu.tsx | 11 +- src/components/QuickPushModal.tsx | 4 +- src/terminalSettings.ts | 3 +- src/types/terminay.ts | 1 + 8 files changed, 184 insertions(+), 42 deletions(-) diff --git a/e2e/file-explorer-sidebar.spec.ts b/e2e/file-explorer-sidebar.spec.ts index ce953f6..09d7c27 100644 --- a/e2e/file-explorer-sidebar.spec.ts +++ b/e2e/file-explorer-sidebar.spec.ts @@ -351,6 +351,11 @@ test('git sidebar pane renders a nested tree and offers a push menu', async ({ await execFileAsync('git', ['config', 'user.email', 'terminay@example.com'], { cwd: workspace.rootDir }) await execFileAsync('git', ['add', '.'], { cwd: workspace.rootDir }) await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: workspace.rootDir }) + const defaultBranch = ( + await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspace.rootDir }) + ).stdout.trim() + const featureBranch = 'feature/push-menu' + await execFileAsync('git', ['checkout', '-b', featureBranch], { cwd: workspace.rootDir }) await workspace.writeText('src/lib/util.ts', 'export const x = 2\n') await workspace.writeText('src/lib/new.ts', 'export const y = 3\n') @@ -390,10 +395,16 @@ test('git sidebar pane renders a nested tree and offers a push menu', async ({ await pushButton.click() const pushMenu = mainWindow.locator('.context-menu') + const pushMenuHeadings = pushMenu.locator('.context-menu__heading') + await expect(pushMenuHeadings.getByText(`Current Branch (${featureBranch})`, { exact: true })).toBeVisible() await expect(pushMenu.getByText('Push to current branch', { exact: true })).toBeVisible() - await expect(pushMenu.getByText('Push to current branch + PR')).toBeVisible() + await expect(pushMenu.getByText('Push to current branch + create PR')).toBeVisible() + await expect(pushMenuHeadings.getByText('New Branch', { exact: true })).toBeVisible() await expect(pushMenu.getByText('Push to new branch', { exact: true })).toBeVisible() - await expect(pushMenu.getByText('Push to new branch + PR')).toBeVisible() + await expect(pushMenu.getByText('Push to new branch + create PR')).toBeVisible() + await expect(pushMenuHeadings.getByText(`Default Branch (${defaultBranch})`, { exact: true })).toBeVisible() + await expect(pushMenu.getByText('Push to default branch')).toBeVisible() + await expect(pushMenu.locator('.context-menu__trailing')).toHaveCount(4) await mainWindow.keyboard.press('Escape') await expect(pushMenu).toHaveCount(0) diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index 6187889..c571cf3 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -152,6 +152,7 @@ export class GitDiffService { return { gitAvailable: false, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -159,6 +160,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -167,6 +169,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -176,6 +179,7 @@ export class GitDiffService { }) const worktrees = parseWorktreeList(stdout, repoRoot) + const defaultBranch = await this.resolveDefaultBranch(workingDirectory) const withEntries = await Promise.all( worktrees.map(async (worktree): Promise => { @@ -234,6 +238,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot, + defaultBranch, worktrees: withEntries, } } @@ -278,6 +283,38 @@ export class GitDiffService { } } + private async resolveDefaultBranch(cwd: string): Promise { + try { + const result = await execFileAsync('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { + cwd, + }) + const remoteHead = result.stdout.trim() + if (remoteHead.startsWith('origin/')) { + return remoteHead.slice('origin/'.length) + } + if (remoteHead) { + return remoteHead + } + } catch {} + + for (const branch of ['main', 'master']) { + try { + await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd }) + return branch + } catch {} + } + + try { + const result = await execFileAsync('git', ['branch', '--format=%(refname:short)'], { cwd }) + return result.stdout + .split(/\r?\n/) + .map((branch) => branch.trim()) + .find((branch) => branch.length > 0) ?? null + } catch { + return null + } + } + private async getAheadOfMainCount(cwd: string): Promise { try { const result = await execFileAsync('git', ['rev-list', '--count', 'main..HEAD'], { cwd }) diff --git a/src/App.css b/src/App.css index 71655ba..d27e91e 100644 --- a/src/App.css +++ b/src/App.css @@ -2855,6 +2855,16 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); } +.context-menu__heading { + padding: 8px 10px 4px; + color: #8d96a6; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-transform: uppercase; + white-space: nowrap; +} + .context-menu__item { all: unset; display: flex; diff --git a/src/App.tsx b/src/App.tsx index 52e91c4..9647ee1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -105,6 +105,7 @@ import type { GitChangeEntry, GitWorktreeStatus, RemoteAccessStatus, + QuickPushAction, TerminalRecordingStartMetadata, TerminalRecordingState, WorktreePanelStatus, @@ -312,51 +313,138 @@ function formatDictationTranscriptForTerminal(text: string): string { return /[\r\n]/.test(text) ? formatBracketedPaste(text) : text; } -type GitPushAgentAction = 'current' | 'current-pr' | 'new' | 'new-pr'; +type GitPushAgentAction = QuickPushAction | 'default'; +type GitPushAgentActionGroup = 'current' | 'new' | 'default'; const GIT_PUSH_AGENT_ACTIONS: Array<{ action: GitPushAgentAction; + group: GitPushAgentActionGroup; label: string; task: string; + quickPush?: boolean; }> = [ { action: 'current', + group: 'current', label: 'Push to current branch', task: 'Commit all of my current changes and push them to the current branch.', + quickPush: true, }, { action: 'current-pr', - label: 'Push to current branch + PR', + group: 'current', + label: 'Push to current branch + create PR', task: 'Commit all of my current changes, push them to the current branch, and open a pull request.', + quickPush: true, }, { action: 'new', + group: 'new', label: 'Push to new branch', task: 'Commit all of my current changes onto a new, descriptively named branch and push it.', + quickPush: true, }, { action: 'new-pr', - label: 'Push to new branch + PR', + group: 'new', + label: 'Push to new branch + create PR', task: 'Commit all of my current changes onto a new, descriptively named branch, push it, and open a pull request.', + quickPush: true, + }, + { + action: 'default', + group: 'default', + label: 'Push to default branch', + task: 'Commit all of my current changes onto the default branch and push it.', }, ]; type GitPushMenuTarget = { branch: string | null | undefined; cwd: string; + defaultBranch?: string | null; worktreePath?: string; }; +function formatGitPushBranchLabel(branch: string | null | undefined): string { + return branch?.trim() || 'unknown'; +} + +function getGitPushActionIcon(action: GitPushAgentAction): ReactNode { + if (action === 'new') { + return