Skip to content

Commit 85b3b74

Browse files
authored
Open commits from non-checked out PRs in VS Code instead of opening the browser (#7347)
* Open commits from non-checked out PRs in VS Code instead of opening the browser Fixes #7346 * Wait for GH repos
1 parent 0b1b738 commit 85b3b74

File tree

9 files changed

+119
-42
lines changed

9 files changed

+119
-42
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"onFileSystem:newIssue",
5353
"onFileSystem:pr",
5454
"onFileSystem:githubpr",
55+
"onFileSystem:githubcommit",
5556
"onFileSystem:review",
5657
"onWebviewPanel:pr.codingAgentSessionLogView"
5758
],

src/common/uri.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ export interface GitUriOptions {
9292
base: boolean;
9393
}
9494

95+
export interface GitHubCommitUriParams {
96+
commit: string;
97+
owner: string;
98+
repo: string;
99+
}
100+
101+
export function fromGitHubCommitUri(uri: vscode.Uri): GitHubCommitUriParams | undefined {
102+
if (uri.scheme !== Schemes.GitHubCommit || uri.query === '') {
103+
return undefined;
104+
}
105+
try {
106+
return JSON.parse(uri.query) as GitHubCommitUriParams;
107+
} catch (e) { }
108+
}
109+
110+
export function toGitHubCommitUri(fileName: string, params: GitHubCommitUriParams): vscode.Uri {
111+
return vscode.Uri.from({
112+
scheme: Schemes.GitHubCommit,
113+
path: `/${fileName}`,
114+
query: JSON.stringify(params)
115+
});
116+
}
117+
95118
const ImageMimetypes = ['image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/tiff', 'image/bmp'];
96119
// Known media types that VS Code can handle: https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/base/common/mime.ts#L33-L84
97120
export const KnownMediaExtensions = [
@@ -651,6 +674,7 @@ export enum Schemes {
651674
Repo = 'repo', // New issue file for passing data
652675
Git = 'git', // File content from the git extension
653676
PRQuery = 'prquery', // PR query tree item
677+
GitHubCommit = 'githubcommit' // file content from GitHub for a commit
654678
}
655679

656680
export const COPILOT_QUERY = vscode.Uri.from({ scheme: Schemes.PRQuery, path: 'copilot' });

src/extension.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { CommentDecorationProvider } from './view/commentDecorationProvider';
3939
import { CompareChanges } from './view/compareChangesTreeDataProvider';
4040
import { CreatePullRequestHelper } from './view/createPullRequestHelper';
4141
import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider';
42+
import { GitHubCommitFileSystemProvider } from './view/githubFileContentProvider';
4243
import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider';
4344
import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider';
4445
import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider';
@@ -436,6 +437,8 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll
436437
const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.'));
437438
readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] };
438439
context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage }));
440+
const githubFilesystemProvider = new GitHubCommitFileSystemProvider(reposManager, apiImpl, credentialStore);
441+
context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.GitHubCommit, githubFilesystemProvider, { isReadonly: new vscode.MarkdownString(vscode.l10n.t('GitHub commits cannot be edited')) }));
439442

440443
await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotRemoteAgentManager, themeWatcher);
441444
return apiImpl;

src/github/githubRepository.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export class GitHubRepository extends Disposable {
207207
`github-browse-${this.remote.normalizedHost}-${this.remote.owner}-${this.remote.repositoryName}`,
208208
`Pull Request (${this.remote.owner}/${this.remote.repositoryName})`,
209209
);
210-
this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this._telemetry);
210+
this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this.telemetry);
211211
this._register(this.commentsHandler);
212212
this._register(this.commentsController);
213213
} catch (e) {
@@ -234,7 +234,7 @@ export class GitHubRepository extends Disposable {
234234
public remote: GitHubRemote,
235235
public readonly rootUri: vscode.Uri,
236236
private readonly _credentialStore: CredentialStore,
237-
private readonly _telemetry: ITelemetry,
237+
public readonly telemetry: ITelemetry,
238238
silent: boolean = false
239239
) {
240240
super();
@@ -264,7 +264,7 @@ export class GitHubRepository extends Disposable {
264264
"action": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }
265265
}
266266
*/
267-
this._telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', {
267+
this.telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', {
268268
action: action.context
269269
});
270270

@@ -566,6 +566,19 @@ export class GitHubRepository extends Disposable {
566566
return success;
567567
}
568568

569+
async getCommitParent(ref: string): Promise<string | undefined> {
570+
Logger.debug(`Fetch commit for ref ${ref} - enter`, this.id);
571+
try {
572+
const { octokit, remote } = await this.ensure();
573+
const commit = (await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, ref })).data;
574+
return commit.parents[0].sha;
575+
} catch (e) {
576+
Logger.error(`Fetching commit for ref ${ref} failed: ${e}`, this.id);
577+
}
578+
Logger.debug(`Fetch commit for ref ${ref} - done`, this.id);
579+
}
580+
581+
569582
async getAllPullRequests(page?: number): Promise<PullRequestData | undefined> {
570583
let remote: GitHubRemote | undefined;
571584
try {
@@ -950,7 +963,7 @@ export class GitHubRepository extends Disposable {
950963
if (model) {
951964
model.update(pullRequest);
952965
} else {
953-
model = new PullRequestModel(this._credentialStore, this._telemetry, this, this.remote, pullRequest);
966+
model = new PullRequestModel(this._credentialStore, this.telemetry, this, this.remote, pullRequest);
954967
const prModel = model;
955968
const disposables: vscode.Disposable[] = [];
956969
disposables.push(model.onDidChange(() => this._onPullRequestModelChanged(prModel)));

src/github/pullRequestModel.ts

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import gql from 'graphql-tag';
1010
import * as vscode from 'vscode';
1111
import { Repository } from '../api/api';
1212
import { COPILOT_ACCOUNTS, DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment';
13-
import { getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk';
13+
import { getGitChangeType, getModifiedContentFromDiffHunk, parseDiff } from '../common/diffHunk';
1414
import { commands } from '../common/executeCommands';
1515
import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file';
1616
import { GitHubRef } from '../common/githubRef';
1717
import Logger from '../common/logger';
1818
import { Remote } from '../common/remote';
1919
import { ITelemetry } from '../common/telemetry';
2020
import { ClosedEvent, EventType, ReviewEvent, TimelineEvent } from '../common/timelineEvent';
21-
import { resolvePath, reviewPath, Schemes, toPRUri, toReviewUri } from '../common/uri';
21+
import { resolvePath, Schemes, toGitHubCommitUri, toPRUri, toReviewUri } from '../common/uri';
2222
import { formatError, isDescendant } from '../common/utils';
2323
import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel';
2424
import { OctokitCommon } from './common';
@@ -1333,47 +1333,32 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
13331333
return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args);
13341334
}
13351335

1336-
static async openCommitChanges(folderManager: FolderRepositoryManager, commitSha: string) {
1336+
static async openCommitChanges(githubRepository: GitHubRepository, commitSha: string) {
13371337
try {
1338-
// Get the repository from the folder manager
1339-
const repository = folderManager.repository;
1340-
if (!repository) {
1341-
vscode.window.showErrorMessage(vscode.l10n.t('No repository found'));
1342-
return;
1343-
}
1344-
1345-
// Get the commit to find its parent
1346-
const commit = await repository.getCommit(commitSha);
1347-
if (!commit.parents || commit.parents.length === 0) {
1338+
const parentCommit = await githubRepository.getCommitParent(commitSha);
1339+
if (!parentCommit) {
13481340
vscode.window.showErrorMessage(vscode.l10n.t('Commit {0} has no parent', commitSha.substring(0, 7)));
13491341
return;
13501342
}
1351-
const parentSha = commit.parents[0];
13521343

1353-
// Get the changes between the commit and its parent
1354-
const changes = await repository.diffBetween(parentSha, commitSha);
1355-
if (!changes || changes.length === 0) {
1344+
const changes = await githubRepository.compareCommits(parentCommit, commitSha);
1345+
if (!changes?.files || changes.files.length === 0) {
13561346
vscode.window.showInformationMessage(vscode.l10n.t('No changes found in commit {0}', commitSha.substring(0, 7)));
13571347
return;
13581348
}
13591349

13601350
// Create URI pairs for the multi diff editor using review scheme
13611351
const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = [];
1362-
for (const change of changes) {
1363-
const rightRelativePath = path.relative(repository.rootUri.fsPath, change.uri.fsPath);
1364-
const rightPath = reviewPath(rightRelativePath, commitSha);
1365-
let rightUri = toReviewUri(rightPath, rightRelativePath, undefined, commitSha, false, { base: false }, repository.rootUri);
1366-
1367-
const leftRelativePath = path.relative(repository.rootUri.fsPath, change.originalUri.fsPath);
1368-
const leftPath = reviewPath(leftRelativePath, parentSha);
1369-
let leftUri = toReviewUri(leftPath, (change.status === GitChangeType.RENAME) ? path.relative(repository.rootUri.fsPath, change.originalUri.fsPath) : leftRelativePath, undefined, parentSha, false, { base: true }, repository.rootUri);
1370-
1371-
if (change.status === GitChangeType.ADD) {
1352+
for (const change of changes.files) {
1353+
const rightUri = toGitHubCommitUri(change.filename, { commit: commitSha, owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName });
1354+
const leftUri = toGitHubCommitUri(change.previous_filename ?? change.filename, { commit: parentCommit, owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName });
1355+
const changeType = getGitChangeType(change.status);
1356+
if (changeType === GitChangeType.ADD) {
13721357
// For added files, show against empty
13731358
args.push([rightUri, undefined, rightUri]);
1374-
} else if (change.status === GitChangeType.DELETE) {
1359+
} else if (changeType === GitChangeType.DELETE) {
13751360
// For deleted files, show old version against empty
1376-
args.push([rightPath, leftUri, undefined]);
1361+
args.push([rightUri, leftUri, undefined]);
13771362
} else {
13781363
args.push([rightUri, leftUri, rightUri]);
13791364
}
@@ -1382,7 +1367,7 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
13821367
/* __GDPR__
13831368
"pr.openCommitChanges" : {}
13841369
*/
1385-
folderManager.telemetry.sendTelemetryEvent('pr.openCommitChanges');
1370+
githubRepository.telemetry.sendTelemetryEvent('pr.openCommitChanges');
13861371

13871372
return commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Commit {0}', commitSha.substring(0, 7)), args);
13881373
} catch (error) {

src/github/pullRequestOverview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
538538
private async openCommitChanges(message: IRequestMessage<OpenCommitChangesArgs>): Promise<void> {
539539
try {
540540
const { commitSha } = message.args;
541-
await PullRequestModel.openCommitChanges(this._folderRepositoryManager, commitSha);
541+
await PullRequestModel.openCommitChanges(this._item.githubRepository, commitSha);
542542
} catch (error) {
543543
Logger.error(`Failed to open commit changes: ${formatError(error)}`, PullRequestOverviewPanel.ID);
544544
vscode.window.showErrorMessage(vscode.l10n.t('Failed to open commit changes: {0}', formatError(error)));

src/view/githubFileContentProvider.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { GitApiImpl } from '../api/api1';
8+
import { fromGitHubCommitUri } from '../common/uri';
9+
import { CredentialStore } from '../github/credentials';
10+
import { RepositoriesManager } from '../github/repositoriesManager';
11+
import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider';
12+
13+
export class GitHubCommitFileSystemProvider extends RepositoryFileSystemProvider {
14+
constructor(private readonly repos: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) {
15+
super(gitAPI, credentialStore);
16+
}
17+
18+
override async readFile(uri: vscode.Uri): Promise<Uint8Array> {
19+
await this.waitForAuth();
20+
await this.waitForAnyGitHubRepos(this.repos);
21+
22+
const params = fromGitHubCommitUri(uri);
23+
if (!params) {
24+
throw new Error(`Invalid GitHub commit URI: ${uri.toString()}`);
25+
}
26+
27+
const folderManager = this.repos.getManagerForRepository(params.owner, params.repo);
28+
if (!folderManager) {
29+
throw new Error(`Repository not found for owner: ${params.owner}, repo: ${params.repo}`);
30+
}
31+
32+
const githubRepo = await folderManager.createGitHubRepositoryFromOwnerName(params.owner, params.repo);
33+
if (!githubRepo) {
34+
throw new Error(`GitHub repository not found for owner: ${params.owner}, repo: ${params.repo}`);
35+
}
36+
37+
return githubRepo.getFile(uri.path, params.commit);
38+
}
39+
}

src/view/repositoryFileSystemProvider.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as vscode from 'vscode';
77
import { GitApiImpl } from '../api/api1';
88
import Logger from '../common/logger';
99
import { CredentialStore } from '../github/credentials';
10+
import { RepositoriesManager } from '../github/repositoriesManager';
1011
import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider';
1112

1213
export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemProvider {
@@ -47,4 +48,20 @@ export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemPro
4748
}
4849
return new Promise(resolve => this.credentialStore.onDidGetSession(() => resolve()));
4950
}
51+
52+
protected async waitForAnyGitHubRepos(reposManager: RepositoriesManager): Promise<void> {
53+
// Check if any folder manager already has GitHub repositories
54+
if (reposManager.folderManagers.some(manager => manager.gitHubRepositories.length > 0)) {
55+
return;
56+
}
57+
58+
Logger.appendLine('Waiting for GitHub repositories.', 'RepositoryFileSystemProvider');
59+
return new Promise(resolve => {
60+
const disposable = reposManager.onDidChangeAnyGitHubRepository(() => {
61+
Logger.appendLine('Found GitHub repositories.', 'RepositoryFileSystemProvider');
62+
disposable.dispose();
63+
resolve();
64+
});
65+
});
66+
}
5067
}

webviews/components/timeline.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,8 @@ const CommitEventView = (event: CommitEvent) => {
110110
const pr = context.pr;
111111

112112
const handleCommitClick = (e: React.MouseEvent) => {
113-
if (pr.isCurrentlyCheckedOut) {
114-
e.preventDefault();
115-
context.openCommitChanges(event.sha);
116-
}
117-
// If not checked out, let the default href behavior proceed
113+
e.preventDefault();
114+
context.openCommitChanges(event.sha);
118115
};
119116

120117
return (
@@ -129,7 +126,6 @@ const CommitEventView = (event: CommitEvent) => {
129126
<a
130127
className="message"
131128
onClick={handleCommitClick}
132-
href={pr.isCurrentlyCheckedOut ? undefined : event.htmlUrl}
133129
title={event.htmlUrl}
134130
>
135131
{event.message.substr(0, event.message.indexOf('\n') > -1 ? event.message.indexOf('\n') : event.message.length)}
@@ -140,7 +136,6 @@ const CommitEventView = (event: CommitEvent) => {
140136
<a
141137
className="sha"
142138
onClick={handleCommitClick}
143-
href={pr.isCurrentlyCheckedOut ? undefined : event.htmlUrl}
144139
title={event.htmlUrl}
145140
>
146141
{event.sha.slice(0, 7)}

0 commit comments

Comments
 (0)