From 1aa080576a722f07d77ac01728058f0590b4f781 Mon Sep 17 00:00:00 2001 From: Daniel Bloom Date: Mon, 9 Mar 2026 01:49:36 -0700 Subject: [PATCH 1/2] 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/2] 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); } }