Skip to content

Commit c95339f

Browse files
Link to local file for permalinks in webview (#8583)
* Link to local file for permalinks in webview fixes #8571 * Fix _waitForReady * refactor based on feedback and add diff links * refactor based on feedback * process inside getInitializeContext * extra space * nits and generated tests * fix: remove repoOwner so it works across forks * Fix tests --------- Co-authored-by: Alex Ross <38270282+alexr00@users.noreply.github.com>
1 parent 66058c0 commit c95339f

File tree

9 files changed

+612
-39
lines changed

9 files changed

+612
-39
lines changed

common/views.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,11 @@ export interface OpenCommitChangesArgs {
180180
commitSha: string;
181181
}
182182

183+
export interface OpenLocalFileArgs {
184+
file: string;
185+
startLine: number;
186+
endLine: number;
187+
href: string;
188+
}
189+
183190
// #endregion

src/common/utils.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,4 +1014,164 @@ export function truncate(value: string, maxLength: number, suffix = '...'): stri
10141014
return value;
10151015
}
10161016
return `${value.substr(0, maxLength)}${suffix}`;
1017-
}
1017+
}
1018+
1019+
/**
1020+
* Metadata extracted from code reference link data attributes.
1021+
* This interface defines the contract between the extension (which creates the attributes)
1022+
* and the webview (which reads them).
1023+
*/
1024+
export interface CodeReferenceLinkMetadata {
1025+
localFile: string;
1026+
startLine: number;
1027+
endLine: number;
1028+
linkType: 'blob' | 'diff';
1029+
href: string;
1030+
}
1031+
1032+
/**
1033+
* Extracts code reference link metadata from an anchor element's data attributes.
1034+
* Returns null if any required attributes are missing.
1035+
*/
1036+
export function extractCodeReferenceLinkMetadata(anchor: Element): CodeReferenceLinkMetadata | null {
1037+
const localFile = anchor.getAttribute('data-local-file');
1038+
const startLine = anchor.getAttribute('data-start-line');
1039+
const endLine = anchor.getAttribute('data-end-line');
1040+
const linkType = anchor.getAttribute('data-link-type');
1041+
const href = anchor.getAttribute('href');
1042+
1043+
if (!localFile || !startLine || !endLine || !linkType || !href) {
1044+
return null;
1045+
}
1046+
1047+
return {
1048+
localFile,
1049+
startLine: parseInt(startLine),
1050+
endLine: parseInt(endLine),
1051+
linkType: linkType as 'blob' | 'diff',
1052+
href
1053+
};
1054+
}
1055+
1056+
/**
1057+
* Process GitHub blob permalinks in HTML and add data attributes for local file handling.
1058+
* Finds blob permalinks (e.g., /blob/[sha]/file.ts#L10), checks if files exist locally,
1059+
* and adds data attributes to enable clicking to open local files.
1060+
* Supports links from any repository owner to work across forks.
1061+
*
1062+
* @param bodyHTML - The HTML content to process
1063+
* @param repoName - GitHub repository name
1064+
* @param authority - Git protocol URL authority (e.g., 'github.com')
1065+
* @param fileExistsCheck - Async function that checks if a file exists locally given its relative path
1066+
* @returns Promise resolving to processed HTML
1067+
*/
1068+
export async function processPermalinks(
1069+
bodyHTML: string,
1070+
repoName: string,
1071+
authority: string,
1072+
fileExistsCheck: (filePath: string) => Promise<boolean>
1073+
): Promise<string> {
1074+
try {
1075+
const escapedRepoName = escapeRegExp(repoName);
1076+
const escapedAuthority = escapeRegExp(authority);
1077+
1078+
// Process blob permalinks (exclude already processed links)
1079+
// Allow any owner to support links across forks
1080+
const blobPattern = new RegExp(
1081+
`<a\\s+(?![^>]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/[^\/]+\/${escapedRepoName}\/blob\/[0-9a-f]{40}\/(?<filePath>[^"#]+)#L(?<startLine>\\d+)(?:-L(?<endLine>\\d+))?"[^>]*?)>(?<linkText>[^<]*?)<\/a>`,
1082+
'g'
1083+
);
1084+
1085+
return await stringReplaceAsync(bodyHTML, blobPattern, async (
1086+
fullMatch: string,
1087+
attributes: string,
1088+
filePath: string,
1089+
startLine: string,
1090+
endLine: string | undefined,
1091+
linkText: string
1092+
) => {
1093+
try {
1094+
// Extract the original URL from attributes
1095+
const hrefMatch = attributes.match(/href="([^"]+)"/);
1096+
const originalUrl = hrefMatch ? hrefMatch[1] : '';
1097+
1098+
// Check if file exists locally
1099+
const exists = await fileExistsCheck(filePath);
1100+
if (exists) {
1101+
// File exists - add data attributes for local handling and "(view on GitHub)" suffix
1102+
const endLineValue = endLine || startLine;
1103+
return `<a data-permalink-processed="true" ${attributes} data-local-file="${filePath}" data-start-line="${startLine}" data-end-line="${endLineValue}" data-link-type="blob">${linkText}</a> (<a data-permalink-processed="true" href="${originalUrl}">view on GitHub</a>)`;
1104+
}
1105+
} catch (error) {
1106+
// File doesn't exist or check failed - keep original link
1107+
}
1108+
return fullMatch;
1109+
});
1110+
} catch (error) {
1111+
// Return original HTML if processing fails
1112+
return bodyHTML;
1113+
}
1114+
}
1115+
1116+
/**
1117+
* Process GitHub diff permalinks in HTML and add data attributes for local file handling.
1118+
* Finds diff permalinks (e.g., /pull/123/files#diff-[hash]R10), maps hashes to filenames,
1119+
* and adds data attributes to enable clicking to open diff views.
1120+
*
1121+
* @param bodyHTML - The HTML content to process
1122+
* @param repoOwner - GitHub repository owner
1123+
* @param repoName - GitHub repository name
1124+
* @param authority - Git protocol URL authority (e.g., 'github.com')
1125+
* @param hashMap - Map of diff hashes to file paths
1126+
* @param prNumber - Pull request number
1127+
* @returns Promise resolving to processed HTML
1128+
*/
1129+
export async function processDiffLinks(
1130+
bodyHTML: string,
1131+
repoOwner: string,
1132+
repoName: string,
1133+
authority: string,
1134+
hashMap: Record<string, string>,
1135+
prNumber: number
1136+
): Promise<string> {
1137+
try {
1138+
const escapedRepoName = escapeRegExp(repoName);
1139+
const escapedRepoOwner = escapeRegExp(repoOwner);
1140+
const escapedAuthority = escapeRegExp(authority);
1141+
1142+
const diffPattern = new RegExp(
1143+
`<a\\s+(?![^>]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/${escapedRepoOwner}\/${escapedRepoName}\/pull\/${prNumber}\/(?:files|changes)#diff-(?<diffHash>[a-f0-9]{64})(?:R(?<startLine>\\d+)(?:-R(?<endLine>\\d+))?)?"[^>]*?)>(?<linkText>[^<]*?)<\/a>`,
1144+
'g'
1145+
);
1146+
1147+
return await stringReplaceAsync(bodyHTML, diffPattern, async (
1148+
fullMatch: string,
1149+
attributes: string,
1150+
diffHash: string,
1151+
startLine: string | undefined,
1152+
endLine: string | undefined,
1153+
linkText: string
1154+
) => {
1155+
try {
1156+
// Extract the original URL from attributes
1157+
const hrefMatch = attributes.match(/href="([^"]+)"/);
1158+
const originalUrl = hrefMatch ? hrefMatch[1] : '';
1159+
1160+
// Look up filename from hash
1161+
const fileName = hashMap[diffHash];
1162+
if (fileName) {
1163+
// Hash found - add data attributes for diff handling and "(view on GitHub)" suffix
1164+
const startLineValue = startLine || '1';
1165+
const endLineValue = endLine || startLineValue;
1166+
return `<a data-permalink-processed="true" ${attributes} data-local-file="${fileName}" data-start-line="${startLineValue}" data-end-line="${endLineValue}" data-link-type="diff">${linkText}</a> (<a data-permalink-processed="true" href="${originalUrl}">view on GitHub</a>)`;
1167+
}
1168+
} catch (error) {
1169+
// Failed to process - keep original link
1170+
}
1171+
return fullMatch;
1172+
});
1173+
} catch (error) {
1174+
// Return original HTML if processing fails
1175+
return bodyHTML;
1176+
}
1177+
}

src/common/webview.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export class WebviewBase extends Disposable {
7474
seq: originalMessage.req,
7575
res: message,
7676
};
77+
await this._waitForReady;
7778
this._webview?.postMessage(reply);
7879
}
7980

@@ -82,6 +83,7 @@ export class WebviewBase extends Disposable {
8283
seq: originalMessage?.req,
8384
err: error,
8485
};
86+
await this._waitForReady;
8587
this._webview?.postMessage(reply);
8688
}
8789
}

src/github/issueOverview.ts

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
'use strict';
66

77
import * as vscode from 'vscode';
8-
import { CloseResult } from '../../common/views';
8+
import { CloseResult, OpenLocalFileArgs } from '../../common/views';
99
import { openPullRequestOnGitHub } from '../commands';
1010
import { FolderRepositoryManager } from './folderRepositoryManager';
1111
import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface';
1212
import { IssueModel } from './issueModel';
1313
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks';
14-
import { isInCodespaces, vscodeDevPrLink } from './utils';
14+
import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils';
1515
import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity } from './views';
1616
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
1717
import { emojify, ensureEmojis } from '../common/emoji';
@@ -249,7 +249,7 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
249249
return isInCodespaces();
250250
}
251251

252-
protected getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Issue {
252+
protected async getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Promise<Issue> {
253253
const hasWritePermission = repositoryAccess.hasWritePermission;
254254
const canEdit = hasWritePermission || viewerCanEdit;
255255
const labels = issue.item.labels.map(label => ({
@@ -266,12 +266,12 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
266266
url: issue.html_url,
267267
createdAt: issue.createdAt,
268268
body: issue.body,
269-
bodyHTML: issue.bodyHTML,
269+
bodyHTML: await this.processLinksInBodyHtml(issue.bodyHTML),
270270
labels: labels,
271271
author: issue.author,
272272
state: issue.state,
273273
stateReason: issue.stateReason,
274-
events: timelineEvents,
274+
events: await this.processTimelineEvents(timelineEvents),
275275
continueOnGitHub: this.continueOnGitHub(),
276276
canEdit,
277277
hasWritePermission,
@@ -321,10 +321,13 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
321321
this._item = issue as TItem;
322322
this.setPanelTitle(this.buildPanelTitle(issueModel.number, issueModel.title));
323323

324+
// Process permalinks in bodyHTML before sending to webview
325+
const context = await this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []);
326+
324327
Logger.debug('pr.initialize', IssueOverviewPanel.ID);
325328
this._postMessage({
326329
command: 'pr.initialize',
327-
pullrequest: this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []),
330+
pullrequest: context,
328331
});
329332

330333
} catch (e) {
@@ -445,6 +448,8 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
445448
return this.copyVscodeDevLink();
446449
case 'pr.openOnGitHub':
447450
return openPullRequestOnGitHub(this._item, this._telemetry);
451+
case 'pr.open-local-file':
452+
return this.openLocalFile(message);
448453
case 'pr.debug':
449454
return this.webviewDebug(message);
450455
default:
@@ -568,16 +573,54 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
568573
Logger.debug(message.args, IssueOverviewPanel.ID);
569574
}
570575

571-
private editDescription(message: IRequestMessage<{ text: string }>) {
572-
this._item
573-
.edit({ body: message.args.text })
574-
.then(result => {
575-
this._replyMessage(message, { body: result.body, bodyHTML: result.bodyHTML });
576-
})
577-
.catch(e => {
578-
this._throwError(message, e);
579-
vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`);
580-
});
576+
/**
577+
* Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel)
578+
* to provide custom processing logic for different item types.
579+
* Returns undefined if bodyHTML is undefined.
580+
*/
581+
protected async processLinksInBodyHtml(bodyHTML: string | undefined): Promise<string | undefined> {
582+
if (!bodyHTML) {
583+
return bodyHTML;
584+
}
585+
return processPermalinks(
586+
bodyHTML,
587+
this._item.githubRepository,
588+
this._item.githubRepository.rootUri
589+
);
590+
}
591+
592+
/**
593+
* Process code reference links in timeline events (comments, reviews, commits).
594+
* Updates bodyHTML fields for all events that contain them.
595+
*/
596+
protected async processTimelineEvents(events: TimelineEvent[]): Promise<TimelineEvent[]> {
597+
return Promise.all(events.map(async (event) => {
598+
// Create a shallow copy to avoid mutating the original
599+
const processedEvent = { ...event };
600+
601+
if (processedEvent.event === EventType.Commented || processedEvent.event === EventType.Reviewed || processedEvent.event === EventType.Committed) {
602+
processedEvent.bodyHTML = await this.processLinksInBodyHtml(processedEvent.bodyHTML);
603+
// ReviewEvent also has comments array
604+
if (processedEvent.event === EventType.Reviewed && processedEvent.comments) {
605+
processedEvent.comments = await Promise.all(processedEvent.comments.map(async (comment: IComment) => ({
606+
...comment,
607+
bodyHTML: await this.processLinksInBodyHtml(comment.bodyHTML)
608+
})));
609+
}
610+
}
611+
return processedEvent;
612+
}));
613+
}
614+
615+
private async editDescription(message: IRequestMessage<{ text: string }>) {
616+
try {
617+
const result = await this._item.edit({ body: message.args.text });
618+
const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML);
619+
this._replyMessage(message, { body: result.body, bodyHTML });
620+
} catch (e) {
621+
this._throwError(message, e);
622+
vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`);
623+
}
581624
}
582625
private editTitle(message: IRequestMessage<{ text: string }>) {
583626
return this._item
@@ -591,8 +634,9 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
591634
});
592635
}
593636

594-
protected _getTimeline(): Promise<TimelineEvent[]> {
595-
return this._item.getIssueTimelineEvents();
637+
protected async _getTimeline(): Promise<TimelineEvent[]> {
638+
const events = await this._item.getIssueTimelineEvents();
639+
return this.processTimelineEvents(events);
596640
}
597641

598642
private async changeAssignees(message: IRequestMessage<void>): Promise<void> {
@@ -726,18 +770,15 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
726770
return this._item.editIssueComment(comment, text);
727771
}
728772

729-
private editComment(message: IRequestMessage<{ comment: IComment; text: string }>) {
730-
this.editCommentPromise(message.args.comment, message.args.text)
731-
.then(result => {
732-
this._replyMessage(message, {
733-
body: result.body,
734-
bodyHTML: result.bodyHTML,
735-
});
736-
})
737-
.catch(e => {
738-
this._throwError(message, e);
739-
vscode.window.showErrorMessage(formatError(e));
740-
});
773+
private async editComment(message: IRequestMessage<{ comment: IComment; text: string }>) {
774+
try {
775+
const result = await this.editCommentPromise(message.args.comment, message.args.text);
776+
const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML);
777+
this._replyMessage(message, { body: result.body, bodyHTML });
778+
} catch (e) {
779+
this._throwError(message, e);
780+
vscode.window.showErrorMessage(formatError(e));
781+
}
741782
}
742783

743784
protected deleteCommentPromise(comment: IComment): Promise<void> {
@@ -761,6 +802,29 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
761802
});
762803
}
763804

805+
protected async openLocalFile(message: IRequestMessage<OpenLocalFileArgs>): Promise<void> {
806+
try {
807+
const { file, startLine, endLine } = message.args;
808+
// Resolve relative path to absolute using repository root
809+
const fileUri = vscode.Uri.joinPath(
810+
this._item.githubRepository.rootUri,
811+
file
812+
);
813+
const selection = new vscode.Range(
814+
new vscode.Position(startLine - 1, 0),
815+
new vscode.Position(endLine - 1, Number.MAX_SAFE_INTEGER)
816+
);
817+
await vscode.window.showTextDocument(fileUri, {
818+
selection,
819+
viewColumn: vscode.ViewColumn.One
820+
});
821+
} catch (e) {
822+
Logger.error(`Open local file failed: ${formatError(e)}`, IssueOverviewPanel.ID);
823+
// Fallback to opening external URL
824+
await vscode.env.openExternal(vscode.Uri.parse(message.args.href));
825+
}
826+
}
827+
764828
protected async close(message: IRequestMessage<string>) {
765829
let comment: IComment | undefined;
766830
if (message.args) {

0 commit comments

Comments
 (0)