diff --git a/package.json b/package.json index 3971843d40..49a46b47cf 100644 --- a/package.json +++ b/package.json @@ -1013,6 +1013,18 @@ "category": "%command.pull.request.category%", "icon": "$(cloud)" }, + { + "command": "pr.pickInWorktree", + "title": "%command.pr.pickInWorktreeFromDescription.title%", + "category": "%command.pull.request.category%", + "icon": "$(folder-library)" + }, + { + "command": "pr.pickInWorktreeFromDescription", + "title": "%command.pr.pickInWorktree.title%", + "category": "%command.pull.request.category%", + "icon": "$(folder-library)" + }, { "command": "pr.exit", "title": "%command.pr.exit.title%", @@ -2084,6 +2096,14 @@ "command": "pr.pickOnCodespaces", "when": "false" }, + { + "command": "pr.pickInWorktree", + "when": "false" + }, + { + "command": "pr.pickInWorktreeFromDescription", + "when": "false" + }, { "command": "pr.exit", "when": "github:inReviewMode" @@ -2882,6 +2902,11 @@ "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", "group": "1_pullrequest@3" }, + { + "command": "pr.pickInWorktree", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && !isWeb", + "group": "1_pullrequest@4" + }, { "command": "pr.openChanges", "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/", @@ -3641,13 +3666,18 @@ "when": "webviewId == PullRequestOverview && github:checkoutMenu" }, { - "command": "pr.checkoutOnVscodeDevFromDescription", + "command": "pr.pickInWorktreeFromDescription", "group": "checkout@1", + "when": "webviewId == PullRequestOverview && github:checkoutMenu && !isWeb" + }, + { + "command": "pr.checkoutOnVscodeDevFromDescription", + "group": "checkout@2", "when": "webviewId == PullRequestOverview && github:checkoutMenu" }, { "command": "pr.checkoutOnCodespacesFromDescription", - "group": "checkout@2", + "group": "checkout@3", "when": "webviewId == PullRequestOverview && github:checkoutMenu" }, { diff --git a/package.nls.json b/package.nls.json index 334da3399c..bd16502abe 100644 --- a/package.nls.json +++ b/package.nls.json @@ -215,6 +215,8 @@ "command.pr.openChanges.title": "Open Changes", "command.pr.pickOnVscodeDev.title": "Checkout Pull Request on vscode.dev", "command.pr.pickOnCodespaces.title": "Checkout Pull Request on Codespaces", + "command.pr.pickInWorktree.title": "Checkout in Worktree", + "command.pr.pickInWorktreeFromDescription.title": "Checkout Pull Request in Worktree", "command.pr.exit.title": "Checkout Default Branch", "command.pr.dismissNotification.title": "Dismiss Notification", "command.pr.markAllCopilotNotificationsAsRead.title": "Dismiss All Copilot Notifications", diff --git a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts index f38544f604..40babc9343 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -453,6 +453,11 @@ declare module 'vscode' { constructor(value: string | MarkdownString); } + export class ChatResponseInfoPart { + value: MarkdownString; + constructor(value: string | MarkdownString); + } + export class ChatResponseProgressPart2 extends ChatResponseProgressPart { value: string; task?: (progress: Progress) => Thenable; @@ -633,6 +638,15 @@ declare module 'vscode' { */ warning(message: string | MarkdownString): void; + /** + * Push an info banner to this stream. Short-hand for + * `push(new ChatResponseInfoPart(message))`. + * + * @param message An informational message + * @returns This stream. + */ + info(message: string | MarkdownString): void; + reference(value: Uri | Location | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }): void; reference2(value: Uri | Location | string | { variableName: string; value?: Uri | Location }, iconPath?: Uri | ThemeIcon | { light: Uri; dark: Uri }, options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }): void; diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index 58e75115de..fc12969f6b 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -76,6 +76,23 @@ declare module 'vscode' { // TODO: Do we need a flag to try auth if needed? provideChatSessionItems(token: CancellationToken): ProviderResult; + /** + * @deprecated Use {@linkcode ChatSessionItemController.resolveChatSessionItem} instead. + * + * Given a chat session item fill in more data, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * @param item A chat session item currently visible in the UI. Treat this as read-only. + * @param token A cancellation token. + * @returns A new {@link ChatSessionItem} instance (or a thenable that resolves to one) with the + * same `resource` as `item` and any additional properties filled in. When no result is returned, + * the given `item` is left unchanged. + */ + resolveChatSessionItem?: (item: ChatSessionItem, token: CancellationToken) => ProviderResult; + // #region Unstable parts of API /** @@ -100,11 +117,6 @@ declare module 'vscode' { readonly command?: string; }; - /** - * @deprecated Use `inputState` instead - */ - readonly sessionOptions: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; - readonly inputState: ChatSessionInputState; } @@ -199,6 +211,26 @@ declare module 'vscode' { */ getChatSessionInputState?: ChatSessionControllerGetInputState; + /** + * Called to fill in more data on a chat session item, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * The editor will only resolve a chat session item once, unless the item is updated via + * {@link ChatSessionItemCollection.add add} or {@link ChatSessionItemCollection.replace replace}, + * which invalidates the resolve cache. + * + * The handler should update the item in the {@link ChatSessionItemController.items items collection} via + * {@link ChatSessionItemCollection.add add}. The editor picks up the updated item from + * the collection after the returned thenable resolves. + * + * @param item A chat session item currently visible in the UI. + * @param token A cancellation token. + */ + resolveChatSessionItem?: (item: ChatSessionItem, token: CancellationToken) => Thenable; + /** * Create a new managed ChatSessionInputState object. */ @@ -505,11 +537,6 @@ declare module 'vscode' { */ provideChatSessionContent(resource: Uri, token: CancellationToken, context: { readonly inputState: ChatSessionInputState; - - /** - * @deprecated Use `inputState` instead - */ - readonly sessionOptions: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; }): Thenable | ChatSession; /** @@ -621,6 +648,16 @@ declare module 'vscode' { * Only one item per option group should be marked as default. */ readonly default?: boolean; + + /** + * Optional slash-command alias (without leading `/`) that selects this option + * when the user submits `/`. Does not send a chat request; only + * updates the selection so the next prompt runs with this option active. + * + * Scoped to chat sessions owned by the contributing provider. Names must be + * unique across the provider's groups; on conflict, the first declared wins. + */ + readonly slashCommand?: string; } /** @@ -678,6 +715,22 @@ declare module 'vscode' { * `{ inputState: ChatSessionInputState; sessionResource: Uri | undefined }` that they can use to determine which session and options they are being invoked for. */ readonly commands?: Command[]; + + /** + * Optional kind that hints how this option group should be presented in the UI. + * + * - `'permissions'`: The group represents tool-approval permissions for the session. + * The editor will not render this group as its own picker. Instead, its items + * replace the built-in items in the chat permission picker for the session, + * and the user's selection is reported back through the standard + * {@link ChatSessionContentProvider.handleChatSessionOptionsChange} flow. + * At most one option group per provider may use this kind; if more than one is + * declared, the first one (in declaration order) is used. The group is invisible + * if the chat permission picker itself is hidden by other `when` clauses. + * + * When omitted, the group is rendered as a standalone picker as usual. + */ + readonly kind?: 'permissions'; } export interface ChatSessionProviderOptions { diff --git a/src/commands.ts b/src/commands.ts index a52e45473d..2758b0840e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -32,6 +32,7 @@ import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; import { BaseContext, OverviewContext } from './github/views'; +import { checkoutPRInWorktree } from './github/worktree'; import { IssueChatContextItem } from './lm/issueContextProvider'; import { PRChatContextItem } from './lm/pullRequestContextProvider'; import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem'; @@ -825,6 +826,47 @@ export function registerCommands( ), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.pickInWorktree', async (pr: PRNode | PullRequestModel | unknown) => { + if (pr === undefined) { + Logger.error('Unexpectedly received undefined when picking a PR for worktree checkout.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; + + if (pr instanceof PRNode) { + pullRequestModel = pr.pullRequestModel; + repository = pr.repository; + } else if (pr instanceof PullRequestModel) { + pullRequestModel = pr; + } else { + Logger.error('Unexpectedly received unknown type when picking a PR for worktree checkout.', logId); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + + // Get the folder manager to access the repository + const folderManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for this pull request.')); + } + + return checkoutPRInWorktree(telemetry, folderManager, pullRequestModel, repository); + }), + ); + + context.subscriptions.push(vscode.commands.registerCommand('pr.pickInWorktreeFromDescription', async (ctx: BaseContext | undefined) => { + if (!ctx) { + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); + } + const resolved = await resolvePr(ctx); + if (!resolved) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to resolve pull request for checkout.')); + } + return checkoutPRInWorktree(telemetry, resolved.folderManager, resolved.pr, undefined); + })); + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnVscodeDevFromDescription', async (context: BaseContext | undefined) => { if (!context) { return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.')); diff --git a/src/github/worktree.ts b/src/github/worktree.ts new file mode 100644 index 0000000000..ca466a8e8a --- /dev/null +++ b/src/github/worktree.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { PullRequestModel } from './pullRequestModel'; +import { Repository } from '../api/api'; +import { commands } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; + +const logId = 'Worktree'; + +/** + * Checks out a pull request in a new git worktree. + * @param telemetry Telemetry instance for tracking usage + * @param folderManager The folder repository manager + * @param pullRequestModel The pull request to checkout + * @param repository Optional repository to use (if not provided, uses folderManager.repository) + */ +export async function checkoutPRInWorktree( + telemetry: ITelemetry, + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + repository: Repository | undefined +): Promise { + // Validate that the PR has a valid head branch + if (!pullRequestModel.head) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.')); + return; + } + + const prHead = pullRequestModel.head; + const repositoryToUse = repository || folderManager.repository; + + /* __GDPR__ + "pr.checkoutInWorktree" : {} + */ + telemetry.sendTelemetryEvent('pr.checkoutInWorktree'); + + // Prepare for operations + const repoRootPath = repositoryToUse.rootUri.fsPath; + const parentDir = path.dirname(repoRootPath); + const defaultWorktreePath = path.join(parentDir, `pr-${pullRequestModel.number}`); + const branchName = prHead.ref; + const remoteName = pullRequestModel.remote.remoteName; + + // Ask user for worktree location first (not in progress) + const worktreeUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultWorktreePath), + title: vscode.l10n.t('Select Worktree Location'), + saveLabel: vscode.l10n.t('Create Worktree'), + }); + + if (!worktreeUri) { + return; // User cancelled + } + + const worktreePath = worktreeUri.fsPath; + const trackedBranchName = `${remoteName}/${branchName}`; + + try { + // Check if the createWorktree API is available + if (!repositoryToUse.createWorktree) { + throw new Error(vscode.l10n.t('Git worktree API is not available. Please update VS Code to the latest version.')); + } + + // Start progress for fetch and worktree creation + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Creating worktree for Pull Request #{0}...', pullRequestModel.number), + }, + async () => { + // Fetch the PR branch first + await repositoryToUse.fetch({ remote: remoteName, ref: branchName }); + + // Check if the branch already exists locally + let branchExists = false; + try { + await repositoryToUse.getBranch(branchName); + branchExists = true; + } catch { + // Branch doesn't exist locally, we'll create it + branchExists = false; + } + + // Use the git extension's createWorktree API + // If branch already exists, don't specify the branch parameter to avoid "branch already exists" error + if (branchExists) { + await repositoryToUse.createWorktree!({ + path: worktreePath, + commitish: branchName + }); + } else { + await repositoryToUse.createWorktree!({ + path: worktreePath, + commitish: trackedBranchName, + branch: branchName + }); + } + } + ); + + // Ask user how they want to open the worktree (modal dialog) + const openInNewWindow = vscode.l10n.t('New Window'); + const openInCurrentWindow = vscode.l10n.t('Current Window'); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('Worktree created for Pull Request #{0}. How would you like to open it?', pullRequestModel.number), + { modal: true }, + openInNewWindow, + openInCurrentWindow + ); + + if (result === openInNewWindow) { + await commands.openFolder(worktreeUri, { forceNewWindow: true }); + } else if (result === openInCurrentWindow) { + await commands.openFolder(worktreeUri, { forceNewWindow: false }); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + Logger.error(`Failed to create worktree: ${errorMessage}`, logId); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create worktree: {0}', errorMessage)); + } +} diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 9f4d8dd948..7b4fd70b69 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -370,7 +370,7 @@ export class PRContext { const args: OpenLocalFileArgs = { file, startLine, endLine, href }; this.postMessage({ command: 'pr.open-diff-from-link', args }); }; - + public viewCheckLogs = (status: PullRequestCheckStatus) => this.postMessage({ command: 'pr.view-check-logs', args: { status } }); public openCommitChanges = async (commitSha: string) => {