Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wait here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the same reason we wait in _postMessage above

protected async _postMessage(message: any) {
 	// Without the following ready check, we can end up in a state where the message handler in the webview
 	// isn't ready for any of the messages we post.
 	await this._waitForReady;
 	this._webview?.postMessage({
 		res: message,
 	});
 }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice without this, the extension simply fails to work about half the time in a dev container (never got a failure outside of the container). The await calls to get the file mapping simply never resolve (presumably because the message got dropped)

this._webview?.postMessage(reply);
}

Expand All @@ -82,6 +83,7 @@ export class WebviewBase extends Disposable {
seq: originalMessage?.req,
err: error,
};
await this._waitForReady;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wait here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

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 on lines +24 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach of finding the permalinks in the webview involves multiple back-and-forths with the extension. I would suggest a different approach:

  • In the extension (probably in issueOverview.ts or pullRequestOverview.ts)
    • Find all the permalinks
    • Check for file existance
    • Replace permalinks with some very easy to find "tag" which contains all the relevant info
  • In the webview
    • Find this easy to find "tag"
    • Parse the relevant info from it
    • Replace with an <a> who's on click handler uses the message passing (context.tsx) to open the relevant file

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can give that a shot.

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