From 1aa080576a722f07d77ac01728058f0590b4f781 Mon Sep 17 00:00:00 2001 From: Daniel Bloom Date: Mon, 9 Mar 2026 01:49:36 -0700 Subject: [PATCH 1/3] Link to local file for permalinks in webview fixes #8571 --- common/views.ts | 8 ++ src/github/issueOverview.ts | 48 ++++++++++- webviews/common/context.tsx | 6 ++ webviews/editorWebview/app.tsx | 146 +++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) diff --git a/common/views.ts b/common/views.ts index 5682e9b011..d0a9045011 100644 --- a/common/views.ts +++ b/common/views.ts @@ -180,4 +180,12 @@ export interface OpenCommitChangesArgs { commitSha: string; } +export interface OpenLocalFileArgs { + file: string; + startLine: number; + endLine: number; +} + +export type CheckFilesExistResult = Record; + // #endregion \ No newline at end of file diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 67f3aef622..5b2902452a 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -5,7 +5,7 @@ 'use strict'; import * as vscode from 'vscode'; -import { CloseResult } from '../../common/views'; +import { CheckFilesExistResult, CloseResult, OpenLocalFileArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; @@ -445,6 +445,10 @@ 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.check-files-exist': + return this.checkFilesExist(message); case 'pr.debug': return this.webviewDebug(message); default: @@ -761,6 +765,48 @@ 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) + ); + 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); + } + } + + private async checkFilesExist(message: IRequestMessage): Promise { + const files = message.args; + const results: CheckFilesExistResult = {}; + + await Promise.all(files.map(async (relativePath) => { + const localFile = vscode.Uri.joinPath( + this._item.githubRepository.rootUri, + relativePath + ); + try { + const stat = await vscode.workspace.fs.stat(localFile); + results[relativePath] = stat.type === vscode.FileType.File; + } catch (e) { + results[relativePath] = false; + } + })); + + return this._replyMessage(message, results); + } + protected async close(message: IRequestMessage) { let comment: IComment | undefined; if (message.args) { diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 55cb61836f..93feef0d84 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -361,6 +361,12 @@ export class PRContext { public openSessionLog = (link: SessionLinkInfo) => this.postMessage({ command: 'pr.open-session-log', args: { link } }); + public openLocalFile = (file: string, startLine: number, endLine: number) => + this.postMessage({ command: 'pr.open-local-file', args: { file, startLine, endLine } }); + + public checkFilesExist = (files: string[]): Promise> => + this.postMessage({ command: 'pr.check-files-exist', args: files }); + 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..589d3ee6be 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -11,6 +11,86 @@ import { PullRequest } from '../../src/github/views'; import { COMMENT_TEXTAREA_ID } from '../common/constants'; import PullRequestContext from '../common/context'; +const PROCESSED_MARKER = 'data-permalink-processed'; + +interface PermalinkAnchor { + element: HTMLAnchorElement; + url: string; + file: string; + startLine: number; + endLine: number; +} + +function findUnprocessedPermalinks( + root: Document | Element, + repoName: string, +): PermalinkAnchor[] { + const anchors: PermalinkAnchor[] = []; + const urlPattern = new RegExp( + `^https://github\\.com/[^/]+/${repoName}/blob/[0-9a-f]{40}/([^#]+)#L([0-9]+)(?:-L([0-9]+))?$`, + ); + + // Find all unprocessed anchor elements + const allAnchors = root.querySelectorAll( + `a[href^="https://github.com/"]:not([${PROCESSED_MARKER}])`, + ); + + allAnchors.forEach((anchor: Element) => { + const htmlAnchor = anchor as HTMLAnchorElement; + + const href = htmlAnchor.getAttribute('href'); + if (!href) return; + + const match = href.match(urlPattern); + if (match) { + const file = match[1]; + const startLine = parseInt(match[2]); + const endLine = match[3] ? parseInt(match[3]) : startLine; + + anchors.push({ + element: htmlAnchor, + url: href, + file, + startLine, + endLine, + }); + } + }); + + return anchors; +} + + +function updatePermalinks( + anchors: PermalinkAnchor[], + fileExistenceMap: Record, +): void { + anchors.forEach(({ element, url, file, startLine, endLine }) => { + const exists = fileExistenceMap[file]; + if (!exists) { + return; + } + + element.setAttribute('data-local-file', file); + element.setAttribute('data-start-line', startLine.toString()); + element.setAttribute('data-end-line', endLine.toString()); + + // Add "(view on GitHub)" link after this anchor + const githubLink = document.createElement('a'); + githubLink.href = url; + githubLink.textContent = 'view on GitHub'; + githubLink.setAttribute(PROCESSED_MARKER, 'true'); + if (element.className) { + githubLink.className = element.className; + } + element.after( + document.createTextNode(' ('), + githubLink, + document.createTextNode(')'), + ); + }); +} + export function main() { render({pr => }, document.getElementById('app')); } @@ -41,6 +121,72 @@ 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 file = anchor.getAttribute('data-local-file'); + const startLine = anchor.getAttribute('data-start-line'); + const endLine = anchor.getAttribute('data-end-line'); + if (file && startLine && endLine) { + // Swallow the event and open the file + event.preventDefault(); + event.stopPropagation(); + ctx.openLocalFile(file, parseInt(startLine), parseInt(endLine)); + } + } + }; + + document.addEventListener('click', handleLinkClick, true); + return () => document.removeEventListener('click', handleLinkClick, true); + }, [ctx]); + + // Process GitHub permalinks + useEffect(() => { + if (!pr) return; + + const processPermalinks = debounce(async () => { + try { + const anchors = findUnprocessedPermalinks(document.body, pr.repo); + anchors.forEach(({ element }) => { + element.setAttribute(PROCESSED_MARKER, 'true'); + }); + + if (anchors.length > 0) { + const uniqueFiles = Array.from(new Set(anchors.map((a) => a.file))); + const fileExistenceMap = await ctx.checkFilesExist(uniqueFiles); + updatePermalinks(anchors, fileExistenceMap); + } + } catch (error) { + console.error('Error processing permalinks:', error); + } + }, 100); + + // Start observing the document body for changes + const observer = new MutationObserver((mutations) => { + const hasNewNodes = mutations.some( + ({ addedNodes }) => addedNodes.length > 0, + ); + + if (hasNewNodes) { + processPermalinks(); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + // Process the initial set of links + processPermalinks(); + + return () => { + observer.disconnect(); + processPermalinks.clear(); + }; + }, [pr, ctx]); + window.onscroll = debounce(() => { ctx.postMessage({ command: 'scroll', From 88612245064f1fb1a7ab358370e8b23c7022e7b6 Mon Sep 17 00:00:00 2001 From: Daniel Bloom Date: Tue, 10 Mar 2026 14:21:30 -0700 Subject: [PATCH 2/3] Fix _waitForReady --- src/common/webview.ts | 2 ++ 1 file changed, 2 insertions(+) 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); } } From 94cacf939570027fdbe00a2f0d8edbdecc1dbd37 Mon Sep 17 00:00:00 2001 From: Daniel-Aaron-Bloom <76709210+Daniel-Aaron-Bloom@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:39:41 -0700 Subject: [PATCH 3/3] refactor based on feedback and add diff links --- common/views.ts | 2 - src/github/issueOverview.ts | 113 ++++++++++++++----------- src/github/pullRequestOverview.ts | 71 +++++++++++++++- src/github/utils.ts | 110 ++++++++++++++++++++++++ webviews/common/context.tsx | 4 +- webviews/editorWebview/app.tsx | 136 ++---------------------------- 6 files changed, 252 insertions(+), 184 deletions(-) diff --git a/common/views.ts b/common/views.ts index d0a9045011..5ea4602411 100644 --- a/common/views.ts +++ b/common/views.ts @@ -186,6 +186,4 @@ export interface OpenLocalFileArgs { endLine: number; } -export type CheckFilesExistResult = Record; - // #endregion \ No newline at end of file diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 5b2902452a..103a8c7e86 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -5,13 +5,13 @@ 'use strict'; import * as vscode from 'vscode'; -import { CheckFilesExistResult, CloseResult, OpenLocalFileArgs } 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'; @@ -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 + issue.bodyHTML = await this.processLinksInBodyHtml(issue.bodyHTML); + 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: this.getInitializeContext(currentUser, issue, await this.processTimelineEvents(timelineEvents), repositoryAccess, viewerCanEdit, assignableUsers[this._item.remote.remoteName] ?? []), }); } catch (e) { @@ -447,8 +450,6 @@ export class IssueOverviewPanel extends W return openPullRequestOnGitHub(this._item, this._telemetry); case 'pr.open-local-file': return this.openLocalFile(message); - case 'pr.check-files-exist': - return this.checkFilesExist(message); case 'pr.debug': return this.webviewDebug(message); default: @@ -572,16 +573,51 @@ 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 permalinks 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 permalinks 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) => { + if (event.event === EventType.Commented || event.event === EventType.Reviewed || event.event === EventType.Committed) { + event.bodyHTML = await this.processLinksInBodyHtml(event.bodyHTML); + // ReviewEvent also has comments array + if (event.event === EventType.Reviewed && event.comments) { + event.comments = await Promise.all(event.comments.map(async (comment: IComment) => { + comment.bodyHTML = await this.processLinksInBodyHtml(comment.bodyHTML); + return comment; + })); + } + } + return event; + })); + } + + 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 @@ -622,7 +658,7 @@ export class IssueOverviewPanel extends W if (allAssignees) { const newAssignees: IAccount[] = allAssignees.map(item => item.user); await this._item.replaceAssignees(newAssignees); - const events = await this._getTimeline(); + const events = await this.processTimelineEvents(await this._getTimeline()); const reply: ChangeAssigneesReply = { assignees: newAssignees, events @@ -689,7 +725,7 @@ export class IssueOverviewPanel extends W const newAssignees = (this._item.assignees ?? []).concat(currentUser); await this._item.replaceAssignees(newAssignees); } - const events = await this._getTimeline(); + const events = await this.processTimelineEvents(await this._getTimeline()); const reply: ChangeAssigneesReply = { assignees: this._item.assignees ?? [], events @@ -707,7 +743,7 @@ export class IssueOverviewPanel extends W const newAssignees = (this._item.assignees ?? []).concat(copilotUser); await this._item.replaceAssignees(newAssignees); } - const events = await this._getTimeline(); + const events = await this.processTimelineEvents(await this._getTimeline()); const reply: ChangeAssigneesReply = { assignees: this._item.assignees ?? [], events @@ -730,18 +766,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 { @@ -787,26 +820,6 @@ export class IssueOverviewPanel extends W } } - private async checkFilesExist(message: IRequestMessage): Promise { - const files = message.args; - const results: CheckFilesExistResult = {}; - - await Promise.all(files.map(async (relativePath) => { - const localFile = vscode.Uri.joinPath( - this._item.githubRepository.rootUri, - relativePath - ); - try { - const stat = await vscode.workspace.fs.stat(localFile); - results[relativePath] = stat.type === vscode.FileType.File; - } catch (e) { - results[relativePath] = false; - } - })); - - return this._replyMessage(message, results); - } - 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..22a24dfedb 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; import { OpenCommitChangesArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; @@ -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(); @@ -370,6 +403,9 @@ 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 = this.getInitializeContext(currentUser, pullRequest, await this.processTimelineEvents(timelineEvents), repositoryAccess, viewerCanEdit, users); this.preLoadInfoNotRequiredForOverview(pullRequest); @@ -535,6 +571,8 @@ 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) { + Logger.warn(`Could not find file ${file} in PR changes`, PullRequestOverviewPanel.ID); + return; + } + + const pathSegments = file.split('/'); + // GitHub line numbers are 1-indexed, VSCode selection API is 0-indexed + return PullRequestModel.openDiff( + this._folderRepositoryManager, + this._item, + change, + pathSegments[pathSegments.length - 1], + startLine - 1, + ); + } catch (e) { + Logger.error(`Open diff from link failed: ${formatError(e)}`, PullRequestOverviewPanel.ID); + } + } + private async openSessionLog(message: IRequestMessage<{ link: SessionLinkInfo }>): Promise { try { const resource = SessionIdForPr.getResource(this._item.number, message.args.link.sessionIndex); @@ -728,7 +793,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + try { + const repoName = escapeRegExp(githubRepository.remote.repositoryName); + const repoOwner = escapeRegExp(githubRepository.remote.owner); + const authority = escapeRegExp(githubRepository.remote.gitProtocol.url.authority); + + // Process blob permalinks (exclude already processed links) + const blobPattern = new RegExp( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${authority}\/${repoOwner}\/${repoName}\/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 localFileUri = vscode.Uri.joinPath(rootUri, filePath); + try { + const stat = await vscode.workspace.fs.stat(localFileUri); + if (stat.type === vscode.FileType.File) { + // File exists - add data attributes for local handling and "(view on GitHub)" suffix + const endLineValue = endLine || startLine; + return `${linkText} (view on GitHub)`; + } + } catch { + // File doesn't exist - keep original link + } + } catch (error) { + Logger.warn(`Failed to process blob permalink: ${error}`, 'processPermalinks'); + } + return fullMatch; + }); + } catch (error) { + Logger.error(`Failed to process blob permalinks in HTML: ${error}`, 'processPermalinks'); + return bodyHTML; // Return original HTML if processing fails + } +} + +/** + * 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 = escapeRegExp(githubRepository.remote.repositoryName); + const repoOwner = escapeRegExp(githubRepository.remote.owner); + const authority = escapeRegExp(githubRepository.remote.gitProtocol.url.authority); + + const diffPattern = new RegExp( + `]*data-permalink-processed)([^>]*?href="https?:\/\/${authority}\/${repoOwner}\/${repoName}\/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) { + Logger.warn(`Failed to process diff permalink: ${error}`, 'processDiffLinks'); + } + return fullMatch; + }); + } catch (error) { + Logger.error(`Failed to process diff permalinks in HTML: ${error}`, 'processDiffLinks'); + return bodyHTML; // Return original HTML if processing fails + } +} + export function convertRESTPullRequestToRawPullRequest( pullRequest: | OctokitCommon.PullsGetResponseData diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 93feef0d84..a34d19f08c 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -364,8 +364,8 @@ export class PRContext { public openLocalFile = (file: string, startLine: number, endLine: number) => this.postMessage({ command: 'pr.open-local-file', args: { file, startLine, endLine } }); - public checkFilesExist = (files: string[]): Promise> => - this.postMessage({ command: 'pr.check-files-exist', args: files }); + public openDiffFromLink = (file: string, startLine: number, endLine: number) => + this.postMessage({ command: 'pr.open-diff-from-link', args: { file, startLine, endLine } }); public viewCheckLogs = (status: PullRequestCheckStatus) => this.postMessage({ command: 'pr.view-check-logs', args: { status } }); diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx index 589d3ee6be..3d57c887c9 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -11,86 +11,6 @@ import { PullRequest } from '../../src/github/views'; import { COMMENT_TEXTAREA_ID } from '../common/constants'; import PullRequestContext from '../common/context'; -const PROCESSED_MARKER = 'data-permalink-processed'; - -interface PermalinkAnchor { - element: HTMLAnchorElement; - url: string; - file: string; - startLine: number; - endLine: number; -} - -function findUnprocessedPermalinks( - root: Document | Element, - repoName: string, -): PermalinkAnchor[] { - const anchors: PermalinkAnchor[] = []; - const urlPattern = new RegExp( - `^https://github\\.com/[^/]+/${repoName}/blob/[0-9a-f]{40}/([^#]+)#L([0-9]+)(?:-L([0-9]+))?$`, - ); - - // Find all unprocessed anchor elements - const allAnchors = root.querySelectorAll( - `a[href^="https://github.com/"]:not([${PROCESSED_MARKER}])`, - ); - - allAnchors.forEach((anchor: Element) => { - const htmlAnchor = anchor as HTMLAnchorElement; - - const href = htmlAnchor.getAttribute('href'); - if (!href) return; - - const match = href.match(urlPattern); - if (match) { - const file = match[1]; - const startLine = parseInt(match[2]); - const endLine = match[3] ? parseInt(match[3]) : startLine; - - anchors.push({ - element: htmlAnchor, - url: href, - file, - startLine, - endLine, - }); - } - }); - - return anchors; -} - - -function updatePermalinks( - anchors: PermalinkAnchor[], - fileExistenceMap: Record, -): void { - anchors.forEach(({ element, url, file, startLine, endLine }) => { - const exists = fileExistenceMap[file]; - if (!exists) { - return; - } - - element.setAttribute('data-local-file', file); - element.setAttribute('data-start-line', startLine.toString()); - element.setAttribute('data-end-line', endLine.toString()); - - // Add "(view on GitHub)" link after this anchor - const githubLink = document.createElement('a'); - githubLink.href = url; - githubLink.textContent = 'view on GitHub'; - githubLink.setAttribute(PROCESSED_MARKER, 'true'); - if (element.className) { - githubLink.className = element.className; - } - element.after( - document.createTextNode(' ('), - githubLink, - document.createTextNode(')'), - ); - }); -} - export function main() { render({pr => }, document.getElementById('app')); } @@ -129,11 +49,18 @@ export function Root({ children }) { const file = 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'); if (file && startLine && endLine) { - // Swallow the event and open the file + // Swallow the event event.preventDefault(); event.stopPropagation(); - ctx.openLocalFile(file, parseInt(startLine), parseInt(endLine)); + + // Open diff view for diff links, local file for blob permalinks + if (linkType === 'diff') { + ctx.openDiffFromLink(file, parseInt(startLine), parseInt(endLine)); + } else { + ctx.openLocalFile(file, parseInt(startLine), parseInt(endLine)); + } } } }; @@ -142,51 +69,6 @@ export function Root({ children }) { return () => document.removeEventListener('click', handleLinkClick, true); }, [ctx]); - // Process GitHub permalinks - useEffect(() => { - if (!pr) return; - - const processPermalinks = debounce(async () => { - try { - const anchors = findUnprocessedPermalinks(document.body, pr.repo); - anchors.forEach(({ element }) => { - element.setAttribute(PROCESSED_MARKER, 'true'); - }); - - if (anchors.length > 0) { - const uniqueFiles = Array.from(new Set(anchors.map((a) => a.file))); - const fileExistenceMap = await ctx.checkFilesExist(uniqueFiles); - updatePermalinks(anchors, fileExistenceMap); - } - } catch (error) { - console.error('Error processing permalinks:', error); - } - }, 100); - - // Start observing the document body for changes - const observer = new MutationObserver((mutations) => { - const hasNewNodes = mutations.some( - ({ addedNodes }) => addedNodes.length > 0, - ); - - if (hasNewNodes) { - processPermalinks(); - } - }); - observer.observe(document.body, { - childList: true, - subtree: true, - }); - - // Process the initial set of links - processPermalinks(); - - return () => { - observer.disconnect(); - processPermalinks.clear(); - }; - }, [pr, ctx]); - window.onscroll = debounce(() => { ctx.postMessage({ command: 'scroll',