diff --git a/common/views.ts b/common/views.ts index 5682e9b011..5ea4602411 100644 --- a/common/views.ts +++ b/common/views.ts @@ -180,4 +180,10 @@ export interface OpenCommitChangesArgs { commitSha: string; } +export interface OpenLocalFileArgs { + file: string; + startLine: number; + endLine: number; +} + // #endregion \ No newline at end of file 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..103a8c7e86 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'; @@ -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) { @@ -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,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 @@ -618,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 @@ -685,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 @@ -703,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 @@ -726,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 { @@ -761,6 +798,28 @@ 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); + } + } + 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 55cb61836f..a34d19f08c 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 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 } }); public openCommitChanges = async (commitSha: string) => { diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx index 3a3b7691d4..3d57c887c9 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -41,6 +41,34 @@ 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'); + const linkType = anchor.getAttribute('data-link-type'); + if (file && startLine && endLine) { + // Swallow the event + event.preventDefault(); + event.stopPropagation(); + + // 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)); + } + } + } + }; + + document.addEventListener('click', handleLinkClick, true); + return () => document.removeEventListener('click', handleLinkClick, true); + }, [ctx]); + window.onscroll = debounce(() => { ctx.postMessage({ command: 'scroll',