Skip to content
8 changes: 8 additions & 0 deletions common/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,12 @@ export interface OpenCommitChangesArgs {
commitSha: string;
}

export interface OpenLocalFileArgs {
file: string;
startLine: number;
endLine: number;
}

export type CheckFilesExistResult = Record<string, boolean>;

// #endregion
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;
Comment thread
alexr00 marked this conversation as resolved.
this._webview?.postMessage(reply);
}

Expand All @@ -82,6 +83,7 @@ export class WebviewBase extends Disposable {
seq: originalMessage?.req,
err: error,
};
await this._waitForReady;
Comment thread
alexr00 marked this conversation as resolved.
this._webview?.postMessage(reply);
}
}
Expand Down
48 changes: 47 additions & 1 deletion src/github/issueOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -445,6 +445,10 @@ 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.check-files-exist':
return this.checkFilesExist(message);
case 'pr.debug':
return this.webviewDebug(message);
default:
Expand Down Expand Up @@ -761,6 +765,48 @@ 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)
);
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<string[]>): Promise<void> {
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<string>) {
let comment: IComment | undefined;
if (message.args) {
Expand Down
6 changes: 6 additions & 0 deletions webviews/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>> =>
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) => {
Expand Down
146 changes: 146 additions & 0 deletions webviews/editorWebview/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
Daniel-Aaron-Bloom marked this conversation as resolved.
Outdated
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<string, boolean>,
): 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(<Root>{pr => <Overview {...pr} />}</Root>, document.getElementById('app'));
}
Expand Down Expand Up @@ -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',
Expand Down