From 8cbb92842e941ba90c68fee0a4f7464471e0dd04 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:14:28 -0700 Subject: [PATCH] feat: add external review link to source control branch panel Add a button to the branch compare header that opens the review or comparison page directly in the browser. Supports generating comparison and PR/MR creation URLs for GitHub, GitLab, Bitbucket, Azure DevOps, and Gitea. --- .../right-sidebar/SourceControl.tsx | 37 ++ ...source-control-branch-context-row.test.tsx | 14 + .../source-control-branch-context-row.tsx | 29 +- .../source-control-header-toolbar.tsx | 5 +- .../source-control-manual-review-url.test.ts | 173 +++++++++ .../source-control-manual-review-url.ts | 338 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 11 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 59a44672999..4c696178a18 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -278,6 +278,7 @@ import { resolveHostedReviewActionUpstreamStatus, resolveHostedReviewStateForActions } from './source-control-hosted-review-push-target' +import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url' export { HostedReviewHeaderLink } from './hosted-review-header-chrome' import { createRunningCommitMessageGenerationRecord, @@ -1467,6 +1468,41 @@ function SourceControlInner(): React.JSX.Element { const linkedBitbucketPR = activeWorktree?.linkedBitbucketPR ?? null const linkedAzureDevOpsPR = activeWorktree?.linkedAzureDevOpsPR ?? null const linkedGiteaPR = activeWorktree?.linkedGiteaPR ?? null + const manualReviewProvider: HostedReviewProvider | null = + hostedReview?.provider ?? + hostedReviewCreation?.provider ?? + (linkedGitLabMR !== null + ? 'gitlab' + : linkedBitbucketPR !== null + ? 'bitbucket' + : linkedAzureDevOpsPR !== null + ? 'azure-devops' + : linkedGiteaPR !== null + ? 'gitea' + : linkedGitHubPR !== null || fallbackGitHubPRNumber !== null + ? 'github' + : null) + const manualReviewUrl = useMemo( + () => + buildSourceControlManualReviewUrl({ + baseRef: compareBaseRef, + branchName, + repoRemoteName: activeRepo?.gitRemoteIdentity?.remoteName ?? null, + repoRemoteUrl: activeRepo?.gitRemoteIdentity?.remoteUrl ?? null, + provider: manualReviewProvider, + pushTarget: activeWorktree?.pushTarget ?? null, + upstreamName: remoteStatus?.upstreamName ?? null + }), + [ + activeRepo?.gitRemoteIdentity?.remoteName, + activeRepo?.gitRemoteIdentity?.remoteUrl, + activeWorktree?.pushTarget, + branchName, + compareBaseRef, + manualReviewProvider, + remoteStatus?.upstreamName + ] + ) const shouldResolveHostedReviewCreation = isBranchVisible && Boolean(activeRepo) && @@ -5415,6 +5451,7 @@ function SourceControlInner(): React.JSX.Element { branchSummary={branchSummary} compareBaseRef={compareBaseRef} upstreamStatus={remoteStatus} + manualReviewUrl={manualReviewUrl} /> {detachedHeadDisplay && ( diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index c9eb2fad92d..8deb9aeae0f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -37,4 +37,18 @@ describe('SourceControlBranchContextRow', () => { expect(markup).toContain('min-w-0 flex-1') expect(markup).not.toContain('max-w-[9rem]') }) + + it('renders a compact external review link when a manual URL is available', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-label="Open review page in browser"') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx index 27d1183d5ac..8cd5f078e04 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Loader2, RefreshCw } from 'lucide-react' +import { ExternalLink, Loader2, RefreshCw } from 'lucide-react' import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -60,16 +60,40 @@ function ContextStat({ ) } +function ManualReviewLinkButton({ + url +}: { + url: string | null | undefined +}): React.JSX.Element | null { + if (!url) { + return null + } + return ( + { + void window.api.shell.openUrl(url) + }} + /> + ) +} + export function SourceControlBranchContextRow({ summary, compareBaseRef, upstreamStatus, + manualReviewUrl, onChangeBaseRef, onRetry }: { summary: GitBranchCompareSummary | null compareBaseRef: string | null upstreamStatus?: GitUpstreamStatus + manualReviewUrl?: string | null onChangeBaseRef: () => void onRetry: () => void }): React.JSX.Element | null { @@ -97,6 +121,7 @@ export function SourceControlBranchContextRow({ title={changeBaseTitle} /> + ) } @@ -118,6 +143,7 @@ export function SourceControlBranchContextRow({ 'Branch compare unavailable' )} + ) : null} + ) } diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index f66de4f47c3..3cac51e1e9f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -40,6 +40,7 @@ type SourceControlHeaderToolbarProps = { branchSummary: GitBranchCompareSummary | null compareBaseRef: string | null upstreamStatus?: GitUpstreamStatus + manualReviewUrl?: string | null } function HostedReviewToolbarLink({ @@ -143,7 +144,8 @@ export function SourceControlHeaderToolbar({ onExpandNotes, branchSummary, compareBaseRef, - upstreamStatus + upstreamStatus, + manualReviewUrl }: SourceControlHeaderToolbarProps): React.JSX.Element { const filterInputRef = useRef(null) const normalizedFilter = filterQuery.trim() @@ -288,6 +290,7 @@ export function SourceControlHeaderToolbar({ summary={branchSummary} compareBaseRef={compareBaseRef} upstreamStatus={upstreamStatus} + manualReviewUrl={manualReviewUrl} onChangeBaseRef={onChangeBaseRef} onRetry={onRefreshBranchCompare} /> diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts new file mode 100644 index 00000000000..532f6867047 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url' + +describe('buildSourceControlManualReviewUrl', () => { + it('builds a GitHub compare URL for the current remote branch', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'native-chat-does-not-auto-open', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@github.com:stablyai/orca.git', + upstreamName: 'origin/native-chat-does-not-auto-open' + }) + ).toBe( + 'https://github.com/stablyai/orca/compare/main...native-chat-does-not-auto-open?expand=1' + ) + }) + + it('qualifies GitHub fork heads when the push target remote differs from the base repo', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/upstream/main', + branchName: 'feature/fork-head', + repoRemoteName: 'upstream', + repoRemoteUrl: 'https://github.com/stablyai/orca.git', + pushTarget: { + remoteName: 'fork', + branchName: 'feature/fork-head', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + ).toBe('https://github.com/stablyai/orca/compare/main...contributor:feature/fork-head?expand=1') + }) + + it('keeps slashes literal in a GitHub compare URL for a slash-containing branch name', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'slashdevcorpse/identifying-pwsh.exe-error', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@github.com:stablyai/orca.git', + upstreamName: 'origin/slashdevcorpse/identifying-pwsh.exe-error' + }) + ).toBe( + 'https://github.com/stablyai/orca/compare/main...slashdevcorpse/identifying-pwsh.exe-error?expand=1' + ) + }) + + it('builds a GitLab merge request URL for a self-hosted GitLab remote', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/release/next', + branchName: 'feature/gitlab', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@gitlab.company.test:group/sub/orca.git', + provider: 'gitlab', + upstreamName: 'origin/feature/gitlab' + }) + ).toBe( + 'https://gitlab.company.test/group/sub/orca/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fgitlab&merge_request%5Btarget_branch%5D=release%2Fnext' + ) + }) + + it('builds a Bitbucket manual pull request URL', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'origin/main', + branchName: 'feature/bitbucket', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@bitbucket.org:team/project.git', + upstreamName: 'origin/feature/bitbucket' + }) + ).toBe( + 'https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fbitbucket&dest=main' + ) + }) + + it('builds an Azure DevOps pull request creation URL', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'feature/azure', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@ssh.dev.azure.com:v3/acme/widgets/widget-app.git', + upstreamName: 'origin/feature/azure' + }) + ).toBe( + 'https://dev.azure.com/acme/widgets/_git/widget-app/pullrequestcreate?sourceRef=refs%2Fheads%2Ffeature%2Fazure&targetRef=refs%2Fheads%2Fmain' + ) + }) + + it('builds a Gitea compare URL when the provider is known', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'feature/gitea', + repoRemoteName: 'origin', + repoRemoteUrl: 'ssh://git@gitea.company.test/team/orca.git', + provider: 'gitea', + upstreamName: 'origin/feature/gitea' + }) + ).toBe('https://gitea.company.test/team/orca/compare/main...feature/gitea') + }) + + it('suppresses the link when the branch tracks a fork remote with no resolvable push URL', () => { + // Repro: branch pushed only to a fork (tracked via plain git, so pushTarget + // is empty). A base-repo compare/main...quick-commands would 404 on origin. + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'quick-commands', + repoRemoteName: 'origin', + repoRemoteUrl: 'https://github.com/stablyai/orca.git', + upstreamName: 'pr-devajmeireles-orca/quick-commands' + }) + ).toBeNull() + }) + + it('uses the pushed upstream branch name when the local branch tracks a differently named branch on the base remote', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'local-wip-name', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@github.com:stablyai/orca.git', + upstreamName: 'origin/feature/pushed-name' + }) + ).toBe('https://github.com/stablyai/orca/compare/main...feature/pushed-name?expand=1') + }) + + it('still qualifies the fork head when Orca resolved the fork push URL', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'quick-commands', + repoRemoteName: 'origin', + repoRemoteUrl: 'https://github.com/stablyai/orca.git', + upstreamName: 'pr-devajmeireles-orca/quick-commands', + pushTarget: { + remoteName: 'pr-devajmeireles-orca', + branchName: 'quick-commands', + remoteUrl: 'git@github.com:devajmeireles/orca.git' + } + }) + ).toBe('https://github.com/stablyai/orca/compare/main...devajmeireles:quick-commands?expand=1') + }) + + it('does not guess a provider for unknown hosts without a provider hint', () => { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'feature/unknown', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@git.company.test:team/orca.git', + upstreamName: 'origin/feature/unknown' + }) + ).toBeNull() + }) + + it('suppresses the link when the branch was never pushed (no upstream, no push target)', () => { + // Repro: local-only branch. main... lands on GitHub's + // "There isn't anything to compare" page. + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'refs/remotes/origin/main', + branchName: 'codex-runtime-home-refactor-design', + repoRemoteName: 'origin', + repoRemoteUrl: 'git@github.com:stablyai/orca.git', + upstreamName: null + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts new file mode 100644 index 00000000000..6b261a8313a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts @@ -0,0 +1,338 @@ +import type { HostedReviewProvider } from '../../../../shared/hosted-review' +import type { GitPushTarget } from '../../../../shared/types' + +type ManualReviewProvider = Exclude + +type RemoteRepoRef = { + provider: ManualReviewProvider | null + webBaseUrl: string + path: string +} + +type ManualReviewUrlInput = { + baseRef: string | null | undefined + branchName: string | null | undefined + repoRemoteName?: string | null + repoRemoteUrl?: string | null + provider?: HostedReviewProvider | null + pushTarget?: GitPushTarget | null + /** Tracking upstream in `/` form (git `branch.upstream`). */ + upstreamName?: string | null +} + +type ParsedUpstream = { remoteName: string; branchName: string } + +function parseUpstream(name: string | null | undefined): ParsedUpstream | null { + const trimmed = name?.trim() + if (!trimmed) { + return null + } + const slash = trimmed.indexOf('/') + if (slash <= 0 || slash === trimmed.length - 1) { + return null + } + return { remoteName: trimmed.slice(0, slash), branchName: trimmed.slice(slash + 1) } +} + +function decodeSegment(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +function cleanPath(path: string): string | null { + const parts = path + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/i, '') + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + .map(decodeSegment) + return parts.length >= 2 ? parts.join('/') : null +} + +function providerForHost(host: string): ManualReviewProvider | null { + const normalized = host.toLowerCase() + if (normalized === 'github.com' || normalized === 'ssh.github.com') { + return 'github' + } + if (normalized === 'gitlab.com') { + return 'gitlab' + } + if (normalized === 'bitbucket.org') { + return 'bitbucket' + } + if ( + normalized === 'dev.azure.com' || + normalized === 'ssh.dev.azure.com' || + normalized.endsWith('.visualstudio.com') + ) { + return 'azure-devops' + } + return null +} + +function normalizeProvider( + provider: HostedReviewProvider | null | undefined +): ManualReviewProvider | null { + return provider && provider !== 'unsupported' ? provider : null +} + +function buildWebOrigin(protocol: string, host: string, hostname: string): string { + return protocol === 'http:' || protocol === 'https:' + ? `${protocol}//${host}` + : `https://${hostname}` +} + +function parseAzureDevOpsRemote(trimmed: string): RemoteRepoRef | null { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike && scpLike[1].toLowerCase() === 'ssh.dev.azure.com') { + const parts = cleanPath(scpLike[2])?.split('/') ?? [] + if (parts.length >= 4 && parts[0].toLowerCase() === 'v3') { + const [, organization, project, repository] = parts + return { + provider: 'azure-devops', + path: `${organization}/${project}/_git/${repository}`, + webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}` + } + } + } + + try { + const url = new URL(trimmed) + const protocol = url.protocol.toLowerCase() + if (!['http:', 'https:', 'ssh:', 'git+ssh:'].includes(protocol)) { + return null + } + const host = url.hostname.toLowerCase() + const parts = cleanPath(url.pathname)?.split('/') ?? [] + if (host === 'ssh.dev.azure.com' && parts.length >= 4 && parts[0].toLowerCase() === 'v3') { + const [, organization, project, repository] = parts + return { + provider: 'azure-devops', + path: `${organization}/${project}/_git/${repository}`, + webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}` + } + } + + const gitIndex = parts.findIndex((part) => part.toLowerCase() === '_git') + if (gitIndex < 1 || gitIndex + 1 >= parts.length) { + return null + } + const project = parts[gitIndex - 1] + const repository = parts[gitIndex + 1] + const prefix = parts.slice(0, gitIndex - 1) + const organization = + host === 'dev.azure.com' ? prefix[0] : host.endsWith('.visualstudio.com') ? null : undefined + const webPath = + organization === undefined + ? [...prefix, project, '_git', repository] + : organization + ? [organization, project, '_git', repository] + : [...prefix, project, '_git', repository] + return { + provider: 'azure-devops', + path: webPath.join('/'), + webBaseUrl: `${buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '')}/${encodePath(webPath.join('/'))}` + } + } catch { + return null + } +} + +function parseRemoteRepo( + remoteUrl: string, + providerHint?: HostedReviewProvider | null +): RemoteRepoRef | null { + const trimmed = remoteUrl.trim().replace(/^git\+/, '') + if (!trimmed || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith('/')) { + return null + } + + const azure = parseAzureDevOpsRemote(trimmed) + if (azure && (providerHint == null || providerHint === 'azure-devops')) { + return azure + } + + const scpLike = !trimmed.includes('://') + ? trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + : null + if (scpLike) { + const host = scpLike[1].toLowerCase() + const path = cleanPath(scpLike[2]) + if (!path) { + return null + } + const hintedProvider = normalizeProvider(providerHint) + return { + provider: hintedProvider ?? providerForHost(host) ?? null, + path, + webBaseUrl: `https://${host}/${encodePath(path)}` + } + } + + try { + const url = new URL(trimmed) + const protocol = url.protocol.toLowerCase() + if (!['git:', 'http:', 'https:', 'ssh:'].includes(protocol)) { + return null + } + const path = cleanPath(url.pathname) + if (!path) { + return null + } + const host = url.hostname.toLowerCase() + const hintedProvider = normalizeProvider(providerHint) + const inferredProvider = providerForHost(host) + const provider = hintedProvider ?? inferredProvider + const webOrigin = + host === 'ssh.github.com' + ? 'https://github.com' + : buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '') + return { + provider, + path, + webBaseUrl: `${webOrigin}/${encodePath(path)}` + } + } catch { + return null + } +} + +function branchFromRef(ref: string | null | undefined, remoteName?: string | null): string | null { + const trimmed = ref?.trim() + if (!trimmed) { + return null + } + const prefixes = remoteName + ? [`refs/remotes/${remoteName}/`, `remotes/${remoteName}/`, `${remoteName}/`] + : ['refs/heads/'] + for (const prefix of prefixes) { + if (trimmed.startsWith(prefix)) { + return trimmed.slice(prefix.length) || null + } + } + if (trimmed.startsWith('refs/remotes/')) { + const remoteAndBranch = trimmed.slice('refs/remotes/'.length) + const slashIndex = remoteAndBranch.indexOf('/') + return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null + } + if (trimmed.startsWith('remotes/')) { + const remoteAndBranch = trimmed.slice('remotes/'.length) + const slashIndex = remoteAndBranch.indexOf('/') + return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null + } + if (trimmed.startsWith('refs/heads/')) { + return trimmed.slice('refs/heads/'.length) || null + } + return trimmed +} + +function githubHeadRef(base: RemoteRepoRef, head: RemoteRepoRef, branch: string): string { + if (base.path.toLowerCase() === head.path.toLowerCase()) { + return branch + } + const owner = head.path.split('/')[0] + return owner ? `${owner}:${branch}` : branch +} + +function appendQuery(url: string, values: Record): string { + const search = new URLSearchParams(values) + return `${url}?${search.toString()}` +} + +// GitHub/Gitea compare refs keep '/' (slashed branch names like feature/foo) and +// ':' (the owner:branch fork qualifier) literal; percent-encoding those separators +// makes GitHub fail to resolve the branch. Only the segments between them are encoded. +function encodeCompareRef(ref: string): string { + return ref + .split(':') + .map((segment) => segment.split('/').map(encodeURIComponent).join('/')) + .join(':') +} + +export function buildSourceControlManualReviewUrl(input: ManualReviewUrlInput): string | null { + const baseBranch = branchFromRef(input.baseRef, input.repoRemoteName) + const localBranch = input.branchName?.trim() + if (!baseBranch || !localBranch || localBranch === 'HEAD') { + return null + } + + const baseRemoteUrl = input.repoRemoteUrl?.trim() || input.pushTarget?.remoteUrl?.trim() + const headRemoteUrl = input.pushTarget?.remoteUrl?.trim() || baseRemoteUrl + if (!baseRemoteUrl || !headRemoteUrl) { + return null + } + + // Why: the tracking upstream is the source of truth for where the branch + // actually lives on the remote. When it tracks a remote we can't resolve to a + // URL (a fork tracked via plain git, so pushTarget is empty), a base-repo + // compare link would 404 — suppress instead of pointing at a missing branch. + const upstream = parseUpstream(input.upstreamName) + const baseRemoteName = input.repoRemoteName?.trim() || null + const hasResolvableForkUrl = Boolean(input.pushTarget?.remoteUrl?.trim()) + if ( + upstream && + baseRemoteName && + upstream.remoteName !== baseRemoteName && + !hasResolvableForkUrl + ) { + return null + } + + const baseRepo = parseRemoteRepo(baseRemoteUrl, input.provider) + const headRepo = parseRemoteRepo(headRemoteUrl, input.provider) + if (!baseRepo || !headRepo) { + return null + } + + // Why: without a pushTarget or tracking upstream there's no evidence the branch + // exists on any remote, so a compare/PR-create link would land on GitHub's + // "There isn't anything to compare" page. No link beats a broken one. + const pushedBranch = input.pushTarget?.branchName?.trim() + if (!pushedBranch && !upstream) { + return null + } + + const provider = normalizeProvider(input.provider) ?? baseRepo.provider ?? headRepo.provider + // Prefer the pushed branch name (pushTarget, else the tracking upstream on the + // base remote) so the link matches what exists remotely, not the local name. + const headBranch = + pushedBranch || + (upstream && (!baseRemoteName || upstream.remoteName === baseRemoteName) + ? upstream.branchName + : localBranch) + + switch (provider) { + case 'github': + return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef( + githubHeadRef(baseRepo, headRepo, headBranch) + )}?expand=1` + case 'gitlab': + return appendQuery(`${baseRepo.webBaseUrl}/-/merge_requests/new`, { + 'merge_request[source_branch]': headBranch, + 'merge_request[target_branch]': baseBranch + }) + case 'bitbucket': + return appendQuery(`${baseRepo.webBaseUrl}/pull-requests/new`, { + source: headBranch, + dest: baseBranch + }) + case 'azure-devops': + return appendQuery(`${baseRepo.webBaseUrl}/pullrequestcreate`, { + sourceRef: `refs/heads/${headBranch}`, + targetRef: `refs/heads/${baseBranch}` + }) + case 'gitea': + return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef(headBranch)}` + default: + return null + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0695879260a..4ce218d3ae4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6401,8 +6401,6 @@ "a62b4cb39a": "Save Changes", "29af933cd5": "New SSH Target", "f2331ce599": "Edit SSH Target", - "7c13f58c91": "Until reset", - "55c56cf2c7": "Timeout after disconnect (seconds)", "4a342f44c1": "Advanced Connection", "e9609ddca6": "Proxy, jump host, and connection reuse", "8c922dffba": "Reuse SSH connection for faster setup", @@ -9165,7 +9163,8 @@ "b715ef615b": "{{value0}} commits ahead of {{value1}}", "c1a8f3e204": "1 commit behind {{value0}}", "d2b9g4f315": "{{value0}} commits behind {{value1}}", - "e9c2a5d416": "Even with {{value0}}" + "e9c2a5d416": "Even with {{value0}}", + "4b4a7de138": "Open review page in browser" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 282c041e488..9e71eb0da71 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9163,7 +9163,8 @@ "b715ef615b": "{{value0}} commits adelante de {{value1}}", "c1a8f3e204": "1 commit detrás de {{value0}}", "d2b9g4f315": "{{value0}} commits detrás de {{value1}}", - "e9c2a5d416": "A la par con {{value0}}" + "e9c2a5d416": "A la par con {{value0}}", + "4b4a7de138": "Open review page in browser" }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index db058eddd76..c994f32cac6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9163,7 +9163,8 @@ "b715ef615b": "{{value1}}より{{value0}}commits 進んでいます", "c1a8f3e204": "{{value0}}より1commit 遅れています", "d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています", - "e9c2a5d416": "{{value0}}と同じ状態" + "e9c2a5d416": "{{value0}}と同じ状態", + "4b4a7de138": "Open review page in browser" }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c120d72f13..3acc372f8bc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9163,7 +9163,8 @@ "b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음", "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", - "e9c2a5d416": "{{value0}}와 동일한 상태" + "e9c2a5d416": "{{value0}}와 동일한 상태", + "4b4a7de138": "Open review page in browser" }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7510e6718e5..e01451e9763 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9163,7 +9163,8 @@ "b715ef615b": "领先 {{value1}} {{value0}} 个提交", "c1a8f3e204": "落后 {{value0}} 1 个提交", "d2b9g4f315": "落后 {{value1}} {{value0}} 个提交", - "e9c2a5d416": "与 {{value0}} 同步" + "e9c2a5d416": "与 {{value0}} 同步", + "4b4a7de138": "Open review page in browser" }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。",