diff --git a/common/views.ts b/common/views.ts index 5682e9b011..9e2f35fdf2 100644 --- a/common/views.ts +++ b/common/views.ts @@ -180,4 +180,11 @@ export interface OpenCommitChangesArgs { commitSha: string; } +export interface OpenLocalFileArgs { + file: string; + startLine: number; + endLine: number; + href: string; +} + // #endregion \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 0f36f1202b..4df10e850f 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1014,4 +1014,164 @@ export function truncate(value: string, maxLength: number, suffix = '...'): stri return value; } return `${value.substr(0, maxLength)}${suffix}`; -} \ No newline at end of file +} + +/** + * 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 +): Promise { + 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( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/[^\/]+\/${escapedRepoName}\/blob\/[0-9a-f]{40}\/(?[^"#]+)#L(?\\d+)(?:-L(?\\d+))?"[^>]*?)>(?[^<]*?)<\/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 `${linkText} (view on GitHub)`; + } + } 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, + prNumber: number +): Promise { + try { + const escapedRepoName = escapeRegExp(repoName); + const escapedRepoOwner = escapeRegExp(repoOwner); + const escapedAuthority = escapeRegExp(authority); + + const diffPattern = new RegExp( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${escapedAuthority}\/${escapedRepoOwner}\/${escapedRepoName}\/pull\/${prNumber}\/(?:files|changes)#diff-(?[a-f0-9]{64})(?:R(?\\d+)(?:-R(?\\d+))?)?"[^>]*?)>(?[^<]*?)<\/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 `${linkText} (view on GitHub)`; + } + } catch (error) { + // Failed to process - keep original link + } + return fullMatch; + }); + } catch (error) { + // Return original HTML if processing fails + return bodyHTML; + } +} diff --git a/src/common/webview.ts b/src/common/webview.ts index f887fd349b..a41fab61e4 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -74,6 +74,7 @@ export class WebviewBase extends Disposable { seq: originalMessage.req, res: message, }; + await this._waitForReady; this._webview?.postMessage(reply); } @@ -82,6 +83,7 @@ export class WebviewBase extends Disposable { seq: originalMessage?.req, err: error, }; + await this._waitForReady; this._webview?.postMessage(reply); } } diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 67f3aef622..17f8107ad4 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -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'; @@ -249,7 +249,7 @@ export class IssueOverviewPanel 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 { const hasWritePermission = repositoryAccess.hasWritePermission; const canEdit = hasWritePermission || viewerCanEdit; const labels = issue.item.labels.map(label => ({ @@ -266,12 +266,12 @@ export class IssueOverviewPanel 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, @@ -321,10 +321,13 @@ export class IssueOverviewPanel 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) { @@ -445,6 +448,8 @@ export class IssueOverviewPanel 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: @@ -568,16 +573,54 @@ export class IssueOverviewPanel 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 { + 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 { + 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 @@ -591,8 +634,9 @@ export class IssueOverviewPanel extends W }); } - protected _getTimeline(): Promise { - return this._item.getIssueTimelineEvents(); + protected async _getTimeline(): Promise { + const events = await this._item.getIssueTimelineEvents(); + return this.processTimelineEvents(events); } private async changeAssignees(message: IRequestMessage): Promise { @@ -726,18 +770,15 @@ export class IssueOverviewPanel 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 { @@ -761,6 +802,29 @@ export class IssueOverviewPanel extends W }); } + protected async openLocalFile(message: IRequestMessage): Promise { + 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) { let comment: IComment | undefined; if (message.args) { diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index b47e103f91..d1863282af 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; -import { OpenCommitChangesArgs } from '../../common/views'; +import { OpenCommitChangesArgs, OpenLocalFileArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; import { getCopilotApi } from './copilotApi'; import { SessionIdForPr } from './copilotRemoteAgent'; @@ -26,7 +27,7 @@ import { IssueOverviewPanel, panelKey } from './issueOverview'; import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel'; import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon'; import { branchPicks, pickEmail, reviewersQuickPick } from './quickPicks'; -import { parseReviewers } from './utils'; +import { parseReviewers, processDiffLinks, processPermalinks } from './utils'; import { CancelCodingAgentReply, ChangeBaseReply, ChangeReviewersReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReadyForReviewAndMergeContext, ReadyForReviewContext, ReviewCommentContext, ReviewType, UnresolvedIdentity } from './views'; import { debounce } from '../common/async'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; @@ -233,6 +234,38 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + if (!bodyHTML) { + return bodyHTML; + } + // Check cache first, otherwise fetch raw file changes + const rawFileChanges = this._item.rawFileChanges ?? await this._item.getRawFileChangesInfo(); + + // Create hash-to-filename mapping for diff links + const hashMap: Record = {}; + rawFileChanges.forEach(file => { + const hash = crypto.createHash('sha256').update(file.filename).digest('hex'); + hashMap[hash] = file.filename; + }); + + let result = await processPermalinks( + bodyHTML, + this._item.githubRepository, + this._item.githubRepository.rootUri + ); + result = await processDiffLinks( + result, + this._item.githubRepository, + hashMap, + this._item.number + ); + return result; + } + protected override onDidChangeViewState(e: vscode.WebviewPanelOnDidChangeViewStateEvent): void { super.onDidChangeViewState(e); this.setVisibilityContext(); @@ -383,7 +416,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel COPILOT_ACCOUNTS[user.login]); const isCopilotAlreadyReviewer = this._existingReviewers.some(reviewer => !isITeam(reviewer.reviewer) && reviewer.reviewer.login === COPILOT_REVIEWER); - const baseContext = this.getInitializeContext(currentUser, pullRequest, timelineEvents, repositoryAccess, viewerCanEdit, users); + const baseContext = await this.getInitializeContext(currentUser, pullRequest, timelineEvents, repositoryAccess, viewerCanEdit, users); this.preLoadInfoNotRequiredForOverview(pullRequest); @@ -535,6 +568,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - return this._item.getTimelineEvents(); + protected override async _getTimeline(): Promise { + const events = await this._item.getTimelineEvents(); + return this.processTimelineEvents(events); } private async openDiff(message: IRequestMessage<{ comment: IComment }>): Promise { @@ -638,6 +674,36 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + try { + const { file, startLine } = message.args; + const fileChanges = await this._item.getFileChangesInfo(); + const change = fileChanges.find( + fileChange => fileChange.fileName === file || fileChange.previousFileName === file, + ); + + if (change) { + + const pathSegments = file.split('/'); + // GitHub line numbers are 1-indexed, VSCode selection API is 0-indexed + await PullRequestModel.openDiff( + this._folderRepositoryManager, + this._item, + change, + pathSegments[pathSegments.length - 1], + startLine - 1, + ); + return; + } + Logger.warn(`Could not find file ${file} in PR changes`, PullRequestOverviewPanel.ID); + } catch (e) { + Logger.error(`Open diff from link failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + } + + // Fallback to opening external URL + await vscode.env.openExternal(vscode.Uri.parse(message.args.href)); + } + private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise { try { const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); diff --git a/src/github/utils.ts b/src/github/utils.ts index 24c42a4508..62035f7119 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -55,7 +55,7 @@ import { Remote } from '../common/remote'; import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; import * as Common from '../common/timelineEvent'; import { DataUri, toOpenIssueWebviewUri, toOpenPullRequestWebviewUri } from '../common/uri'; -import { escapeRegExp, gitHubLabelColor, stringReplaceAsync, uniqBy } from '../common/utils'; +import { escapeRegExp, gitHubLabelColor, processDiffLinks as processDiffLinksCore, processPermalinks as processPermalinksCore, stringReplaceAsync, uniqBy } from '../common/utils'; export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/; @@ -334,6 +334,61 @@ async function transformHtmlUrlsToExtensionUrls(body: string, githubRepository: }); } +/** + * 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. + */ +export async function processPermalinks( + bodyHTML: string, + githubRepository: GitHubRepository, + rootUri: vscode.Uri +): Promise { + try { + const repoName = githubRepository.remote.repositoryName; + const authority = githubRepository.remote.gitProtocol.url.authority; + + // Create file existence check callback + const fileExistsCheck = async (filePath: string): Promise => { + try { + const localFileUri = vscode.Uri.joinPath(rootUri, filePath); + const stat = await vscode.workspace.fs.stat(localFileUri); + return stat.type === vscode.FileType.File; + } catch { + return false; + } + }; + + return await processPermalinksCore(bodyHTML, repoName, authority, fileExistsCheck); + } catch (error) { + Logger.error(`Failed to process blob permalinks in HTML: ${error}`, 'processPermalinks'); + 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. + */ +export async function processDiffLinks( + bodyHTML: string, + githubRepository: GitHubRepository, + hashMap: Record, + prNumber: number +): Promise { + try { + const repoName = githubRepository.remote.repositoryName; + const repoOwner = githubRepository.remote.owner; + const authority = githubRepository.remote.gitProtocol.url.authority; + + return await processDiffLinksCore(bodyHTML, repoOwner, repoName, authority, hashMap, prNumber); + } catch (error) { + Logger.error(`Failed to process diff permalinks in HTML: ${error}`, 'processDiffLinks'); + return bodyHTML; + } +} + export function convertRESTPullRequestToRawPullRequest( pullRequest: | OctokitCommon.PullsGetResponseData @@ -1886,4 +1941,4 @@ export async function extractRepoFromQuery(folderManager: FolderRepositoryManage } return undefined; -} \ No newline at end of file +} diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index aabe5b77d3..2b8e5585b2 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -51,4 +51,188 @@ describe('utils', () => { assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); }); }); + + describe('processPermalinks', () => { + const repoOwner = 'microsoft'; + const repoName = 'vscode'; + const authority = 'github.com'; + const sha = 'a'.repeat(40); + + function makePermalink(filePath: string, startLine: number, endLine?: number): string { + const lineRef = endLine ? `#L${startLine}-L${endLine}` : `#L${startLine}`; + return `link text`; + } + + it('should add data attributes when file exists locally', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="10"')); + assert(result.includes('data-link-type="blob"')); + assert(result.includes('data-permalink-processed="true"')); + assert(result.includes('view on GitHub')); + }); + + it('should set end line when range is specified', async () => { + const html = makePermalink('src/file.ts', 10, 20); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="20"')); + }); + + it('should not modify links when file does not exist locally', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => false); + + assert.strictEqual(result, html); + }); + + it('should not modify non-permalink links', async () => { + const html = 'example'; + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should not modify links to a different repo', async () => { + const html = `link`; + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should skip already processed links', async () => { + const html = `link`; + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert.strictEqual(result, html); + }); + + it('should process multiple links independently', async () => { + const html = makePermalink('src/exists.ts', 1) + makePermalink('src/missing.ts', 2); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async (path) => path === 'src/exists.ts'); + + assert(result.includes('data-local-file="src/exists.ts"')); + assert(!result.includes('data-local-file="src/missing.ts"')); + }); + + it('should return original HTML when fileExistsCheck throws', async () => { + const html = makePermalink('src/file.ts', 10); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => { throw new Error('fail'); }); + + assert.strictEqual(result, html); + }); + + it('should handle links without surrounding text', async () => { + const html = makePermalink('src/file.ts', 5); + const result = await utils.processPermalinks(html, repoOwner, repoName, authority, async () => true); + + assert(result.includes('link text')); + assert(result.includes('data-local-file="src/file.ts"')); + }); + }); + + describe('processDiffLinks', () => { + const repoOwner = 'microsoft'; + const repoName = 'vscode'; + const authority = 'github.com'; + const prNumber = 123; + const diffHash = 'a'.repeat(64); + + function makeDiffLink(hash: string, startLine?: number, endLine?: number, variant: 'files' | 'changes' = 'files'): string { + let fragment = `diff-${hash}`; + if (startLine !== undefined) { + fragment += `R${startLine}`; + if (endLine !== undefined) { + fragment += `-R${endLine}`; + } + } + return `link text`; + } + + it('should add data attributes when hash maps to a file', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 10); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="10"')); + assert(result.includes('data-link-type="diff"')); + assert(result.includes('data-permalink-processed="true"')); + assert(result.includes('view on GitHub')); + }); + + it('should set end line when range is specified', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 10, 20); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-start-line="10"')); + assert(result.includes('data-end-line="20"')); + }); + + it('should default start line to 1 when no line is specified', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-start-line="1"')); + assert(result.includes('data-end-line="1"')); + }); + + it('should not modify links when hash is not in the map', async () => { + const hashMap: Record = {}; + const html = makeDiffLink(diffHash, 10); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should not modify non-diff links', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = 'example'; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should not modify links to a different repo', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = `link`; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should skip already processed links', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = `link`; + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert.strictEqual(result, html); + }); + + it('should match links using changes variant', async () => { + const hashMap: Record = { [diffHash]: 'src/file.ts' }; + const html = makeDiffLink(diffHash, 5, undefined, 'changes'); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/file.ts"')); + assert(result.includes('data-start-line="5"')); + }); + + it('should process multiple links independently', async () => { + const otherHash = 'b'.repeat(64); + const hashMap: Record = { [diffHash]: 'src/found.ts' }; + const html = makeDiffLink(diffHash, 1) + makeDiffLink(otherHash, 2); + const result = await utils.processDiffLinks(html, repoOwner, repoName, authority, hashMap, prNumber); + + assert(result.includes('data-local-file="src/found.ts"')); + assert(!result.includes('data-local-file="src/other.ts"')); + }); + }); }); diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 55cb61836f..9f4d8dd948 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -7,7 +7,7 @@ import { createContext } from 'react'; import { getState, setState, updateState } from './cache'; import { COMMENT_TEXTAREA_ID } from './constants'; import { getMessageHandler, MessageHandler } from './message'; -import { CloseResult, DescriptionResult, OpenCommitChangesArgs } from '../../common/views'; +import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArgs } from '../../common/views'; import { IComment } from '../../src/common/comment'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; @@ -361,6 +361,16 @@ export class PRContext { public openSessionLog = (link: SessionLinkInfo) => this.postMessage({ command: 'pr.open-session-log', args: { link } }); + public openLocalFile = (file: string, startLine: number, endLine: number, href: string) => { + const args: OpenLocalFileArgs = { file, startLine, endLine, href }; + this.postMessage({ command: 'pr.open-local-file', args }); + }; + + public openDiffFromLink = (file: string, startLine: number, endLine: number, href: string) => { + const args: OpenLocalFileArgs = { file, startLine, endLine, href }; + this.postMessage({ command: 'pr.open-diff-from-link', args }); + }; + public viewCheckLogs = (status: PullRequestCheckStatus) => this.postMessage({ command: 'pr.view-check-logs', args: { status } }); public openCommitChanges = async (commitSha: string) => { diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx index 3a3b7691d4..292438a3b6 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -7,6 +7,7 @@ import * as debounce from 'debounce'; import React, { useContext, useEffect, useState } from 'react'; import { render } from 'react-dom'; import { Overview } from './overview'; +import { extractCodeReferenceLinkMetadata } from '../../src/common/utils'; import { PullRequest } from '../../src/github/views'; import { COMMENT_TEXTAREA_ID } from '../common/constants'; import PullRequestContext from '../common/context'; @@ -41,6 +42,31 @@ export function Root({ children }) { return () => window.removeEventListener('focus', handleWindowFocus); }, []); + useEffect(() => { + const handleLinkClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const anchor = target.closest('a[data-local-file]'); + if (anchor) { + const metadata = extractCodeReferenceLinkMetadata(anchor); + if (metadata) { + // Prevent default navigation, handlers will fallback to opening the link externally if they fail + event.preventDefault(); + event.stopPropagation(); + + // Open diff view for diff links, local file for blob permalinks + if (metadata.linkType === 'diff') { + ctx.openDiffFromLink(metadata.localFile, metadata.startLine, metadata.endLine, metadata.href); + } else { + ctx.openLocalFile(metadata.localFile, metadata.startLine, metadata.endLine, metadata.href); + } + } + } + }; + + document.addEventListener('click', handleLinkClick, true); + return () => document.removeEventListener('click', handleLinkClick, true); + }, [ctx]); + window.onscroll = debounce(() => { ctx.postMessage({ command: 'scroll',