diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index aa9b8a14ea..a5f188b776 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -26,6 +26,7 @@ import { ClaudeChatSessionParticipant } from './claudeChatSessionParticipant'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessionsContribution'; import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { CopilotChatSessionsProvider } from './copilotCloudSessionsProvider'; +import { PRContentProvider } from './prContentProvider'; import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService'; import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver'; @@ -135,6 +136,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib if (enabled && !this.copilotCloudRegistrations) { // Register the Copilot Cloud chat participant this.copilotCloudRegistrations = new DisposableStore(); + + this.copilotCloudRegistrations.add( + this.copilotAgentInstaService.createInstance(PRContentProvider) + ); + const copilotSessionsProvider = this.copilotCloudRegistrations.add( this.copilotAgentInstaService.createInstance(CopilotChatSessionsProvider) ); diff --git a/src/extension/chatSessions/vscode-node/prContentProvider.ts b/src/extension/chatSessions/vscode-node/prContentProvider.ts new file mode 100644 index 0000000000..78510c0eb7 --- /dev/null +++ b/src/extension/chatSessions/vscode-node/prContentProvider.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; + +/** + * URI scheme for PR content + */ +export const PR_SCHEME = 'copilot-pr'; + +/** + * Parameters encoded in PR content URIs + */ +export interface PRContentUriParams { + owner: string; + repo: string; + prNumber: number; + fileName: string; + commitSha: string; + isBase: boolean; // true for left side, false for right side + previousFileName?: string; // for renames +} + +/** + * Create a URI for PR file content + */ +export function toPRContentUri( + fileName: string, + params: Omit +): vscode.Uri { + return vscode.Uri.from({ + scheme: PR_SCHEME, + path: `/${fileName}`, + query: JSON.stringify({ ...params, fileName }) + }); +} + +/** + * Parse parameters from a PR content URI + */ +export function fromPRContentUri(uri: vscode.Uri): PRContentUriParams | undefined { + if (uri.scheme !== PR_SCHEME) { + return undefined; + } + try { + return JSON.parse(uri.query) as PRContentUriParams; + } catch (e) { + return undefined; + } +} + +/** + * TextDocumentContentProvider for PR content that fetches file content from GitHub + */ +export class PRContentProvider extends Disposable implements vscode.TextDocumentContentProvider { + private static readonly ID = 'PRContentProvider'; + private _onDidChange = this._register(new vscode.EventEmitter()); + readonly onDidChange = this._onDidChange.event; + + constructor( + @IOctoKitService private readonly _octoKitService: IOctoKitService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + // Register text document content provider for PR scheme + this._register( + vscode.workspace.registerTextDocumentContentProvider( + PR_SCHEME, + this + ) + ); + } + + async provideTextDocumentContent(uri: vscode.Uri): Promise { + const params = fromPRContentUri(uri); + if (!params) { + this.logService.error(`[${PRContentProvider.ID}] Invalid PR content URI: ${uri.toString()}`); + return ''; + } + + try { + this.logService.trace( + `[${PRContentProvider.ID}] Fetching ${params.isBase ? 'base' : 'head'} content for ${params.fileName} ` + + `from ${params.owner}/${params.repo}#${params.prNumber} at ${params.commitSha}` + ); + + // Fetch file content from GitHub + const content = await this._octoKitService.getFileContent( + params.owner, + params.repo, + params.commitSha, + params.fileName + ); + + return content; + } catch (error) { + this.logService.error( + `[${PRContentProvider.ID}] Failed to fetch PR file content: ${error instanceof Error ? error.message : String(error)}` + ); + // Return empty content instead of throwing to avoid breaking the diff view + return ''; + } + } +} diff --git a/src/extension/chatSessions/vscode-node/pullRequestFileChangesService.ts b/src/extension/chatSessions/vscode-node/pullRequestFileChangesService.ts index 6cce6faee4..976db5e950 100644 --- a/src/extension/chatSessions/vscode-node/pullRequestFileChangesService.ts +++ b/src/extension/chatSessions/vscode-node/pullRequestFileChangesService.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; import { IGitService } from '../../../platform/git/common/gitService'; import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; import { IOctoKitService } from '../../../platform/github/common/githubService'; import { ILogService } from '../../../platform/log/common/logService'; import { createServiceIdentifier } from '../../../util/common/services'; import { getRepoId } from '../vscode/copilotCodingAgentUtils'; +import { toPRContentUri } from './prContentProvider'; export const IPullRequestFileChangesService = createServiceIdentifier('IPullRequestFileChangesService'); @@ -25,7 +25,6 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer constructor( @IGitService private readonly _gitService: IGitService, @IOctoKitService private readonly _octoKitService: IOctoKitService, - @IGitExtensionService private readonly _gitExtensionService: IGitExtensionService, @ILogService private readonly logService: ILogService, ) { } @@ -47,34 +46,54 @@ export class PullRequestFileChangesService implements IPullRequestFileChangesSer return undefined; } - const diffEntries: vscode.ChatResponseDiffEntry[] = []; - const git = this._gitExtensionService.getExtensionApi(); - const repo = git?.repositories[0]; - const workspaceRoot = repo?.rootUri; - - if (!workspaceRoot) { - this.logService.warn('No workspace root found for file URIs'); + // Check if we have base and head commit SHAs + if (!pullRequest.baseRefOid || !pullRequest.headRefOid) { + this.logService.warn('PR missing base or head commit SHA, cannot create diff URIs'); return undefined; } + const diffEntries: vscode.ChatResponseDiffEntry[] = []; + for (const file of files) { - const fileUri = vscode.Uri.joinPath(workspaceRoot, file.filename); - const originalUri = file.previous_filename - ? vscode.Uri.joinPath(workspaceRoot, file.previous_filename) - : fileUri; + // Always use remote URIs to ensure we show the exact PR content + // Local files may be on different branches or have different changes + this.logService.trace(`Creating remote URIs for ${file.filename}`); + + const originalUri = toPRContentUri( + file.previous_filename || file.filename, + { + owner: repoId.org, + repo: repoId.repo, + prNumber: pullRequest.number, + commitSha: pullRequest.baseRefOid, + isBase: true, + previousFileName: file.previous_filename + } + ); + + const modifiedUri = toPRContentUri( + file.filename, + { + owner: repoId.org, + repo: repoId.repo, + prNumber: pullRequest.number, + commitSha: pullRequest.headRefOid, + isBase: false + } + ); - this.logService.trace(`DiffEntry -> original='${originalUri.fsPath}' modified='${fileUri.fsPath}' (+${file.additions} -${file.deletions})`); + this.logService.trace(`DiffEntry -> original='${originalUri.toString()}' modified='${modifiedUri.toString()}' (+${file.additions} -${file.deletions})`); diffEntries.push({ originalUri, - modifiedUri: fileUri, - goToFileUri: fileUri, + modifiedUri, + goToFileUri: modifiedUri, added: file.additions, removed: file.deletions, }); } const title = `Changes in Pull Request #${pullRequest.number}`; - return new vscode.ChatResponseMultiDiffPart(diffEntries, title, true /* readOnly */); + return new vscode.ChatResponseMultiDiffPart(diffEntries, title, false); } catch (error) { this.logService.error(`Failed to get file changes multi diff part: ${error}`); return undefined; diff --git a/src/platform/github/common/githubAPI.ts b/src/platform/github/common/githubAPI.ts index dbb3601b3e..f61cc40726 100644 --- a/src/platform/github/common/githubAPI.ts +++ b/src/platform/github/common/githubAPI.ts @@ -27,7 +27,8 @@ export interface PullRequestSearchItem { additions: number; deletions: number; fullDatabaseId: number; - headRefOid: number; + headRefOid: string; + baseRefOid?: string; body: string; } @@ -184,6 +185,7 @@ export async function makeSearchGraphQLRequest( id fullDatabaseId headRefOid + baseRefOid title state url @@ -240,6 +242,7 @@ export async function getPullRequestFromGlobalId( id fullDatabaseId headRefOid + baseRefOid title state url diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index 4919145f8d..83c543facd 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -5,6 +5,7 @@ import type { Endpoints } from "@octokit/types"; import { createServiceIdentifier } from '../../../util/common/services'; +import { decodeBase64 } from '../../../util/vs/base/common/buffer'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; @@ -261,6 +262,16 @@ export interface IOctoKitService { * @returns A promise that resolves to true if the PR was successfully closed */ closePullRequest(owner: string, repo: string, pullNumber: number): Promise; + + /** + * Get file content from a specific commit. + * @param owner The repository owner + * @param repo The repository name + * @param ref The commit SHA, branch name, or tag + * @param path The file path within the repository + * @returns The file content as a string + */ + getFileContent(owner: string, repo: string, ref: string, path: string): Promise; } /** @@ -348,4 +359,14 @@ export class BaseOctoKitService { protected async closePullRequestWithToken(owner: string, repo: string, pullNumber: number, token: string): Promise { return closePullRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, owner, repo, pullNumber); } + + protected async getFileContentWithToken(owner: string, repo: string, ref: string, path: string, token: string): Promise { + const response = await makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, `repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, 'GET', token, undefined); + + if (response?.content && response.encoding === 'base64') { + return decodeBase64(response.content.replace(/\n/g, '')).toString(); + } else { + return ''; + } + } } diff --git a/src/platform/github/common/octoKitServiceImpl.ts b/src/platform/github/common/octoKitServiceImpl.ts index 8ffdae9e3e..127c041735 100644 --- a/src/platform/github/common/octoKitServiceImpl.ts +++ b/src/platform/github/common/octoKitServiceImpl.ts @@ -178,4 +178,12 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } return this.closePullRequestWithToken(owner, repo, pullNumber, authToken); } + + async getFileContent(owner: string, repo: string, ref: string, path: string): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + throw new Error('No GitHub authentication available'); + } + return this.getFileContentWithToken(owner, repo, ref, path, authToken); + } } \ No newline at end of file