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
162 changes: 161 additions & 1 deletion src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,4 +1014,164 @@ 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.
* Supports links from any repository owner to work across forks.
*
* @param bodyHTML - The HTML content to process
* @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,
repoName: string,
authority: string,
fileExistsCheck: (filePath: string) => Promise<boolean>
): Promise<string> {
try {
const escapedRepoName = escapeRegExp(repoName);
const escapedAuthority = escapeRegExp(authority);

// Process blob permalinks (exclude already processed links)
// Allow any owner to support links across forks
const blobPattern = new RegExp(
`<a\\s+(?![^>]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/[^\/]+\/${escapedRepoName}\/blob\/[0-9a-f]{40}\/(?<filePath>[^"#]+)#L(?<startLine>\\d+)(?:-L(?<endLine>\\d+))?"[^>]*?)>(?<linkText>[^<]*?)<\/a>`,
'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;
this._webview?.postMessage(reply);
}

Expand All @@ -82,6 +83,7 @@ export class WebviewBase extends Disposable {
seq: originalMessage?.req,
err: error,
};
await this._waitForReady;
this._webview?.postMessage(reply);
}
}
Expand Down
124 changes: 94 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,29 @@ 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)
);
await vscode.window.showTextDocument(fileUri, {
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