Skip to content
7 changes: 7 additions & 0 deletions common/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,11 @@ export interface OpenCommitChangesArgs {
commitSha: string;
}

export interface OpenLocalFileArgs {
file: string;
startLine: number;
endLine: number;
href: string;
}

// #endregion
163 changes: 162 additions & 1 deletion src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,4 +1014,165 @@ export function truncate(value: string, maxLength: number, suffix = '...'): stri
return value;
}
return `${value.substr(0, maxLength)}${suffix}`;
}
}

/**
* Metadata extracted from code reference link data attributes.
* This interface defines the contract between the extension (which creates the attributes)
* and the webview (which reads them).
*/
export interface CodeReferenceLinkMetadata {
localFile: string;
startLine: number;
endLine: number;
linkType: 'blob' | 'diff';
href: string;
}

/**
* Extracts code reference link metadata from an anchor element's data attributes.
* Returns null if any required attributes are missing.
*/
export function extractCodeReferenceLinkMetadata(anchor: Element): CodeReferenceLinkMetadata | null {
const localFile = anchor.getAttribute('data-local-file');
const startLine = anchor.getAttribute('data-start-line');
const endLine = anchor.getAttribute('data-end-line');
const linkType = anchor.getAttribute('data-link-type');
const href = anchor.getAttribute('href');

if (!localFile || !startLine || !endLine || !linkType || !href) {
return null;
}

return {
localFile,
startLine: parseInt(startLine),
endLine: parseInt(endLine),
linkType: linkType as 'blob' | 'diff',
href
};
}

/**
* Process GitHub blob permalinks in HTML and add data attributes for local file handling.
* Finds blob permalinks (e.g., /blob/[sha]/file.ts#L10), checks if files exist locally,
* and adds data attributes to enable clicking to open local files.
*
* @param bodyHTML - The HTML content to process
* @param repoOwner - GitHub repository owner
* @param repoName - GitHub repository name
* @param authority - Git protocol URL authority (e.g., 'github.com')
* @param fileExistsCheck - Async function that checks if a file exists locally given its relative path
* @returns Promise resolving to processed HTML
*/
export async function processPermalinks(
bodyHTML: string,
repoOwner: string,
repoName: string,
authority: string,
fileExistsCheck: (filePath: string) => Promise<boolean>
): Promise<string> {
try {
const escapedRepoName = escapeRegExp(repoName);
const escapedRepoOwner = escapeRegExp(repoOwner);
const escapedAuthority = escapeRegExp(authority);

// Process blob permalinks (exclude already processed links)
const blobPattern = new RegExp(
`<a\\s+(?![^>]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/${escapedRepoOwner}\/${escapedRepoName}\/blob\/[0-9a-f]{40}\/(?<filePath>[^"#]+)#L(?<startLine>\\d+)(?:-L(?<endLine>\\d+))?"[^>]*?)>(?<linkText>[^<]*?)<\/a>`,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should a link like this work (I know it's a private repo, but the structure of the link): https://github.com/alexr00/playground/blob/f86be02708e48ed4648b224771995c7573213946/readme.md?#L6

I'm not seeing it get replaced for a PR in that repo.

Copy link
Copy Markdown
Contributor Author

@Daniel-Aaron-Bloom Daniel-Aaron-Bloom Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should work, I'm using it in private repos right now (and it's being very helpful). It only doesn't replace the permalinks if the file doesn't exist locally (so you have to check out a PR branch before opening the view) and diff links if the files isn't present in the diff.

These two should show up in this repo, although at the time I made this comment the latter did not because of the different repoOwner (it has since been updated and now both appear to work):

https://github.com/microsoft/vscode-pull-request-github/pull/8583/changes#diff-0660e0b0f883e874d471ed55dbd1bc1a9e009f9422952d55ac7979b8daa23141R77

https://github.com/Daniel-Aaron-Bloom/vscode-pull-request-github/blob/854b78ed243e91c2e431dca323c33a06c2fe343e/common/views.ts#L14

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2026-03-18 at 8 58 48 AM

All 4 are clickable and work properly for me.

'g'
);

return await stringReplaceAsync(bodyHTML, blobPattern, async (
fullMatch: string,
attributes: string,
filePath: string,
startLine: string,
endLine: string | undefined,
linkText: string
) => {
try {
// Extract the original URL from attributes
const hrefMatch = attributes.match(/href="([^"]+)"/);
const originalUrl = hrefMatch ? hrefMatch[1] : '';

// Check if file exists locally
const exists = await fileExistsCheck(filePath);
if (exists) {
// File exists - add data attributes for local handling and "(view on GitHub)" suffix
const endLineValue = endLine || startLine;
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>)`;
}
} catch (error) {
// File doesn't exist or check failed - keep original link
}
return fullMatch;
});
} catch (error) {
// Return original HTML if processing fails
return bodyHTML;
}
}

/**
* Process GitHub diff permalinks in HTML and add data attributes for local file handling.
* Finds diff permalinks (e.g., /pull/123/files#diff-[hash]R10), maps hashes to filenames,
* and adds data attributes to enable clicking to open diff views.
*
* @param bodyHTML - The HTML content to process
* @param repoOwner - GitHub repository owner
* @param repoName - GitHub repository name
* @param authority - Git protocol URL authority (e.g., 'github.com')
* @param hashMap - Map of diff hashes to file paths
* @param prNumber - Pull request number
* @returns Promise resolving to processed HTML
*/
export async function processDiffLinks(
bodyHTML: string,
repoOwner: string,
repoName: string,
authority: string,
hashMap: Record<string, string>,
prNumber: number
): Promise<string> {
try {
const escapedRepoName = escapeRegExp(repoName);
const escapedRepoOwner = escapeRegExp(repoOwner);
const escapedAuthority = escapeRegExp(authority);

const diffPattern = new RegExp(
`<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>`,
'g'
);

return await stringReplaceAsync(bodyHTML, diffPattern, async (
fullMatch: string,
attributes: string,
diffHash: string,
startLine: string | undefined,
endLine: string | undefined,
linkText: string
) => {
try {
// Extract the original URL from attributes
const hrefMatch = attributes.match(/href="([^"]+)"/);
const originalUrl = hrefMatch ? hrefMatch[1] : '';

// Look up filename from hash
const fileName = hashMap[diffHash];
if (fileName) {
// Hash found - add data attributes for diff handling and "(view on GitHub)" suffix
const startLineValue = startLine || '1';
const endLineValue = endLine || startLineValue;
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>)`;
}
} catch (error) {
// Failed to process - keep original link
}
return fullMatch;
});
} catch (error) {
// Return original HTML if processing fails
return bodyHTML;
}
}
2 changes: 2 additions & 0 deletions src/common/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class WebviewBase extends Disposable {
seq: originalMessage.req,
res: message,
};
await this._waitForReady;
Comment thread
alexr00 marked this conversation as resolved.
this._webview?.postMessage(reply);
}

Expand All @@ -82,6 +83,7 @@ export class WebviewBase extends Disposable {
seq: originalMessage?.req,
err: error,
};
await this._waitForReady;
Comment thread
alexr00 marked this conversation as resolved.
this._webview?.postMessage(reply);
}
}
Expand Down
125 changes: 95 additions & 30 deletions src/github/issueOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
'use strict';

import * as vscode from 'vscode';
import { CloseResult } from '../../common/views';
import { CloseResult, OpenLocalFileArgs } from '../../common/views';
import { openPullRequestOnGitHub } from '../commands';
import { FolderRepositoryManager } from './folderRepositoryManager';
import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface';
import { IssueModel } from './issueModel';
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks';
import { isInCodespaces, vscodeDevPrLink } from './utils';
import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils';
import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity } from './views';
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
import { emojify, ensureEmojis } from '../common/emoji';
Expand Down Expand Up @@ -249,7 +249,7 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
return isInCodespaces();
}

protected getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Issue {
protected async getInitializeContext(currentUser: IAccount, issue: IssueModel, timelineEvents: TimelineEvent[], repositoryAccess: RepoAccessAndMergeMethods, viewerCanEdit: boolean, assignableUsers: IAccount[]): Promise<Issue> {
const hasWritePermission = repositoryAccess.hasWritePermission;
const canEdit = hasWritePermission || viewerCanEdit;
const labels = issue.item.labels.map(label => ({
Expand All @@ -266,12 +266,12 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
url: issue.html_url,
createdAt: issue.createdAt,
body: issue.body,
bodyHTML: issue.bodyHTML,
bodyHTML: await this.processLinksInBodyHtml(issue.bodyHTML),
labels: labels,
author: issue.author,
state: issue.state,
stateReason: issue.stateReason,
events: timelineEvents,
events: await this.processTimelineEvents(timelineEvents),
continueOnGitHub: this.continueOnGitHub(),
canEdit,
hasWritePermission,
Expand Down Expand Up @@ -321,10 +321,13 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
this._item = issue as TItem;
this.setPanelTitle(this.buildPanelTitle(issueModel.number, issueModel.title));

// Process permalinks in bodyHTML before sending to webview
const context = await this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []);

Logger.debug('pr.initialize', IssueOverviewPanel.ID);
this._postMessage({
command: 'pr.initialize',
pullrequest: this.getInitializeContext(currentUser, issue, timelineEvents, repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []),
pullrequest: context,
});

} catch (e) {
Expand Down Expand Up @@ -445,6 +448,8 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
return this.copyVscodeDevLink();
case 'pr.openOnGitHub':
return openPullRequestOnGitHub(this._item, this._telemetry);
case 'pr.open-local-file':
return this.openLocalFile(message);
case 'pr.debug':
return this.webviewDebug(message);
default:
Expand Down Expand Up @@ -568,16 +573,54 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
Logger.debug(message.args, IssueOverviewPanel.ID);
}

private editDescription(message: IRequestMessage<{ text: string }>) {
this._item
.edit({ body: message.args.text })
.then(result => {
this._replyMessage(message, { body: result.body, bodyHTML: result.bodyHTML });
})
.catch(e => {
this._throwError(message, e);
vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`);
});
/**
* Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel)
* to provide custom processing logic for different item types.
* Returns undefined if bodyHTML is undefined.
*/
protected async processLinksInBodyHtml(bodyHTML: string | undefined): Promise<string | undefined> {
if (!bodyHTML) {
return bodyHTML;
}
return processPermalinks(
bodyHTML,
this._item.githubRepository,
this._item.githubRepository.rootUri
);
}

/**
* Process code reference links in timeline events (comments, reviews, commits).
* Updates bodyHTML fields for all events that contain them.
*/
protected async processTimelineEvents(events: TimelineEvent[]): Promise<TimelineEvent[]> {
return Promise.all(events.map(async (event) => {
// Create a shallow copy to avoid mutating the original
const processedEvent = { ...event };

if (processedEvent.event === EventType.Commented || processedEvent.event === EventType.Reviewed || processedEvent.event === EventType.Committed) {
processedEvent.bodyHTML = await this.processLinksInBodyHtml(processedEvent.bodyHTML);
// ReviewEvent also has comments array
if (processedEvent.event === EventType.Reviewed && processedEvent.comments) {
processedEvent.comments = await Promise.all(processedEvent.comments.map(async (comment: IComment) => ({
...comment,
bodyHTML: await this.processLinksInBodyHtml(comment.bodyHTML)
})));
}
}
return processedEvent;
}));
}

private async editDescription(message: IRequestMessage<{ text: string }>) {
try {
const result = await this._item.edit({ body: message.args.text });
const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML);
this._replyMessage(message, { body: result.body, bodyHTML });
} catch (e) {
this._throwError(message, e);
vscode.window.showErrorMessage(`Editing description failed: ${formatError(e)}`);
}
}
private editTitle(message: IRequestMessage<{ text: string }>) {
return this._item
Expand All @@ -591,8 +634,9 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
});
}

protected _getTimeline(): Promise<TimelineEvent[]> {
return this._item.getIssueTimelineEvents();
protected async _getTimeline(): Promise<TimelineEvent[]> {
const events = await this._item.getIssueTimelineEvents();
return this.processTimelineEvents(events);
}

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

private editComment(message: IRequestMessage<{ comment: IComment; text: string }>) {
this.editCommentPromise(message.args.comment, message.args.text)
.then(result => {
this._replyMessage(message, {
body: result.body,
bodyHTML: result.bodyHTML,
});
})
.catch(e => {
this._throwError(message, e);
vscode.window.showErrorMessage(formatError(e));
});
private async editComment(message: IRequestMessage<{ comment: IComment; text: string }>) {
try {
const result = await this.editCommentPromise(message.args.comment, message.args.text);
const bodyHTML = await this.processLinksInBodyHtml(result.bodyHTML);
this._replyMessage(message, { body: result.body, bodyHTML });
} catch (e) {
this._throwError(message, e);
vscode.window.showErrorMessage(formatError(e));
}
}

protected deleteCommentPromise(comment: IComment): Promise<void> {
Expand All @@ -761,6 +802,30 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
});
}

protected async openLocalFile(message: IRequestMessage<OpenLocalFileArgs>): Promise<void> {
try {
const { file, startLine, endLine } = message.args;
// Resolve relative path to absolute using repository root
const fileUri = vscode.Uri.joinPath(
this._item.githubRepository.rootUri,
file
);
const selection = new vscode.Range(
new vscode.Position(startLine - 1, 0),
new vscode.Position(endLine - 1, Number.MAX_SAFE_INTEGER)
);
const document = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(document, {
selection,
viewColumn: vscode.ViewColumn.One
});
} catch (e) {
Logger.error(`Open local file failed: ${formatError(e)}`, IssueOverviewPanel.ID);
// Fallback to opening external URL
await vscode.env.openExternal(vscode.Uri.parse(message.args.href));
}
}

protected async close(message: IRequestMessage<string>) {
let comment: IComment | undefined;
if (message.args) {
Expand Down
Loading