diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ed72bc122323..06224be0f1d8 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -18,6 +18,7 @@ export { getPRChecks } from './client/check/get-pr-checks' export { getPRComments } from './client/fetch/get-pr-comments' export { getPRForBranch } from './client/lookup/get-pr-for-branch' export { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' +export { getPRPreviewDeploymentUrl } from './client/fetch/pr-preview-deployment-url' export { getPullRequestPushTarget, type PullRequestPushTarget diff --git a/src/main/github/client/fetch/pr-preview-deployment-url.test.ts b/src/main/github/client/fetch/pr-preview-deployment-url.test.ts new file mode 100644 index 000000000000..4e0922bef191 --- /dev/null +++ b/src/main/github/client/fetch/pr-preview-deployment-url.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ghExecFileAsync: vi.fn(), + resolveGitHubRepoExecution: vi.fn(), + repositoryRateLimitGuard: vi.fn() +})) + +vi.mock('../../gh-utils', () => ({ + ghExecFileAsync: mocks.ghExecFileAsync, + acquire: vi.fn(async () => {}), + release: vi.fn() +})) +vi.mock('../../github-api-repository', () => ({ + resolveGitHubRepoExecution: mocks.resolveGitHubRepoExecution +})) +vi.mock('../../rate-limit', () => ({ + repositoryRateLimitGuard: mocks.repositoryRateLimitGuard, + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getPRPreviewDeploymentUrl } from './pr-preview-deployment-url' + +type Status = { state: string; environmentUrl: string | null } | null + +function respondWithCommits(commits: Status[][]): void { + mocks.ghExecFileAsync.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + commits: { + nodes: commits.map((statuses) => ({ + commit: { + deployments: { nodes: statuses.map((latestStatus) => ({ latestStatus })) } + } + })) + } + } + } + } + }), + stderr: '' + }) +} + +describe('getPRPreviewDeploymentUrl', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveGitHubRepoExecution.mockResolvedValue({ + ownerRepo: { owner: 'acme', repo: 'web' }, + ghOptions: { cwd: '/repo' } + }) + mocks.repositoryRateLimitGuard.mockReturnValue({ blocked: false }) + }) + + it('returns the newest successful deployment, skipping a head commit still building', async () => { + respondWithCommits([ + [{ state: 'SUCCESS', environmentUrl: 'https://old.pages.dev' }], + [ + { state: 'FAILURE', environmentUrl: null }, + { state: 'SUCCESS', environmentUrl: 'https://newer.pages.dev' } + ], + [null] + ]) + + await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBe('https://newer.pages.dev') + const argv = mocks.ghExecFileAsync.mock.calls[0][0] as string[] + expect(argv).toEqual(expect.arrayContaining(['owner=acme', 'repo=web', 'number=42'])) + }) + + it('returns null when no deployment succeeded', async () => { + respondWithCommits([[{ state: 'FAILURE', environmentUrl: null }], []]) + + await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBeNull() + }) + + it('returns null when the lookup fails', async () => { + mocks.ghExecFileAsync.mockRejectedValue(new Error('gh failed')) + + await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBeNull() + }) +}) diff --git a/src/main/github/client/fetch/pr-preview-deployment-url.ts b/src/main/github/client/fetch/pr-preview-deployment-url.ts new file mode 100644 index 000000000000..171a9d6e73e7 --- /dev/null +++ b/src/main/github/client/fetch/pr-preview-deployment-url.ts @@ -0,0 +1,92 @@ +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' + +const PR_DEPLOYMENTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + commits(last: 30) { + nodes { + commit { + deployments(first: 10, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { latestStatus { state environmentUrl } } + } + } + } + } + } + } +}` + +type DeploymentStatus = { state?: string; environmentUrl?: string | null } | null + +type PRDeploymentsResponse = { + data?: { + repository?: { + pullRequest?: { + commits?: { + nodes?: { commit?: { deployments?: { nodes?: { latestStatus?: DeploymentStatus }[] } } }[] + } + } + } + } +} + +function newestSuccessfulDeploymentUrl(response: PRDeploymentsResponse): string | null { + const commits = response.data?.repository?.pullRequest?.commits?.nodes ?? [] + for (const node of commits.toReversed()) { + for (const deployment of node.commit?.deployments?.nodes ?? []) { + const status = deployment.latestStatus + if (status?.state === 'SUCCESS' && status.environmentUrl) { + return status.environmentUrl + } + } + } + return null +} + +export async function getPRPreviewDeploymentUrl( + repoPath: string, + prNumber: number, + prRepo?: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return null + } + if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { + return null + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${PR_DEPLOYMENTS_QUERY}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-F', + `number=${prNumber}` + ], + ghOptions + ) + return newestSuccessfulDeploymentUrl(JSON.parse(stdout) as PRDeploymentsResponse) + } catch (err) { + console.warn('getPRPreviewDeploymentUrl failed:', err) + return null + } finally { + release() + } +} diff --git a/src/main/ipc/github-ipc-module-mocks.ts b/src/main/ipc/github-ipc-module-mocks.ts index 6fec0fc5fab5..b05e7443246d 100644 --- a/src/main/ipc/github-ipc-module-mocks.ts +++ b/src/main/ipc/github-ipc-module-mocks.ts @@ -20,6 +20,7 @@ const CLIENT_EXPORTS = [ 'getPRChecks', 'getPRCheckDetails', 'getPRComments', + 'getPRPreviewDeploymentUrl', 'setPRCommentReaction', 'resolveReviewThread', 'setPRFileViewed', diff --git a/src/main/ipc/github-pr-read-handlers.ts b/src/main/ipc/github-pr-read-handlers.ts index 1c200218f91f..2775deae8795 100644 --- a/src/main/ipc/github-pr-read-handlers.ts +++ b/src/main/ipc/github-pr-read-handlers.ts @@ -6,6 +6,7 @@ import { getPRCheckDetails, getPRChecks, getPRComments, + getPRPreviewDeploymentUrl, getRepoSlug, getRepoUpstream, resolveReviewThread, @@ -97,6 +98,28 @@ export function registerGitHubPRReadHandlers(store: Store): void { } ) + ipcMain.handle( + 'gh:prPreviewUrl', + ( + _event, + args: { + repoPath: string + repoId?: string | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRPreviewDeploymentUrl( + repo.path, + args.prNumber, + args.prRepo ?? null, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + ipcMain.handle( 'gh:prComments', ( diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts index 22294f6dc4e3..d87abc9e8538 100644 --- a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -150,6 +150,12 @@ export const ghPullRequestsAndWorkItemsApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => ipcRenderer.invoke('gh:prComments', args), + prPreviewUrl: (args: { + repoPath: string + repoId?: string | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:prPreviewUrl', args), setPRCommentReaction: (args: { repoPath: string repoId?: string | null diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts index 68de9ac2d317..5e790b7eefc1 100644 --- a/src/preload/api/github-pull-request-api.ts +++ b/src/preload/api/github-pull-request-api.ts @@ -103,6 +103,12 @@ export type GithubPullRequestApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => Promise + prPreviewUrl: (args: { + repoPath: string + repoId?: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + }) => Promise setPRCommentReaction: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/app-shell/source-control-shortcuts.test.ts b/src/renderer/src/app-shell/source-control-shortcuts.test.ts index 99e3084b5c0e..c5d3028b6e78 100644 --- a/src/renderer/src/app-shell/source-control-shortcuts.test.ts +++ b/src/renderer/src/app-shell/source-control-shortcuts.test.ts @@ -58,6 +58,38 @@ describe('frontend dev server shortcut', () => { }) }) +const preview = vi.hoisted(() => ({ opened: true, open: vi.fn(async () => preview.opened) })) +vi.mock('@/lib/open-pull-request-preview', () => ({ + openActivePullRequestPreview: preview.open +})) + +describe('pull request preview shortcut', () => { + beforeEach(() => vi.clearAllMocks()) + + it("opens the active pull request's preview deployment", async () => { + preview.opened = true + const match = (id: string): boolean => id === 'sourceControl.openPullRequestPreview' + + expect(takeSourceControlShortcut(match, true, actions)).toBe( + 'sourceControl.openPullRequestPreview' + ) + await vi.waitFor(() => expect(preview.open).toHaveBeenCalledOnce()) + expect(toast.info).not.toHaveBeenCalled() + }) + + it('says so when the pull request has no preview deployment', async () => { + preview.opened = false + const match = (id: string): boolean => id === 'sourceControl.openPullRequestPreview' + + expect(takeSourceControlShortcut(match, true, actions)).toBe( + 'sourceControl.openPullRequestPreview' + ) + await vi.waitFor(() => + expect(toast.info).toHaveBeenCalledWith('No preview deployment for this pull request.') + ) + }) +}) + describe('pull request shortcuts', () => { const openUrl = vi.fn(async () => {}) const writeClipboardText = vi.fn(async () => {}) diff --git a/src/renderer/src/app-shell/source-control-shortcuts.ts b/src/renderer/src/app-shell/source-control-shortcuts.ts index a55862029454..e5f08b1890b9 100644 --- a/src/renderer/src/app-shell/source-control-shortcuts.ts +++ b/src/renderer/src/app-shell/source-control-shortcuts.ts @@ -10,6 +10,7 @@ import { } from '@/components/pending-review/review-summary-focus' import { activeWorktreePullRequestUrl } from '@/lib/worktree-pull-request-url' import { openActiveWorkspaceFrontend } from '@/lib/open-workspace-frontend' +import { openActivePullRequestPreview } from '@/lib/open-pull-request-preview' import { useAppStore } from '../store' import type { KeybindingActionId } from '../../../shared/keybindings/types' @@ -21,6 +22,7 @@ type SourceControlShortcutId = | 'sourceControl.focusReviewSummary' | 'sourceControl.openPullRequest' | 'sourceControl.copyPullRequestUrl' + | 'sourceControl.openPullRequestPreview' | 'workspace.openFrontend' function focusReviewSummaryInOpenPanel(): boolean { @@ -129,6 +131,19 @@ export function takeSourceControlShortcut( }) return 'sourceControl.copyPullRequestUrl' } + if (matchShortcut('sourceControl.openPullRequestPreview')) { + void openActivePullRequestPreview().then((opened) => { + if (!opened) { + toast.info( + translate( + 'sourceControl.noPullRequestPreview', + 'No preview deployment for this pull request.' + ) + ) + } + }) + return 'sourceControl.openPullRequestPreview' + } return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 413a5c35503a..9eb06514647b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20528,7 +20528,8 @@ "status": "PR #{{number}} unlinked" }, "pullRequestUrlCopied": "Copied pull request URL", - "noPullRequest": "This workspace has no pull request yet." + "noPullRequest": "This workspace has no pull request yet.", + "noPullRequestPreview": "No preview deployment for this pull request." }, "agentsSidebarIntro": { "migrated": { diff --git a/src/renderer/src/lib/open-pull-request-preview.test.ts b/src/renderer/src/lib/open-pull-request-preview.test.ts new file mode 100644 index 000000000000..1e7fb2945b2c --- /dev/null +++ b/src/renderer/src/lib/open-pull-request-preview.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const pullRequest = vi.hoisted(() => ({ + current: null as { + worktreeId: string + repoPath: string + repoId: string + prNumber: number + prRepo: { owner: string; repo: string } | null + } | null +})) +const openWorkspaceFrontendUrl = vi.hoisted(() => vi.fn()) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-pull-request-url', () => ({ + activeWorktreeGitHubPullRequest: () => pullRequest.current +})) +vi.mock('@/lib/open-workspace-frontend', () => ({ openWorkspaceFrontendUrl })) + +import { openActivePullRequestPreview } from './open-pull-request-preview' + +describe('openActivePullRequestPreview', () => { + const prPreviewUrl = vi.fn() + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', { api: { gh: { prPreviewUrl } } }) + }) + + it("opens the pull request's preview deployment in the workspace", async () => { + pullRequest.current = { + worktreeId: 'wt-1', + repoPath: '/code/app', + repoId: 'repo-1', + prNumber: 7, + prRepo: { owner: 'acme', repo: 'app' } + } + prPreviewUrl.mockResolvedValue('https://abc.pages.dev') + + await expect(openActivePullRequestPreview()).resolves.toBe(true) + expect(prPreviewUrl).toHaveBeenCalledWith({ + repoPath: '/code/app', + repoId: 'repo-1', + prNumber: 7, + prRepo: { owner: 'acme', repo: 'app' } + }) + expect(openWorkspaceFrontendUrl).toHaveBeenCalledWith('wt-1', 'https://abc.pages.dev') + }) + + it('opens nothing when the pull request has no preview deployment', async () => { + pullRequest.current = { + worktreeId: 'wt-1', + repoPath: '/code/app', + repoId: 'repo-1', + prNumber: 7, + prRepo: null + } + prPreviewUrl.mockResolvedValue(null) + + await expect(openActivePullRequestPreview()).resolves.toBe(false) + expect(openWorkspaceFrontendUrl).not.toHaveBeenCalled() + }) + + it('opens nothing without a pull request', async () => { + pullRequest.current = null + + await expect(openActivePullRequestPreview()).resolves.toBe(false) + expect(prPreviewUrl).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/open-pull-request-preview.ts b/src/renderer/src/lib/open-pull-request-preview.ts new file mode 100644 index 000000000000..81bce88087f8 --- /dev/null +++ b/src/renderer/src/lib/open-pull-request-preview.ts @@ -0,0 +1,21 @@ +import { useAppStore } from '@/store' +import { activeWorktreeGitHubPullRequest } from '@/lib/worktree-pull-request-url' +import { openWorkspaceFrontendUrl } from '@/lib/open-workspace-frontend' + +export async function openActivePullRequestPreview(): Promise { + const pullRequest = activeWorktreeGitHubPullRequest(useAppStore.getState()) + if (!pullRequest) { + return false + } + const url = await window.api.gh.prPreviewUrl({ + repoPath: pullRequest.repoPath, + repoId: pullRequest.repoId, + prNumber: pullRequest.prNumber, + prRepo: pullRequest.prRepo + }) + if (!url) { + return false + } + openWorkspaceFrontendUrl(pullRequest.worktreeId, url) + return true +} diff --git a/src/renderer/src/lib/worktree-pull-request-url.test.ts b/src/renderer/src/lib/worktree-pull-request-url.test.ts index ce2b1238b200..b7274f3898c3 100644 --- a/src/renderer/src/lib/worktree-pull-request-url.test.ts +++ b/src/renderer/src/lib/worktree-pull-request-url.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { worktreeCardReviewCacheKeys } from '@/components/sidebar/worktree-card-review-resolution' -import { activeWorktreePullRequestUrl } from './worktree-pull-request-url' +import { + activeWorktreeGitHubPullRequest, + activeWorktreePullRequestUrl +} from './worktree-pull-request-url' const repo = { id: 'repo-1', path: '/code/app', displayName: 'app', badgeColor: '', addedAt: 0 } const worktree = { @@ -53,3 +56,39 @@ describe('activeWorktreePullRequestUrl', () => { expect(activeWorktreePullRequestUrl(state({ activeWorktreeId: null }))).toBeNull() }) }) + +describe('activeWorktreeGitHubPullRequest', () => { + it("identifies the GitHub pull request the active workspace's card shows", () => { + const { hostedReviewCacheKey } = worktreeCardReviewCacheKeys(worktree as never, repo, settings) + const review = { + provider: 'github', + number: 7, + title: 'Feature', + state: 'open', + url: 'https://github.com/acme/app/pull/7', + status: 'success', + updatedAt: '', + mergeable: 'MERGEABLE', + githubRepository: { owner: 'acme', repo: 'app' } + } + expect( + activeWorktreeGitHubPullRequest( + state({ + hostedReviewCache: { + [hostedReviewCacheKey]: { data: review, fetchedAt: 1, branchLookupGitHubPRNumber: 7 } + } + }) + ) + ).toEqual({ + worktreeId: worktree.id, + repoPath: '/code/app', + repoId: 'repo-1', + prNumber: 7, + prRepo: { owner: 'acme', repo: 'app' } + }) + }) + + it('gives nothing without a pull request', () => { + expect(activeWorktreeGitHubPullRequest(state())).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/worktree-pull-request-url.ts b/src/renderer/src/lib/worktree-pull-request-url.ts index 6ee6fb670217..a92806508f09 100644 --- a/src/renderer/src/lib/worktree-pull-request-url.ts +++ b/src/renderer/src/lib/worktree-pull-request-url.ts @@ -3,6 +3,7 @@ import { resolveWorktreeCardReview, worktreeCardReviewCacheKeys } from '@/components/sidebar/worktree-card-review-resolution' +import type { GitHubOwnerRepo } from '../../../shared/github/pull-request-types' type PullRequestUrlState = Pick< AppState, @@ -14,8 +15,15 @@ type PullRequestUrlState = Pick< | 'prCache' > -/** The URL of the review the active workspace's card shows, when it has one. */ -export function activeWorktreePullRequestUrl(state: PullRequestUrlState): string | null { +export type ActiveGitHubPullRequest = { + worktreeId: string + repoPath: string + repoId: string + prNumber: number + prRepo: GitHubOwnerRepo | null +} + +function activeWorktreeReview(state: PullRequestUrlState) { const worktree = state.activeWorktreeId ? state.getKnownWorktreeById(state.activeWorktreeId) : undefined @@ -33,5 +41,27 @@ export function activeWorktreePullRequestUrl(state: PullRequestUrlState): string hostedReviewCacheKey ? state.hostedReviewCache[hostedReviewCacheKey] : undefined, prCacheKey ? state.prCache?.[prCacheKey] : undefined ) - return prDisplay?.url ?? null + return prDisplay ? { worktree, repo, prDisplay } : null +} + +/** The URL of the review the active workspace's card shows, when it has one. */ +export function activeWorktreePullRequestUrl(state: PullRequestUrlState): string | null { + return activeWorktreeReview(state)?.prDisplay.url ?? null +} + +export function activeWorktreeGitHubPullRequest( + state: PullRequestUrlState +): ActiveGitHubPullRequest | null { + const review = activeWorktreeReview(state) + if (!review?.repo || review.prDisplay.provider !== 'github') { + return null + } + const { worktree, repo, prDisplay } = review + return { + worktreeId: worktree.id, + repoPath: repo.path, + repoId: repo.id, + prNumber: prDisplay.number, + prRepo: ('githubRepository' in prDisplay ? prDisplay.githubRepository : undefined) ?? null + } } diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts index 0e63b3066481..360f6fb74383 100644 --- a/src/renderer/src/web/preload-api/web-github-api.ts +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -73,6 +73,7 @@ export function createGitHubApi(): WebGitHubApi { route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), prComments: (args) => route>(GITHUB_WEB_RPC_METHODS.prComments, args), + prPreviewUrl: () => Promise.resolve(null), setPRCommentReaction: (args) => route>( GITHUB_WEB_RPC_METHODS.setPRCommentReaction, diff --git a/src/renderer/src/web/web-preload-api-github.test.ts b/src/renderer/src/web/web-preload-api-github.test.ts index 2a89a27aed2e..aa33cfc38b27 100644 --- a/src/renderer/src/web/web-preload-api-github.test.ts +++ b/src/renderer/src/web/web-preload-api-github.test.ts @@ -55,6 +55,7 @@ describe('web GitHub preload API', () => { 'prComments', 'prFileContents', 'prForBranch', + 'prPreviewUrl', 'projectWorkItemDetailsBySlug', 'rateLimit', 'refreshPRNow', diff --git a/src/shared/keybindings-pull-request.test.ts b/src/shared/keybindings-pull-request.test.ts index f182cdae0c06..f28521fd3a66 100644 --- a/src/shared/keybindings-pull-request.test.ts +++ b/src/shared/keybindings-pull-request.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { keybindingMatchesAction } from './keybindings' +import { getEffectiveKeybindingsForAction, keybindingMatchesAction } from './keybindings' describe('pull request shortcuts', () => { it('opens the pull request with Option+Cmd+G on macOS, where Option+G types ©', () => { @@ -12,6 +12,15 @@ describe('pull request shortcuts', () => { ).toBe(true) }) + it.each(['darwin', 'linux', 'win32'] as const)( + 'opens the preview deployment with Mod+Shift+P on %s', + (platform) => { + expect( + getEffectiveKeybindingsForAction('sourceControl.openPullRequestPreview', platform) + ).toEqual(['Mod+Shift+P']) + } + ) + it('copies its URL with Shift+Option+Cmd+G on macOS', () => { expect( keybindingMatchesAction( diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts index dc6a0205d97d..41f1f19d5b4a 100644 --- a/src/shared/keybindings/definitions-core-4.ts +++ b/src/shared/keybindings/definitions-core-4.ts @@ -191,6 +191,15 @@ export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ // Why macOS only: Ctrl+Alt+letter is AltGr text input on Windows and Linux. defaultBindings: { darwin: ['Mod+Alt+Shift+G'], linux: [], win32: [] } }, + { + id: 'sourceControl.openPullRequestPreview', + title: 'Open Pull Request Preview Deployment', + group: 'Global', + scope: 'global', + conflictGroup: 'editor', + searchKeywords: ['shortcut', 'pull request', 'preview', 'deployment', 'browser', 'open'], + defaultBindings: platformBindings(['Mod+Shift+P']) + }, { id: 'workspace.openFrontend', title: 'Open Frontend Dev Server', diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts index bb1e60c14ad0..e94ae4060c85 100644 --- a/src/shared/keybindings/types.ts +++ b/src/shared/keybindings/types.ts @@ -111,6 +111,7 @@ export type KeybindingActionId = | 'sourceControl.sendQueuedComments' | 'sourceControl.openPullRequest' | 'sourceControl.copyPullRequestUrl' + | 'sourceControl.openPullRequestPreview' | 'workspace.openFrontend' | 'sourceControl.focusReviewSummary' | 'fileExplorer.undo'