Skip to content

Commit b83a808

Browse files
committed
#404 New context menu on all link elements (e.g. Issue Links, URL's in commit messages, author email addresses), enabling the URL to be easily copied to the clipboard.
1 parent a176f2e commit b83a808

12 files changed

Lines changed: 303 additions & 73 deletions

File tree

src/gitGraphView.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Logger } from './logger';
88
import { RepoFileWatcher } from './repoFileWatcher';
99
import { RepoManager } from './repoManager';
1010
import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types';
11-
import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openFile, showErrorMessage, viewDiff, viewFileAtRevision, viewScm } from './utils';
11+
import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewFileAtRevision, viewScm } from './utils';
1212
import { Disposable, toDisposable } from './utils/disposable';
1313

1414
/**
@@ -458,6 +458,12 @@ export class GitGraphView extends Disposable {
458458
error: await this.dataSource.openExternalDirDiff(msg.repo, msg.fromHash, msg.toHash, msg.isGui)
459459
});
460460
break;
461+
case 'openExternalUrl':
462+
this.sendMessage({
463+
command: 'openExternalUrl',
464+
error: await openExternalUrl(msg.url)
465+
});
466+
break;
461467
case 'openFile':
462468
this.sendMessage({
463469
command: 'openFile',

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,14 @@ export interface ResponseOpenExternalDirDiff extends ResponseWithErrorInfo {
948948
readonly command: 'openExternalDirDiff';
949949
}
950950

951+
export interface RequestOpenExternalUrl extends BaseMessage {
952+
readonly command: 'openExternalUrl';
953+
readonly url: string;
954+
}
955+
export interface ResponseOpenExternalUrl extends ResponseWithErrorInfo {
956+
readonly command: 'openExternalUrl';
957+
}
958+
951959
export interface RequestOpenFile extends RepoRequest {
952960
readonly command: 'openFile';
953961
readonly filePath: string;
@@ -1198,6 +1206,7 @@ export type RequestMessage =
11981206
| RequestMerge
11991207
| RequestOpenExtensionSettings
12001208
| RequestOpenExternalDirDiff
1209+
| RequestOpenExternalUrl
12011210
| RequestOpenFile
12021211
| RequestOpenTerminal
12031212
| RequestPopStash
@@ -1257,6 +1266,7 @@ export type ResponseMessage =
12571266
| ResponseMerge
12581267
| ResponseOpenExtensionSettings
12591268
| ResponseOpenExternalDirDiff
1269+
| ResponseOpenExternalUrl
12601270
| ResponseOpenFile
12611271
| ResponseOpenTerminal
12621272
| ResponsePopStash

src/utils.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export function getRepoName(path: string) {
216216
* @param repo The path of the repository.
217217
* @param ref The reference of the revision to archive.
218218
* @param dataSource The DataSource instance that can be used to create the archive.
219-
* @returns The ErrorInfo from the executed command.
219+
* @returns A promise resolving to the ErrorInfo of the executed command.
220220
*/
221221
export function archive(repo: string, ref: string, dataSource: DataSource): Thenable<ErrorInfo> {
222222
return vscode.window.showSaveDialog({
@@ -245,6 +245,7 @@ export function archive(repo: string, ref: string, dataSource: DataSource): Then
245245
* Copy the path of a file in a repository to the clipboard.
246246
* @param repo The repository the file is contained in.
247247
* @param filePath The relative path of the file within the repository.
248+
* @returns A promise resolving to the ErrorInfo of the executed command.
248249
*/
249250
export function copyFilePathToClipboard(repo: string, filePath: string) {
250251
return copyToClipboard(path.join(repo, filePath));
@@ -253,6 +254,7 @@ export function copyFilePathToClipboard(repo: string, filePath: string) {
253254
/**
254255
* Copy a string to the clipboard.
255256
* @param text The string.
257+
* @returns A promise resolving to the ErrorInfo of the executed command.
256258
*/
257259
export function copyToClipboard(text: string): Thenable<ErrorInfo> {
258260
return vscode.env.clipboard.writeText(text).then(
@@ -267,6 +269,7 @@ export function copyToClipboard(text: string): Thenable<ErrorInfo> {
267269
* @param sourceOwner The owner of the repository that is the source of the Pull Request.
268270
* @param sourceRepo The name of the repository that is the source of the Pull Request.
269271
* @param sourceBranch The source branch the Pull Request should be created from.
272+
* @returns A promise resolving to the ErrorInfo of the executed command.
270273
*/
271274
export function createPullRequest(config: PullRequestConfig, sourceOwner: string, sourceRepo: string, sourceBranch: string) {
272275
let templateUrl;
@@ -294,14 +297,12 @@ export function createPullRequest(config: PullRequestConfig, sourceOwner: string
294297

295298
const url = templateUrl.replace(/\$([1-8])/g, (_, index) => urlFieldValues[parseInt(index) - 1]);
296299

297-
return vscode.env.openExternal(vscode.Uri.parse(url)).then(
298-
() => null,
299-
() => 'Visual Studio Code was unable to open the Pull Request URL: ' + url
300-
);
300+
return openExternalUrl(url, 'Pull Request URL');
301301
}
302302

303303
/**
304304
* Open the Visual Studio Code Settings Editor to the Git Graph Extension Settings.
305+
* @returns A promise resolving to the ErrorInfo of the executed command.
305306
*/
306307
export function openExtensionSettings(): Thenable<ErrorInfo> {
307308
return vscode.commands.executeCommand('workbench.action.openSettings', '@ext:mhutchie.git-graph').then(
@@ -310,10 +311,29 @@ export function openExtensionSettings(): Thenable<ErrorInfo> {
310311
);
311312
}
312313

314+
/**
315+
* Open an External URL using the default application.
316+
* @param url The URL for Visual Studio Code to open.
317+
* @param type The type of URL being opened (defaults to "External URL").
318+
* @returns A promise resolving to the ErrorInfo of the executed command.
319+
*/
320+
export function openExternalUrl(url: string, type: string = 'External URL'): Thenable<ErrorInfo> {
321+
const getErrorMessage = () => 'Visual Studio Code was unable to open the ' + type + ': ' + url;
322+
try {
323+
return vscode.env.openExternal(vscode.Uri.parse(url)).then(
324+
(success) => success ? null : getErrorMessage(),
325+
getErrorMessage
326+
);
327+
} catch (_) {
328+
return Promise.resolve(getErrorMessage());
329+
}
330+
}
331+
313332
/**
314333
* Open a file within a repository in Visual Studio Code.
315334
* @param repo The repository the file is contained in.
316335
* @param filePath The relative path of the file within the repository.
336+
* @returns A promise resolving to the ErrorInfo of the executed command.
317337
*/
318338
export function openFile(repo: string, filePath: string) {
319339
return new Promise<ErrorInfo>(resolve => {
@@ -342,6 +362,7 @@ export function openFile(repo: string, filePath: string) {
342362
* @param oldFilePath The relative path of the left-side file within the repository.
343363
* @param newFilePath The relative path of the right-side file within the repository.
344364
* @param type The Git file status of the change.
365+
* @returns A promise resolving to the ErrorInfo of the executed command.
345366
*/
346367
export function viewDiff(repo: string, fromHash: string, toHash: string, oldFilePath: string, newFilePath: string, type: GitFileStatus) {
347368
if (type !== GitFileStatus.Untracked) {
@@ -366,6 +387,13 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile
366387
}
367388
}
368389

390+
/**
391+
* Open a Visual Studio Code Editor (readonly) for a file a specific Git revision.
392+
* @param repo The repository the file is contained in.
393+
* @param hash The revision of the file.
394+
* @param filePath The relative path of the file within the repository.
395+
* @returns A promise resolving to the ErrorInfo of the executed command.
396+
*/
369397
export async function viewFileAtRevision(repo: string, hash: string, filePath: string) {
370398
const pathComponents = filePath.split('/');
371399
const title = abbrevCommit(hash) + ': ' + pathComponents[pathComponents.length - 1];
@@ -381,6 +409,7 @@ export async function viewFileAtRevision(repo: string, hash: string, filePath: s
381409

382410
/**
383411
* Open the Visual Studio Code Source Control View.
412+
* @returns A promise resolving to the ErrorInfo of the executed command.
384413
*/
385414
export function viewScm(): Thenable<ErrorInfo> {
386415
return vscode.commands.executeCommand('workbench.view.scm').then(

tests/utils.test.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { DataSource } from '../src/dataSource';
2424
import { ExtensionState } from '../src/extensionState';
2525
import { Logger } from '../src/logger';
2626
import { GitFileStatus, PullRequestProvider } from '../src/types';
27-
import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isGitAtLeastVersion, isPathInWorkspace, openExtensionSettings, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewFileAtRevision, viewScm } from '../src/utils';
27+
import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isGitAtLeastVersion, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewFileAtRevision, viewScm } from '../src/utils';
2828
import { EventEmitter } from '../src/utils/event';
2929

3030
const extensionContext = vscode.mocks.extensionContext;
@@ -694,7 +694,7 @@ describe('copyToClipboard', () => {
694694
describe('createPullRequest', () => {
695695
it('Should construct and open a BitBucket Pull Request Creation Url', async () => {
696696
// Setup
697-
vscode.env.openExternal.mockResolvedValueOnce(null);
697+
vscode.env.openExternal.mockResolvedValueOnce(true);
698698

699699
// Run
700700
const result = await createPullRequest({
@@ -718,7 +718,7 @@ describe('createPullRequest', () => {
718718

719719
it('Should construct and open a Custom Providers Pull Request Creation Url', async () => {
720720
// Setup
721-
vscode.env.openExternal.mockResolvedValueOnce(null);
721+
vscode.env.openExternal.mockResolvedValueOnce(true);
722722

723723
// Run
724724
const result = await createPullRequest({
@@ -745,7 +745,7 @@ describe('createPullRequest', () => {
745745

746746
it('Should construct and open a GitHub Pull Request Creation Url', async () => {
747747
// Setup
748-
vscode.env.openExternal.mockResolvedValueOnce(null);
748+
vscode.env.openExternal.mockResolvedValueOnce(true);
749749

750750
// Run
751751
const result = await createPullRequest({
@@ -769,7 +769,7 @@ describe('createPullRequest', () => {
769769

770770
it('Should construct and open a GitLab Pull Request Creation Url', async () => {
771771
// Setup
772-
vscode.env.openExternal.mockResolvedValueOnce(null);
772+
vscode.env.openExternal.mockResolvedValueOnce(true);
773773

774774
// Run
775775
const result = await createPullRequest({
@@ -793,7 +793,7 @@ describe('createPullRequest', () => {
793793

794794
it('Should construct and open a GitLab Pull Request Creation Url (without destProjectId)', async () => {
795795
// Setup
796-
vscode.env.openExternal.mockResolvedValueOnce(null);
796+
vscode.env.openExternal.mockResolvedValueOnce(true);
797797

798798
// Run
799799
const result = await createPullRequest({
@@ -864,6 +864,68 @@ describe('openExtensionSettings', () => {
864864
});
865865
});
866866

867+
describe('openExternalUrl', () => {
868+
it('Should open the URL via Visual Studio Code', async () => {
869+
// Setup
870+
vscode.env.openExternal.mockResolvedValueOnce(true);
871+
872+
// Run
873+
const result = await openExternalUrl('https://github.com/mhutchie/vscode-git-graph');
874+
875+
// Assert
876+
expect(result).toBe(null);
877+
expect(vscode.env.openExternal.mock.calls[0][0].toString()).toBe('https://github.com/mhutchie/vscode-git-graph');
878+
});
879+
880+
it('Should return an error message if vscode was unable to open the url (vscode.env.openExternal resolves FALSE)', async () => {
881+
// Setup
882+
vscode.env.openExternal.mockResolvedValueOnce(false);
883+
884+
// Run
885+
const result = await openExternalUrl('https://github.com/mhutchie/vscode-git-graph');
886+
887+
// Assert
888+
expect(result).toBe('Visual Studio Code was unable to open the External URL: https://github.com/mhutchie/vscode-git-graph');
889+
});
890+
891+
it('Should return an error message if vscode was unable to open the url (vscode.env.openExternal rejects)', async () => {
892+
// Setup
893+
vscode.env.openExternal.mockRejectedValueOnce(null);
894+
895+
// Run
896+
const result = await openExternalUrl('https://github.com/mhutchie/vscode-git-graph');
897+
898+
// Assert
899+
expect(result).toBe('Visual Studio Code was unable to open the External URL: https://github.com/mhutchie/vscode-git-graph');
900+
});
901+
902+
it('Should return an error message if vscode was unable to parse the url', async () => {
903+
// Setup
904+
const spyOnParse = jest.spyOn(vscode.Uri, 'parse');
905+
spyOnParse.mockImplementationOnce(() => {
906+
throw new Error();
907+
});
908+
909+
// Run
910+
const result = await openExternalUrl('https://github.com/mhutchie/vscode-git-graph');
911+
912+
// Assert
913+
expect(result).toBe('Visual Studio Code was unable to open the External URL: https://github.com/mhutchie/vscode-git-graph');
914+
expect(vscode.env.openExternal).not.toHaveBeenCalled();
915+
});
916+
917+
it('Should return an error message with a custom type', async () => {
918+
// Setup
919+
vscode.env.openExternal.mockRejectedValueOnce(null);
920+
921+
// Run
922+
const result = await openExternalUrl('https://github.com/mhutchie/vscode-git-graph', 'Custom URL');
923+
924+
// Assert
925+
expect(result).toBe('Visual Studio Code was unable to open the Custom URL: https://github.com/mhutchie/vscode-git-graph');
926+
});
927+
});
928+
867929
describe('openFile', () => {
868930
it('Should open the file in vscode', async () => {
869931
// Setup

web/contextMenu.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ class ContextMenu {
4242
* @param target The target that the context menu was triggered on.
4343
* @param event The mouse event that triggered the context menu.
4444
* @param frameElem The HTML Element that the context menu should be rendered within (and be positioned relative to it's content).
45-
* @param blockUserInteractionElem An optional HTML Element that the user shouldn't be able to interact with while the context menu is open.
4645
* @param onClose An optional callback to be invoked when the context menu is closed.
46+
* @param className An optional class name to add to the context menu.
4747
*/
48-
public show(actions: ContextMenuActions, checked: boolean, target: ContextMenuTarget | null, event: MouseEvent, frameElem: HTMLElement, blockUserInteractionElem: HTMLElement | null = null, onClose: (() => void) | null = null) {
48+
public show(actions: ContextMenuActions, checked: boolean, target: ContextMenuTarget | null, event: MouseEvent, frameElem: HTMLElement, onClose: (() => void) | null = null, className: string | null = null) {
4949
let html = '', handlers: (() => void)[] = [], handlerId = 0;
5050
this.close();
5151

@@ -67,7 +67,7 @@ class ContextMenu {
6767
if (handlers.length === 0) return; // No context menu actions are visible
6868

6969
const menu = document.createElement('ul');
70-
menu.className = 'contextMenu' + (checked ? ' checked' : '');
70+
menu.className = 'contextMenu' + (checked ? ' checked' : '') + (className !== null ? ' ' + className : '');
7171
menu.style.opacity = '0';
7272
menu.innerHTML = html;
7373
frameElem.appendChild(menu);
@@ -98,10 +98,6 @@ class ContextMenu {
9898
if (this.target !== null && this.target.type !== TargetType.Repo) {
9999
alterClass(this.target.elem, CLASS_CONTEXT_MENU_ACTIVE, true);
100100
}
101-
102-
if (blockUserInteractionElem !== null) {
103-
alterClass(blockUserInteractionElem, CLASS_BLOCK_USER_INTERACTION, true);
104-
}
105101
}
106102

107103
/**
@@ -112,7 +108,6 @@ class ContextMenu {
112108
this.elem.remove();
113109
this.elem = null;
114110
}
115-
alterClassOfCollection(<HTMLCollectionOf<HTMLElement>>document.getElementsByClassName(CLASS_BLOCK_USER_INTERACTION), CLASS_BLOCK_USER_INTERACTION, false);
116111
alterClassOfCollection(<HTMLCollectionOf<HTMLElement>>document.getElementsByClassName(CLASS_CONTEXT_MENU_ACTIVE), CLASS_CONTEXT_MENU_ACTIVE, false);
117112
if (this.onClose !== null) {
118113
this.onClose();

web/global.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ declare global {
3434
codeReview: GG.CodeReview | null;
3535
lastViewedFile: string | null;
3636
loading: boolean;
37-
fileChangesScrollTop: number;
38-
fileContextMenuOpen: number;
37+
scrollTop: {
38+
summary: number,
39+
fileView: number
40+
};
41+
contextMenuOpen: {
42+
summary: boolean,
43+
fileView: number
44+
};
3945
}
4046

4147
interface WebViewState {

0 commit comments

Comments
 (0)