From ad1ba5c16bf3982337df7982451ec9ea5942ab6c Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Sun, 28 Feb 2021 19:55:51 +1100 Subject: [PATCH 01/15] #466 New context menu action to copy the relative path of a file in the Commit Details View to the clipboard. --- src/gitGraphView.ts | 2 +- src/types.ts | 1 + src/utils.ts | 5 +++-- tests/utils.test.ts | 19 ++++++++++++++++--- web/main.ts | 19 ++++++++++++------- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 4844aa1e..a07c0245 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -259,7 +259,7 @@ export class GitGraphView extends Disposable { case 'copyFilePath': this.sendMessage({ command: 'copyFilePath', - error: await copyFilePathToClipboard(msg.repo, msg.filePath) + error: await copyFilePathToClipboard(msg.repo, msg.filePath, msg.absolute) }); break; case 'copyToClipboard': diff --git a/src/types.ts b/src/types.ts index 82e3c8de..3fc6058c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -681,6 +681,7 @@ export interface ResponseCompareCommits extends ResponseWithErrorInfo { export interface RequestCopyFilePath extends RepoRequest { readonly command: 'copyFilePath'; readonly filePath: string; + readonly absolute: boolean; } export interface ResponseCopyFilePath extends ResponseWithErrorInfo { readonly command: 'copyFilePath'; diff --git a/src/utils.ts b/src/utils.ts index bc40a4f5..3dde5f82 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -245,10 +245,11 @@ export function archive(repo: string, ref: string, dataSource: DataSource): Then * Copy the path of a file in a repository to the clipboard. * @param repo The repository the file is contained in. * @param filePath The relative path of the file within the repository. + * @param absolute TRUE => Absolute path, FALSE => Relative path. * @returns A promise resolving to the ErrorInfo of the executed command. */ -export function copyFilePathToClipboard(repo: string, filePath: string) { - return copyToClipboard(path.join(repo, filePath)); +export function copyFilePathToClipboard(repo: string, filePath: string, absolute: boolean) { + return copyToClipboard(absolute ? path.join(repo, filePath) : filePath); } /** diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 6eccb299..e42bb95c 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -642,12 +642,12 @@ describe('archive', () => { }); describe('copyFilePathToClipboard', () => { - it('Appends the file path to the repository path, and copies the result to the clipboard', async () => { + it('Appends the relative file path to the repository path, and copies the result to the clipboard', async () => { // Setup vscode.env.clipboard.writeText.mockResolvedValueOnce(null); // Run - const result = await copyFilePathToClipboard('/a/b', 'c/d.txt'); + const result = await copyFilePathToClipboard('/a/b', 'c/d.txt', true); // Assert const receivedArgs: any[] = vscode.env.clipboard.writeText.mock.calls[0]; @@ -655,12 +655,25 @@ describe('copyFilePathToClipboard', () => { expect(getPathFromStr(receivedArgs[0])).toBe('/a/b/c/d.txt'); }); + it('Copies the relative file path to the clipboard', async () => { + // Setup + vscode.env.clipboard.writeText.mockResolvedValueOnce(null); + + // Run + const result = await copyFilePathToClipboard('/a/b', 'c/d.txt', false); + + // Assert + const receivedArgs: any[] = vscode.env.clipboard.writeText.mock.calls[0]; + expect(result).toBe(null); + expect(getPathFromStr(receivedArgs[0])).toBe('c/d.txt'); + }); + it('Returns an error message when writeText fails', async () => { // Setup vscode.env.clipboard.writeText.mockRejectedValueOnce(null); // Run - const result = await copyFilePathToClipboard('/a/b', 'c/d.txt'); + const result = await copyFilePathToClipboard('/a/b', 'c/d.txt', true); // Assert expect(result).toBe('Visual Studio Code was unable to write to the Clipboard.'); diff --git a/web/main.ts b/web/main.ts index 243d29f4..8ae5886d 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2823,8 +2823,8 @@ class GitGraphView { }); }; - const triggerCopyFilePath = (file: GG.GitFileChange) => { - sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath }); + const triggerCopyFilePath = (file: GG.GitFileChange, absolute: boolean) => { + sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath, absolute: absolute }); }; const triggerViewFileAtRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { @@ -2884,7 +2884,7 @@ class GitGraphView { if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; const fileElem = getFileElemOfEventTarget(e.target); - triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem)); + triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem), true); }); addListenerToClass('viewGitFileAtRevision', 'click', (e) => { @@ -2943,9 +2943,14 @@ class GitGraphView { ], [ { - title: 'Copy File Path to the Clipboard', + title: 'Copy Absolute File Path to Clipboard', visible: true, - onClick: () => triggerCopyFilePath(file) + onClick: () => triggerCopyFilePath(file, true) + }, + { + title: 'Copy Relative File Path to Clipboard', + visible: true, + onClick: () => triggerCopyFilePath(file, false) } ] ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { @@ -3084,7 +3089,7 @@ window.addEventListener('load', () => { } break; case 'copyFilePath': - finishOrDisplayError(msg.error, 'Unable to Copy File Path to the Clipboard'); + finishOrDisplayError(msg.error, 'Unable to Copy File Path to Clipboard'); break; case 'copyToClipboard': finishOrDisplayError(msg.error, 'Unable to Copy ' + msg.type + ' to Clipboard'); @@ -3377,7 +3382,7 @@ function generateFileTreeLeafHtml(name: string, leaf: FileTreeLeaf, gitFiles: Re (initialState.config.enhancedAccessibility ? '' + fileTreeFile.type + '' : '') + (fileTreeFile.type !== GG.GitFileStatus.Added && fileTreeFile.type !== GG.GitFileStatus.Untracked && fileTreeFile.type !== GG.GitFileStatus.Deleted && textFile ? '(+' + fileTreeFile.additions + '|-' + fileTreeFile.deletions + ')' : '') + (fileTreeFile.newFilePath === lastViewedFile ? '' + SVG_ICONS.eyeOpen + '' : '') + - '' + SVG_ICONS.copy + '' + + '' + SVG_ICONS.copy + '' + (fileTreeFile.type !== GG.GitFileStatus.Deleted ? (diffPossible && !isUncommitted ? '' + SVG_ICONS.commit + '' : '') + '' + SVG_ICONS.openFile + '' From 78779008d26902815f5fde2b47bd1ba831b2b1d9 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 3 Mar 2021 19:40:02 +1100 Subject: [PATCH 02/15] #395 Added a "Force Fetch" option onto the "Fetch into Local Branch" Dialog, allowing any local branch (that's not checked out) to be reset to the remote branch. --- package.json | 5 +++++ src/config.ts | 3 +++ src/dataSource.ts | 10 ++++++++-- src/gitGraphView.ts | 2 +- src/types.ts | 4 ++++ tests/config.test.ts | 28 ++++++++++++++++++++++++++++ tests/dataSource.test.ts | 16 ++++++++++++++-- web/main.ts | 4 ++-- 8 files changed, 65 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 77db7378..0a86f63e 100644 --- a/package.json +++ b/package.json @@ -525,6 +525,11 @@ "default": false, "description": "Default state of the \"Force Delete\" checkbox." }, + "git-graph.dialog.fetchIntoLocalBranch.forceFetch": { + "type": "boolean", + "default": false, + "description": "Default state of the \"Force Fetch\" checkbox." + }, "git-graph.dialog.fetchRemote.prune": { "type": "boolean", "default": false, diff --git a/src/config.ts b/src/config.ts index 515c21d6..cacc37e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -192,6 +192,9 @@ class Config { deleteBranch: { forceDelete: !!this.config.get('dialog.deleteBranch.forceDelete', false) }, + fetchIntoLocalBranch: { + forceFetch: !!this.config.get('dialog.fetchIntoLocalBranch.forceFetch', false) + }, fetchRemote: { prune: !!this.config.get('dialog.fetchRemote.prune', false), pruneTags: !!this.config.get('dialog.fetchRemote.pruneTags', false) diff --git a/src/dataSource.ts b/src/dataSource.ts index e85a8da6..4ee39e21 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -884,10 +884,16 @@ export class DataSource extends Disposable { * @param remote The name of the remote containing the remote branch. * @param remoteBranch The name of the remote branch. * @param localBranch The name of the local branch. + * @param force Force fetch the remote branch. * @returns The ErrorInfo from the executed command. */ - public fetchIntoLocalBranch(repo: string, remote: string, remoteBranch: string, localBranch: string) { - return this.runGitCommand(['fetch', remote, remoteBranch + ':' + localBranch], repo); + public fetchIntoLocalBranch(repo: string, remote: string, remoteBranch: string, localBranch: string, force: boolean) { + const args = ['fetch']; + if (force) { + args.push('-f'); + } + args.push(remote, remoteBranch + ':' + localBranch); + return this.runGitCommand(args, repo); } /** diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index a07c0245..5af41d16 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -386,7 +386,7 @@ export class GitGraphView extends Disposable { case 'fetchIntoLocalBranch': this.sendMessage({ command: 'fetchIntoLocalBranch', - error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch) + error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch, msg.force) }); break; case 'endCodeReview': diff --git a/src/types.ts b/src/types.ts index 3fc6058c..7e24085a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -447,6 +447,9 @@ export interface DialogDefaults { readonly deleteBranch: { readonly forceDelete: boolean }; + readonly fetchIntoLocalBranch: { + readonly forceFetch: boolean + }; readonly fetchRemote: { readonly prune: boolean, readonly pruneTags: boolean @@ -859,6 +862,7 @@ export interface RequestFetchIntoLocalBranch extends RepoRequest { readonly remote: string; readonly remoteBranch: string; readonly localBranch: string; + readonly force: boolean; } export interface ResponseFetchIntoLocalBranch extends ResponseWithErrorInfo { readonly command: 'fetchIntoLocalBranch'; diff --git a/tests/config.test.ts b/tests/config.test.ts index cb118103..47f86255 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -810,6 +810,7 @@ describe('Config', () => { 'dialog.cherryPick.recordOrigin', 'dialog.createBranch.checkOut', 'dialog.deleteBranch.forceDelete', + 'dialog.fetchIntoLocalBranch.forceFetch', 'dialog.fetchRemote.prune', 'dialog.fetchRemote.pruneTags', 'dialog.merge.noCommit', @@ -834,6 +835,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -865,6 +867,9 @@ describe('Config', () => { deleteBranch: { forceDelete: true }, + fetchIntoLocalBranch: { + forceFetch: true + }, fetchRemote: { prune: true, pruneTags: true @@ -906,6 +911,7 @@ describe('Config', () => { 'dialog.cherryPick.recordOrigin', 'dialog.createBranch.checkOut', 'dialog.deleteBranch.forceDelete', + 'dialog.fetchIntoLocalBranch.forceFetch', 'dialog.fetchRemote.prune', 'dialog.fetchRemote.pruneTags', 'dialog.merge.noCommit', @@ -930,6 +936,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -961,6 +968,9 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false @@ -1002,6 +1012,7 @@ describe('Config', () => { 'dialog.cherryPick.recordOrigin', 'dialog.createBranch.checkOut', 'dialog.deleteBranch.forceDelete', + 'dialog.fetchIntoLocalBranch.forceFetch', 'dialog.fetchRemote.prune', 'dialog.fetchRemote.pruneTags', 'dialog.merge.noCommit', @@ -1026,6 +1037,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -1057,6 +1069,9 @@ describe('Config', () => { deleteBranch: { forceDelete: true }, + fetchIntoLocalBranch: { + forceFetch: true + }, fetchRemote: { prune: true, pruneTags: true @@ -1098,6 +1113,7 @@ describe('Config', () => { 'dialog.cherryPick.recordOrigin', 'dialog.createBranch.checkOut', 'dialog.deleteBranch.forceDelete', + 'dialog.fetchIntoLocalBranch.forceFetch', 'dialog.fetchRemote.prune', 'dialog.fetchRemote.pruneTags', 'dialog.merge.noCommit', @@ -1122,6 +1138,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -1153,6 +1170,9 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false @@ -1204,6 +1224,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -1235,6 +1256,9 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false @@ -1279,6 +1303,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.cherryPick.recordOrigin', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.createBranch.checkOut', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.deleteBranch.forceDelete', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); @@ -1310,6 +1335,9 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false diff --git a/tests/dataSource.test.ts b/tests/dataSource.test.ts index cb5ea4bf..10b9e940 100644 --- a/tests/dataSource.test.ts +++ b/tests/dataSource.test.ts @@ -4945,19 +4945,31 @@ describe('DataSource', () => { mockGitSuccessOnce(); // Run - const result = await dataSource.fetchIntoLocalBranch('/path/to/repo', 'origin', 'master', 'develop'); + const result = await dataSource.fetchIntoLocalBranch('/path/to/repo', 'origin', 'master', 'develop', false); // Assert expect(result).toBe(null); expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['fetch', 'origin', 'master:develop'], expect.objectContaining({ cwd: '/path/to/repo' })); }); + it('Should (force) fetch a remote branch into a local branch', async () => { + // Setup + mockGitSuccessOnce(); + + // Run + const result = await dataSource.fetchIntoLocalBranch('/path/to/repo', 'origin', 'master', 'develop', true); + + // Assert + expect(result).toBe(null); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['fetch', '-f', 'origin', 'master:develop'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + it('Should return an error message thrown by git', async () => { // Setup mockGitThrowingErrorOnce(); // Run - const result = await dataSource.fetchIntoLocalBranch('/path/to/repo', 'origin', 'master', 'develop'); + const result = await dataSource.fetchIntoLocalBranch('/path/to/repo', 'origin', 'master', 'develop', false); // Assert expect(result).toBe('error message'); diff --git a/web/main.ts b/web/main.ts index 8ae5886d..7852ba34 100644 --- a/web/main.ts +++ b/web/main.ts @@ -1252,8 +1252,8 @@ class GitGraphView { title: 'Fetch into local branch' + ELLIPSIS, visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, onClick: () => { - dialog.showConfirmation('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', 'Yes, fetch', () => { - runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName }, 'Fetching Branch'); + dialog.showCheckbox('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', 'Force Fetch' + SVG_ICONS.info + '', this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, 'Yes, fetch', (force) => { + runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: force }, 'Fetching Branch'); }, target); } }, { From ac69033ddc86084bb6026ad08e445b0d79de04fc Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 3 Mar 2021 21:20:50 +1100 Subject: [PATCH 03/15] #471 Spaces can be automatically substituted with hyphens or underscores in reference inputs on dialogs. --- package.json | 15 ++++++++ src/config.ts | 4 +++ src/types.ts | 3 ++ tests/config.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++---- web/dialog.ts | 10 +++++- 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 0a86f63e..4a75fff0 100644 --- a/package.json +++ b/package.json @@ -540,6 +540,21 @@ "default": false, "description": "Default state of the \"Prune Tags\" checkbox." }, + "git-graph.dialog.general.referenceInputSpaceSubstitution": { + "type": "string", + "enum": [ + "None", + "Hyphen", + "Underscore" + ], + "enumDescriptions": [ + "Don't replace spaces.", + "Replace space characters with hyphens, for example: \"new branch\" -> \"new-branch\".", + "Replace space characters with underscores, for example: \"new branch\" -> \"new_branch\"." + ], + "default": "None", + "description": "Specifies a substitution that is automatically performed when space characters are entered or pasted into reference inputs on dialogs (e.g. Create Branch, Add Tag, etc.)." + }, "git-graph.dialog.merge.noCommit": { "type": "boolean", "default": false, diff --git a/src/config.ts b/src/config.ts index cacc37e0..4e957745 100644 --- a/src/config.ts +++ b/src/config.ts @@ -173,6 +173,7 @@ class Config { get dialogDefaults(): DialogDefaults { let resetCommitMode = this.config.get('dialog.resetCurrentBranchToCommit.mode', 'Mixed'); let resetUncommittedMode = this.config.get('dialog.resetUncommittedChanges.mode', 'Mixed'); + let refInputSpaceSubstitution = this.config.get('dialog.general.referenceInputSpaceSubstitution', 'None'); return { addTag: { @@ -199,6 +200,9 @@ class Config { prune: !!this.config.get('dialog.fetchRemote.prune', false), pruneTags: !!this.config.get('dialog.fetchRemote.pruneTags', false) }, + general: { + referenceInputSpaceSubstitution: refInputSpaceSubstitution === 'Hyphen' ? '-' : refInputSpaceSubstitution === 'Underscore' ? '_' : null + }, merge: { noCommit: !!this.config.get('dialog.merge.noCommit', false), noFastForward: !!this.config.get('dialog.merge.noFastForward', true), diff --git a/src/types.ts b/src/types.ts index 7e24085a..39afdf57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -454,6 +454,9 @@ export interface DialogDefaults { readonly prune: boolean, readonly pruneTags: boolean }; + readonly general: { + readonly referenceInputSpaceSubstitution: string | null + }; readonly merge: { readonly noCommit: boolean, readonly noFastForward: boolean, diff --git a/tests/config.test.ts b/tests/config.test.ts index 47f86255..92de8e20 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -838,6 +838,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -874,6 +875,9 @@ describe('Config', () => { prune: true, pruneTags: true }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: true, noFastForward: true, @@ -939,6 +943,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -975,6 +980,9 @@ describe('Config', () => { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: false, @@ -1040,6 +1048,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -1076,6 +1085,9 @@ describe('Config', () => { prune: true, pruneTags: true }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: true, noFastForward: true, @@ -1141,6 +1153,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -1177,6 +1190,9 @@ describe('Config', () => { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: false, @@ -1227,6 +1243,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -1263,6 +1280,9 @@ describe('Config', () => { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: true, @@ -1306,6 +1326,7 @@ describe('Config', () => { expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchIntoLocalBranch.forceFetch', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.prune', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.fetchRemote.pruneTags', false); + expect(workspaceConfiguration.get).toBeCalledWith('dialog.general.referenceInputSpaceSubstitution', 'None'); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noCommit', false); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.noFastForward', true); expect(workspaceConfiguration.get).toBeCalledWith('dialog.merge.squashCommits', false); @@ -1342,6 +1363,9 @@ describe('Config', () => { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: true, @@ -1371,7 +1395,7 @@ describe('Config', () => { }); describe('dialogDefaults.addTag.type', () => { - it('Should return "annotated" the configuration value is "Annotated"', () => { + it('Should return TagType.Annotated when the configuration value is "Annotated"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.addTag.type', 'Annotated'); @@ -1382,7 +1406,7 @@ describe('Config', () => { expect(value.addTag.type).toBe(TagType.Annotated); }); - it('Should return "lightweight" the configuration value is "Annotated"', () => { + it('Should return TagType.Lightweight when the configuration value is "Lightweight"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.addTag.type', 'Lightweight'); @@ -1394,8 +1418,54 @@ describe('Config', () => { }); }); + describe('dialogDefaults.general.referenceInputSpaceSubstitution', () => { + it('Should return NULL when the configuration value is "None"', () => { + // Setup + vscode.mockExtensionSettingReturnValue('dialog.general.referenceInputSpaceSubstitution', 'None'); + + // Run + const value = config.dialogDefaults; + + // Assert + expect(value.general.referenceInputSpaceSubstitution).toBe(null); + }); + + it('Should return "-" when the configuration value is "Hyphen"', () => { + // Setup + vscode.mockExtensionSettingReturnValue('dialog.general.referenceInputSpaceSubstitution', 'Hyphen'); + + // Run + const value = config.dialogDefaults; + + // Assert + expect(value.general.referenceInputSpaceSubstitution).toBe('-'); + }); + + it('Should return "_" when the configuration value is "Underscore"', () => { + // Setup + vscode.mockExtensionSettingReturnValue('dialog.general.referenceInputSpaceSubstitution', 'Underscore'); + + // Run + const value = config.dialogDefaults; + + // Assert + expect(value.general.referenceInputSpaceSubstitution).toBe('_'); + }); + + it('Should return the default value (NULL) when the configuration value is invalid', () => { + // Setup + vscode.mockExtensionSettingReturnValue('dialog.general.referenceInputSpaceSubstitution', 'invalid'); + + // Run + const value = config.dialogDefaults; + + // Assert + expect(value.general.referenceInputSpaceSubstitution).toBe(null); + }); + }); + describe('dialogDefaults.resetCommit.mode', () => { - it('Should return GitResetMode.Hard the configuration value is "Hard"', () => { + it('Should return GitResetMode.Hard when the configuration value is "Hard"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.resetCurrentBranchToCommit.mode', 'Hard'); @@ -1406,7 +1476,7 @@ describe('Config', () => { expect(value.resetCommit.mode).toBe(GitResetMode.Hard); }); - it('Should return GitResetMode.Mixed the configuration value is "Mixed"', () => { + it('Should return GitResetMode.Mixed when the configuration value is "Mixed"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.resetCurrentBranchToCommit.mode', 'Mixed'); @@ -1417,7 +1487,7 @@ describe('Config', () => { expect(value.resetCommit.mode).toBe(GitResetMode.Mixed); }); - it('Should return GitResetMode.Soft the configuration value is "Soft"', () => { + it('Should return GitResetMode.Soft when the configuration value is "Soft"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.resetCurrentBranchToCommit.mode', 'Soft'); @@ -1430,7 +1500,7 @@ describe('Config', () => { }); describe('dialogDefaults.resetUncommitted.mode', () => { - it('Should return GitResetMode.Hard the configuration value is "Hard"', () => { + it('Should return GitResetMode.Hard when the configuration value is "Hard"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.resetUncommittedChanges.mode', 'Hard'); @@ -1441,7 +1511,7 @@ describe('Config', () => { expect(value.resetUncommitted.mode).toBe(GitResetMode.Hard); }); - it('Should return GitResetMode.Mixed the configuration value is "Mixed"', () => { + it('Should return GitResetMode.Mixed when the configuration value is "Mixed"', () => { // Setup vscode.mockExtensionSettingReturnValue('dialog.resetUncommittedChanges.mode', 'Mixed'); diff --git a/web/dialog.ts b/web/dialog.ts index 9992b386..e1385a99 100644 --- a/web/dialog.ts +++ b/web/dialog.ts @@ -91,6 +91,8 @@ class Dialog { private type: DialogType | null = null; private customSelects: { [inputIndex: string]: CustomSelect } = {}; + private static readonly WHITESPACE_REGEXP = /\s/gu; + /** * Show a confirmation dialog to the user. * @param message A message outlining what the user is being asked to confirm. @@ -269,7 +271,13 @@ class Dialog { if (dialogInput.value === '') this.elem!.classList.add(CLASS_DIALOG_NO_INPUT); dialogInput.addEventListener('keyup', () => { if (this.elem === null) return; - let noInput = dialogInput.value === '', invalidInput = dialogInput.value.match(REF_INVALID_REGEX) !== null; + if (initialState.config.dialogDefaults.general.referenceInputSpaceSubstitution !== null) { + const selectionStart = dialogInput.selectionStart, selectionEnd = dialogInput.selectionEnd; + dialogInput.value = dialogInput.value.replace(Dialog.WHITESPACE_REGEXP, initialState.config.dialogDefaults.general.referenceInputSpaceSubstitution); + dialogInput.selectionStart = selectionStart; + dialogInput.selectionEnd = selectionEnd; + } + const noInput = dialogInput.value === '', invalidInput = dialogInput.value.match(REF_INVALID_REGEX) !== null; alterClass(this.elem, CLASS_DIALOG_NO_INPUT, noInput); if (alterClass(this.elem, CLASS_DIALOG_INPUT_INVALID, !noInput && invalidInput)) { dialogAction.title = invalidInput ? 'Unable to ' + actionName + ', one or more invalid characters entered.' : ''; From dbea5a1ac36a87eeecd62a896239a9a6516e36ce Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Sun, 7 Mar 2021 17:30:48 +1100 Subject: [PATCH 04/15] #457 New "View Diff with Working File" action on the file context menu in the Commit Details View. --- src/gitGraphView.ts | 8 +++++- src/types.ts | 11 +++++++++ src/utils.ts | 18 +++++++++++++- tests/utils.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++++-- web/main.ts | 51 ++++++++++++++++++++++++++------------ 5 files changed, 128 insertions(+), 20 deletions(-) diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 5af41d16..70244c04 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -8,7 +8,7 @@ import { Logger } from './logger'; import { RepoFileWatcher } from './repoFileWatcher'; import { RepoManager } from './repoManager'; import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types'; -import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewFileAtRevision, viewScm } from './utils'; +import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from './utils'; import { Disposable, toDisposable } from './utils/disposable'; /** @@ -582,6 +582,12 @@ export class GitGraphView extends Disposable { error: await viewDiff(msg.repo, msg.fromHash, msg.toHash, msg.oldFilePath, msg.newFilePath, msg.type) }); break; + case 'viewDiffWithWorkingFile': + this.sendMessage({ + command: 'viewDiffWithWorkingFile', + error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath) + }); + break; case 'viewFileAtRevision': this.sendMessage({ command: 'viewFileAtRevision', diff --git a/src/types.ts b/src/types.ts index 39afdf57..1e066a61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1172,6 +1172,15 @@ export interface ResponseViewDiff extends ResponseWithErrorInfo { readonly command: 'viewDiff'; } +export interface RequestViewDiffWithWorkingFile extends RepoRequest { + readonly command: 'viewDiffWithWorkingFile'; + readonly hash: string; + readonly filePath: string; +} +export interface ResponseViewDiffWithWorkingFile extends ResponseWithErrorInfo { + readonly command: 'viewDiffWithWorkingFile'; +} + export interface RequestViewFileAtRevision extends RepoRequest { readonly command: 'viewFileAtRevision'; readonly hash: string; @@ -1247,6 +1256,7 @@ export type RequestMessage = | RequestStartCodeReview | RequestTagDetails | RequestViewDiff + | RequestViewDiffWithWorkingFile | RequestViewFileAtRevision | RequestViewScm; @@ -1305,6 +1315,7 @@ export type ResponseMessage = | ResponseStartCodeReview | ResponseTagDetails | ResponseViewDiff + | ResponseViewDiffWithWorkingFile | ResponseViewFileAtRevision | ResponseViewScm; diff --git a/src/utils.ts b/src/utils.ts index 3dde5f82..33a30d91 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -381,13 +381,29 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile viewColumn: getConfig().openNewTabEditorGroup }).then( () => null, - () => 'Visual Studio Code was unable load the diff editor for ' + newFilePath + '.' + () => 'Visual Studio Code was unable to load the diff editor for ' + newFilePath + '.' ); } else { return openFile(repo, newFilePath); } } +/** + * Open the Visual Studio Code Diff View to display the changes of a file between a commit hash and the working tree. + * @param repo The repository the file is contained in. + * @param hash The revision of the left-side of the Diff View. + * @param filePath The relative path of the file within the repository. + * @returns A promise resolving to the ErrorInfo of the executed command. + */ +export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string) { + return new Promise((resolve) => { + const p = path.join(repo, filePath); + fs.access(p, fs.constants.R_OK, (err) => { + resolve(viewDiff(repo, hash, UNCOMMITTED, filePath, filePath, err === null ? GitFileStatus.Modified : GitFileStatus.Deleted)); + }); + }); +} + /** * Open a Visual Studio Code Editor (readonly) for a file a specific Git revision. * @param repo The repository the file is contained in. diff --git a/tests/utils.test.ts b/tests/utils.test.ts index e42bb95c..73a13bfb 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -24,7 +24,7 @@ import { DataSource } from '../src/dataSource'; import { ExtensionState } from '../src/extensionState'; import { Logger } from '../src/logger'; import { GitFileStatus, PullRequestProvider } from '../src/types'; -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'; +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, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; const extensionContext = vscode.mocks.extensionContext; @@ -1232,7 +1232,7 @@ describe('viewDiff', () => { const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); // Assert - expect(result).toBe('Visual Studio Code was unable load the diff editor for subfolder/modified.txt.'); + expect(result).toBe('Visual Studio Code was unable to load the diff editor for subfolder/modified.txt.'); }); it('Should open an untracked file in vscode', async () => { @@ -1255,6 +1255,62 @@ describe('viewDiff', () => { }); }); +describe('viewDiffWithWorkingFile', () => { + it('Should load the vscode diff view (modified file)', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + + // Run + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt'); + + // Assert + const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.diff'); + expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); + expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/modified.txt'); + expect(title).toBe('modified.txt (1a2b3c4d ↔ Present)'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Active + }); + expect(result).toBe(null); + }); + + it('Should load the vscode diff view (deleted file)', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + + // Run + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/deleted.txt'); + + // Assert + const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.diff'); + expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZiIsInJlcG8iOiIvcGF0aC90by9yZXBvIn0='); + expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(title).toBe('deleted.txt (Deleted between 1a2b3c4d & Present)'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Active + }); + expect(result).toBe(null); + }); + + it('Should return an error message when vscode was unable to load the diff view', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + vscode.commands.executeCommand.mockRejectedValueOnce(null); + + // Run + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt'); + + // Assert + expect(result).toBe('Visual Studio Code was unable to load the diff editor for subfolder/modified.txt.'); + }); +}); + describe('viewFileAtRevision', () => { it('Should open the file in vscode', async () => { // Setup diff --git a/web/main.ts b/web/main.ts index 7852ba34..b6c0a7a4 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2714,7 +2714,7 @@ class GitGraphView { }); } - private cdvFileViewed(filePath: string, fileElem: HTMLElement) { + private cdvFileViewed(filePath: string, fileElem: HTMLElement, markAsCodeReviewed: boolean) { const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFiles'); if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; @@ -2727,7 +2727,7 @@ class GitGraphView { lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); - if (expandedCommit.codeReview !== null) { + if (expandedCommit.codeReview !== null && markAsCodeReviewed) { let i = expandedCommit.codeReview.remainingFiles.indexOf(filePath); if (i > -1) { sendMessage({ command: 'codeReviewFileReviewed', repo: this.currentRepo, id: expandedCommit.codeReview.id, filePath: filePath }); @@ -2785,6 +2785,17 @@ class GitGraphView { const getFileElemOfEventTarget = (target: EventTarget) => (target).closest('.fileTreeFileRecord'); const getFileOfFileElem = (fileChanges: ReadonlyArray, fileElem: HTMLElement) => fileChanges[parseInt(fileElem.dataset.index!)]; + const getCommitHashForFile = (file: GG.GitFileChange, expandedCommit: ExpandedCommit) => { + const commit = this.commits[this.commitLookup[expandedCommit.commitHash]]; + if (expandedCommit.compareWithHash !== null) { + return this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; + } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { + return commit.stash.untrackedFilesHash!; + } else { + return expandedCommit.commitHash; + } + }; + const triggerViewFileDiff = (file: GG.GitFileChange, fileElem: HTMLElement) => { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; @@ -2811,7 +2822,7 @@ class GitGraphView { toHash = expandedCommit.commitHash; } - this.cdvFileViewed(file.newFilePath, fileElem); + this.cdvFileViewed(file.newFilePath, fileElem, true); sendMessage({ command: 'viewDiff', repo: this.currentRepo, @@ -2831,21 +2842,20 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - let commit = this.commits[this.commitLookup[expandedCommit.commitHash]], hash: string; - if (expandedCommit.compareWithHash !== null) { - hash = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; - } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { - hash = commit.stash.untrackedFilesHash!; - } else { - hash = expandedCommit.commitHash; - } + this.cdvFileViewed(file.newFilePath, fileElem, true); + sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; - this.cdvFileViewed(file.newFilePath, fileElem); - sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: hash, filePath: file.newFilePath }); + this.cdvFileViewed(file.newFilePath, fileElem, false); + sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { - this.cdvFileViewed(file.newFilePath, fileElem); + this.cdvFileViewed(file.newFilePath, fileElem, true); sendMessage({ command: 'openFile', repo: this.currentRepo, filePath: file.newFilePath }); }; @@ -2922,6 +2932,7 @@ class GitGraphView { elem: fileElem }; const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); + const fileExistsAtThisRevisionAndDiffPossible = file.type !== GG.GitFileStatus.Deleted && diffPossible && !isUncommitted; contextMenu.show([ [ @@ -2932,9 +2943,14 @@ class GitGraphView { }, { title: 'View File at this Revision', - visible: file.type !== GG.GitFileStatus.Deleted && diffPossible && !isUncommitted, + visible: fileExistsAtThisRevisionAndDiffPossible, onClick: () => triggerViewFileAtRevision(file, fileElem) }, + { + title: 'View Diff with Working File', + visible: fileExistsAtThisRevisionAndDiffPossible, + onClick: () => triggerViewFileDiffWithWorkingFile(file, fileElem) + }, { title: 'Open File', visible: file.type !== GG.GitFileStatus.Deleted, @@ -3240,7 +3256,10 @@ window.addEventListener('load', () => { } break; case 'viewDiff': - finishOrDisplayError(msg.error, 'Unable to View Diff of File'); + finishOrDisplayError(msg.error, 'Unable to View Diff'); + break; + case 'viewDiffWithWorkingFile': + finishOrDisplayError(msg.error, 'Unable to View Diff with Working File'); break; case 'viewFileAtRevision': finishOrDisplayError(msg.error, 'Unable to View File at Revision'); From 8238ea3a381817731d0d03a1753a552ed1dcf377 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 10 Mar 2021 20:20:15 +1100 Subject: [PATCH 05/15] #476 "Open File" action is now available in the Visual Studio Code Diff View Title Menu, when the Diff View is opened from Git Graph. --- package.json | 20 +++++ src/commands.ts | 44 ++++++++++- src/dataSource.ts | 9 +-- src/diffDocProvider.ts | 38 +++++---- src/utils.ts | 21 ++--- tests/commands.test.ts | 145 +++++++++++++++++++++++++++++++++- tests/diffDocProvider.test.ts | 13 +-- tests/mocks/vscode.ts | 10 ++- tests/utils.test.ts | 135 ++++++++++++++++++------------- 9 files changed, 340 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 4a75fff0..bb356a29 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,13 @@ "category": "Git Graph", "command": "git-graph.version", "title": "Get Version Information" + }, + { + "category": "Git Graph", + "command": "git-graph.openFile", + "title": "Open File", + "icon": "$(go-to-file)", + "enablement": "isInDiffEditor && resourceScheme == git-graph && git-graph:codiconsSupported" } ], "configuration": { @@ -1422,6 +1429,19 @@ } }, "menus": { + "commandPalette": [ + { + "command": "git-graph.openFile", + "when": "isInDiffEditor && resourceScheme == git-graph && git-graph:codiconsSupported" + } + ], + "editor/title": [ + { + "command": "git-graph.openFile", + "group": "navigation", + "when": "isInDiffEditor && resourceScheme == git-graph && git-graph:codiconsSupported" + } + ], "scm/title": [ { "when": "scmProvider == git && config.git-graph.sourceCodeProviderIntegrationLocation == 'Inline'", diff --git a/src/commands.ts b/src/commands.ts index abf896a4..bc77347c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,11 +3,12 @@ import * as vscode from 'vscode'; import { AvatarManager } from './avatarManager'; import { getConfig } from './config'; import { DataSource } from './dataSource'; +import { DiffDocProvider, decodeDiffDocUri } from './diffDocProvider'; import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; import { GitGraphView } from './gitGraphView'; import { Logger } from './logger'; import { RepoManager } from './repoManager'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, abbrevCommit, abbrevText, copyToClipboard, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; import { Disposable } from './utils/disposable'; import { Event } from './utils/event'; @@ -44,6 +45,7 @@ export class CommandManager extends Disposable { this.repoManager = repoManager; this.gitExecutable = gitExecutable; + // Register Extension Commands this.registerCommand('git-graph.view', (arg) => this.view(arg)); this.registerCommand('git-graph.addGitRepository', () => this.addGitRepository()); this.registerCommand('git-graph.removeGitRepository', () => this.removeGitRepository()); @@ -53,12 +55,20 @@ export class CommandManager extends Disposable { this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); this.registerCommand('git-graph.version', () => this.version()); + this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); this.registerDisposable( onDidChangeGitExecutable((gitExecutable) => { this.gitExecutable = gitExecutable; }) ); + + // Register Extension Contexts + try { + this.registerContext('git-graph:codiconsSupported', doesVersionMeetRequirement(vscode.version, '1.42.0')); + } catch (_) { + this.logger.logError('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); + } } /** @@ -72,6 +82,18 @@ export class CommandManager extends Disposable { ); } + /** + * Register a context with Visual Studio Code. + * @param key The Context Key. + * @param value The Context Value. + */ + private registerContext(key: string, value: any) { + return vscode.commands.executeCommand('setContext', key, value).then( + () => this.logger.log('Successfully set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"'), + () => this.logger.logError('Failed to set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"') + ); + } + /* Commands */ @@ -292,6 +314,26 @@ export class CommandManager extends Disposable { } } + /** + * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). + * The method run when the `git-graph.openFile` command is invoked. + * @param arg The Git Graph URI. + */ + private openFile(arg?: vscode.Uri) { + const uri = arg || vscode.window.activeTextEditor?.document.uri; + if (typeof uri === 'object' && uri.scheme === DiffDocProvider.scheme) { + // A Git Graph URI has been provided + const request = decodeDiffDocUri(uri); + return openFile(request.repo, request.filePath, vscode.ViewColumn.Active).then((errorInfo) => { + if (errorInfo !== null) { + return showErrorMessage('Unable to Open File: ' + errorInfo); + } + }); + } else { + return showErrorMessage('Unable to Open File: The command was not called with the required arguments.'); + } + } + /* Helper Methods */ diff --git a/src/dataSource.ts b/src/dataSource.ts index 4ee39e21..d1a0be67 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -7,7 +7,7 @@ import { AskpassEnvironment, AskpassManager } from './askpass/askpassManager'; import { getConfig } from './config'; import { Logger } from './logger'; import { CommitOrdering, DateType, DeepWriteable, ErrorInfo, GitCommit, GitCommitDetails, GitCommitStash, GitConfigLocation, GitFileChange, GitFileStatus, GitPushBranchMode, GitRepoConfig, GitRepoConfigBranches, GitResetMode, GitSignatureStatus, GitStash, MergeActionOn, RebaseActionOn, SquashMessageFormat, TagType, Writeable } from './types'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, abbrevCommit, constructIncompatibleGitVersionMessage, getPathFromStr, getPathFromUri, isGitAtLeastVersion, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, showErrorMessage } from './utils'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, abbrevCommit, constructIncompatibleGitVersionMessage, doesVersionMeetRequirement, getPathFromStr, getPathFromUri, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, showErrorMessage } from './utils'; import { Disposable } from './utils/disposable'; import { Event } from './utils/event'; @@ -88,7 +88,7 @@ export class DataSource extends Disposable { */ public setGitExecutable(gitExecutable: GitExecutable | null) { this.gitExecutable = gitExecutable; - this.gitExecutableSupportsGpgInfo = gitExecutable !== null ? isGitAtLeastVersion(gitExecutable, '2.4.0') : false; + this.gitExecutableSupportsGpgInfo = gitExecutable !== null ? doesVersionMeetRequirement(gitExecutable.version, '2.4.0') : false; this.generateGitCommandFormats(); } @@ -719,7 +719,7 @@ export class DataSource extends Disposable { if (pruneTags) { if (!prune) { return Promise.resolve('In order to Prune Tags, pruning must also be enabled when fetching from ' + (remote !== null ? 'a remote' : 'remote(s)') + '.'); - } else if (this.gitExecutable !== null && !isGitAtLeastVersion(this.gitExecutable, '2.17.0')) { + } else if (this.gitExecutable !== null && !doesVersionMeetRequirement(this.gitExecutable.version, '2.17.0')) { return Promise.resolve(constructIncompatibleGitVersionMessage(this.gitExecutable, '2.17.0', 'pruning tags when fetching')); } args.push('--prune-tags'); @@ -1197,8 +1197,7 @@ export class DataSource extends Disposable { public pushStash(repo: string, message: string, includeUntracked: boolean): Promise { if (this.gitExecutable === null) { return Promise.resolve(UNABLE_TO_FIND_GIT_MSG); - } - if (!isGitAtLeastVersion(this.gitExecutable, '2.13.2')) { + } else if (!doesVersionMeetRequirement(this.gitExecutable.version, '2.13.2')) { return Promise.resolve(constructIncompatibleGitVersionMessage(this.gitExecutable, '2.13.2')); } diff --git a/src/diffDocProvider.ts b/src/diffDocProvider.ts index 9ab521f3..abfce9cd 100644 --- a/src/diffDocProvider.ts +++ b/src/diffDocProvider.ts @@ -47,15 +47,20 @@ export class DiffDocProvider extends Disposable implements vscode.TextDocumentCo * @returns The content of the text document. */ public provideTextDocumentContent(uri: vscode.Uri): string | Thenable { - let document = this.docs.get(uri.toString()); - if (document) return document.value; + const document = this.docs.get(uri.toString()); + if (document) { + return document.value; + } - let request = decodeDiffDocUri(uri); - if (request === null) return ''; // Return empty file (used for one side of added / deleted file diff) + const request = decodeDiffDocUri(uri); + if (!request.exists) { + // Return empty file (used for one side of added / deleted file diff) + return ''; + } return this.dataSource.getCommitFile(request.repo, request.commit, request.filePath).then( (contents) => { - let document = new DiffDocument(contents); + const document = new DiffDocument(contents); this.docs.set(uri.toString(), document); return document.value; }, @@ -99,7 +104,8 @@ type DiffDocUriData = { filePath: string; commit: string; repo: string; -} | null; + exists: boolean; +}; /** * Produce the URI of a file to be used in the Visual Studio Diff View. @@ -115,17 +121,19 @@ export function encodeDiffDocUri(repo: string, filePath: string, commit: string, return vscode.Uri.file(path.join(repo, filePath)); } - let data: DiffDocUriData, extension: string; - if ((diffSide === DiffSide.Old && type === GitFileStatus.Added) || (diffSide === DiffSide.New && type === GitFileStatus.Deleted)) { - data = null; + const fileDoesNotExist = (diffSide === DiffSide.Old && type === GitFileStatus.Added) || (diffSide === DiffSide.New && type === GitFileStatus.Deleted); + const data: DiffDocUriData = { + filePath: getPathFromStr(filePath), + commit: commit, + repo: repo, + exists: !fileDoesNotExist + }; + + let extension: string; + if (fileDoesNotExist) { extension = ''; } else { - data = { - filePath: getPathFromStr(filePath), - commit: commit, - repo: repo - }; - let extIndex = data.filePath.indexOf('.', data.filePath.lastIndexOf('/') + 1); + const extIndex = data.filePath.indexOf('.', data.filePath.lastIndexOf('/') + 1); extension = extIndex > -1 ? data.filePath.substring(extIndex) : ''; } diff --git a/src/utils.ts b/src/utils.ts index 33a30d91..30a48206 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -334,16 +334,17 @@ export function openExternalUrl(url: string, type: string = 'External URL'): The * Open a file within a repository in Visual Studio Code. * @param repo The repository the file is contained in. * @param filePath The relative path of the file within the repository. + * @param viewColumn An optional ViewColumn that the file should be opened in. * @returns A promise resolving to the ErrorInfo of the executed command. */ -export function openFile(repo: string, filePath: string) { +export function openFile(repo: string, filePath: string, viewColumn: vscode.ViewColumn | null = null) { return new Promise(resolve => { const p = path.join(repo, filePath); fs.access(p, fs.constants.R_OK, (err) => { if (err === null) { vscode.commands.executeCommand('vscode.open', vscode.Uri.file(p), { preview: true, - viewColumn: getConfig().openNewTabEditorGroup + viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn }).then( () => resolve(null), () => resolve('Visual Studio Code was unable to open ' + filePath + '.') @@ -710,17 +711,17 @@ export async function getGitExecutableFromPaths(paths: string[]): Promise `executable` is at least `version`, FALSE => `executable` is older than `version`. + * Checks whether a version is at least a required version. + * @param version The version to check. + * @param requiredVersion The minimum required version. + * @returns TRUE => `version` is at least `requiredVersion`, FALSE => `version` is older than `requiredVersion`. */ -export function isGitAtLeastVersion(executable: GitExecutable, version: string) { - const v1 = parseVersion(executable.version); - const v2 = parseVersion(version); +export function doesVersionMeetRequirement(version: string, requiredVersion: string) { + const v1 = parseVersion(version); + const v2 = parseVersion(requiredVersion); if (v1 === null || v2 === null) { // Unable to parse a version number diff --git a/tests/commands.test.ts b/tests/commands.test.ts index ba4544e3..3ebbf415 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -15,10 +15,12 @@ import { ConfigurationChangeEvent } from 'vscode'; import { AvatarManager } from '../src/avatarManager'; import { CommandManager } from '../src/commands'; import { DataSource } from '../src/dataSource'; +import { DiffSide, encodeDiffDocUri } from '../src/diffDocProvider'; import { DEFAULT_REPO_STATE, ExtensionState } from '../src/extensionState'; import { GitGraphView } from '../src/gitGraphView'; import { Logger } from '../src/logger'; import { RepoManager } from '../src/repoManager'; +import { GitFileStatus } from '../src/types'; import * as utils from '../src/utils'; import { EventEmitter } from '../src/utils/event'; @@ -69,7 +71,7 @@ describe('CommandManager', () => { it('Should construct a CommandManager, and be disposed', () => { // Assert - expect(commandManager['disposables']).toHaveLength(10); + expect(commandManager['disposables']).toHaveLength(11); expect(commandManager['gitExecutable']).toStrictEqual({ path: '/path/to/git', version: '2.25.0' @@ -96,6 +98,81 @@ describe('CommandManager', () => { }); }); + describe('git-graph:codiconsSupported', () => { + it('Should set git-graph:codiconsSupported to TRUE when vscode.version >= 1.42.0', () => { + // Setup + commandManager.dispose(); + const spyOnExecuteCommand = jest.spyOn(vscode.commands, 'executeCommand'); + const spyOnLog = jest.spyOn(logger, 'log'); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + + // Run + commandManager = new CommandManager(vscode.mocks.extensionContext, avatarManager, dataSource, extensionState, repoManager, { path: '/path/to/git', version: '2.25.0' }, onDidChangeGitExecutable.subscribe, logger); + + // Assert + waitForExpect(() => { + expect(spyOnExecuteCommand).toHaveBeenCalledWith('setContext', 'git-graph:codiconsSupported', true); + expect(spyOnLog).toHaveBeenCalledWith('Successfully set Visual Studio Code Context "git-graph:codiconsSupported" to "true"'); + }); + }); + + it('Should set git-graph:codiconsSupported to FALSE when vscode.version < 1.42.0', () => { + // Setup + commandManager.dispose(); + vscode.mockVscodeVersion('1.41.1'); + const spyOnExecuteCommand = jest.spyOn(vscode.commands, 'executeCommand'); + const spyOnLog = jest.spyOn(logger, 'log'); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + + // Run + commandManager = new CommandManager(vscode.mocks.extensionContext, avatarManager, dataSource, extensionState, repoManager, { path: '/path/to/git', version: '2.25.0' }, onDidChangeGitExecutable.subscribe, logger); + + // Assert + waitForExpect(() => { + expect(spyOnExecuteCommand).toHaveBeenCalledWith('setContext', 'git-graph:codiconsSupported', false); + expect(spyOnLog).toHaveBeenCalledWith('Successfully set Visual Studio Code Context "git-graph:codiconsSupported" to "false"'); + }); + }); + + it('Should log an error message when vscode.commands.executeCommand rejects', () => { + // Setup + commandManager.dispose(); + const spyOnExecuteCommand = jest.spyOn(vscode.commands, 'executeCommand'); + const spyOnLogError = jest.spyOn(logger, 'logError'); + vscode.commands.executeCommand.mockRejectedValueOnce(null); + + // Run + commandManager = new CommandManager(vscode.mocks.extensionContext, avatarManager, dataSource, extensionState, repoManager, { path: '/path/to/git', version: '2.25.0' }, onDidChangeGitExecutable.subscribe, logger); + + // Assert + waitForExpect(() => { + expect(spyOnExecuteCommand).toHaveBeenCalledWith('setContext', 'git-graph:codiconsSupported', true); + expect(spyOnLogError).toHaveBeenCalledWith('Failed to set Visual Studio Code Context "git-graph:codiconsSupported" to "true"'); + }); + }); + + it('Should log an error message when an exception is thrown', () => { + // Setup + commandManager.dispose(); + const spyOnExecuteCommand = jest.spyOn(vscode.commands, 'executeCommand'); + const spyOnDoesVersionMeetRequirement = jest.spyOn(utils, 'doesVersionMeetRequirement'); + const spyOnLogError = jest.spyOn(logger, 'logError'); + vscode.commands.executeCommand.mockRejectedValueOnce(null); + spyOnDoesVersionMeetRequirement.mockImplementationOnce(() => { + throw new Error(); + }); + + // Run + commandManager = new CommandManager(vscode.mocks.extensionContext, avatarManager, dataSource, extensionState, repoManager, { path: '/path/to/git', version: '2.25.0' }, onDidChangeGitExecutable.subscribe, logger); + + // Assert + waitForExpect(() => { + expect(spyOnExecuteCommand).toHaveBeenCalledWith('setContext', 'git-graph:codiconsSupported', true); + expect(spyOnLogError).toHaveBeenCalledWith('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); + }); + }); + }); + describe('git-graph.view', () => { it('Should open the Git Graph View', async () => { // Setup @@ -141,6 +218,7 @@ describe('CommandManager', () => { it('Should open the Git Graph View to the repository containing the active text editor', async () => { // Setup + vscode.window.activeTextEditor = { document: { uri: vscode.Uri.file('/path/to/workspace-folder/active-file.txt') } }; vscode.mockExtensionSettingReturnValue('openToTheRepoOfTheActiveTextEditorDocument', true); jest.spyOn(repoManager, 'getRepoContainingFile').mockReturnValueOnce('/path/to/workspace-folder'); @@ -1118,4 +1196,69 @@ describe('CommandManager', () => { }); }); }); + + describe('git-graph.openFile', () => { + let spyOnOpenFile: jest.SpyInstance; + beforeAll(() => { + spyOnOpenFile = jest.spyOn(utils, 'openFile'); + }); + + it('Should open the provided file', async () => { + spyOnOpenFile.mockResolvedValueOnce(null); + + // Run + await vscode.commands.executeCommand('git-graph.openFile', encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New)); + + // Assert + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + }); + + it('Should open the file of the active text editor', async () => { + vscode.window.activeTextEditor = { document: { uri: encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New) } }; + spyOnOpenFile.mockResolvedValueOnce(null); + + // Run + await vscode.commands.executeCommand('git-graph.openFile'); + + // Assert + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + }); + + it('Should display an error message when no URI is provided', async () => { + vscode.window.activeTextEditor = undefined; + vscode.window.showErrorMessage.mockResolvedValueOnce(null); + + // Run + await vscode.commands.executeCommand('git-graph.openFile'); + + // Assert + expect(spyOnOpenFile).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: The command was not called with the required arguments.'); + }); + + it('Should display an error message when no Git Graph URI is provided', async () => { + vscode.window.activeTextEditor = { document: { uri: vscode.Uri.file('/path/to/workspace-folder/active-file.txt') } }; + vscode.window.showErrorMessage.mockResolvedValueOnce(null); + + // Run + await vscode.commands.executeCommand('git-graph.openFile'); + + // Assert + expect(spyOnOpenFile).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: The command was not called with the required arguments.'); + }); + + it('Should display an error message when the file can\'t be opened', async () => { + vscode.window.activeTextEditor = { document: { uri: encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New) } }; + spyOnOpenFile.mockResolvedValueOnce('Error Message'); + vscode.window.showErrorMessage.mockResolvedValueOnce(null); + + // Run + await vscode.commands.executeCommand('git-graph.openFile'); + + // Assert + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: Error Message'); + }); + }); }); diff --git a/tests/diffDocProvider.test.ts b/tests/diffDocProvider.test.ts index 5f036bcd..f04f90cc 100644 --- a/tests/diffDocProvider.test.ts +++ b/tests/diffDocProvider.test.ts @@ -156,7 +156,7 @@ describe('encodeDiffDocUri', () => { // Assert expect(uri.scheme).toBe('git-graph'); expect(uri.fsPath).toBe('file'); - expect(uri.query).toBe('bnVsbA=='); + expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjpmYWxzZX0='); }); it('Should return an empty file URI if requested on a file displayed on the new side of the diff, and it is deleted', () => { @@ -166,7 +166,7 @@ describe('encodeDiffDocUri', () => { // Assert expect(uri.scheme).toBe('git-graph'); expect(uri.fsPath).toBe('file'); - expect(uri.query).toBe('bnVsbA=='); + expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjpmYWxzZX0='); }); it('Should return a git-graph URI with the provided file extension', () => { @@ -176,7 +176,7 @@ describe('encodeDiffDocUri', () => { // Assert expect(uri.scheme).toBe('git-graph'); expect(uri.fsPath).toBe('file.txt'); - expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIn0='); + expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjp0cnVlfQ=='); }); it('Should return a git-graph URI with no file extension when it is not provided', () => { @@ -186,7 +186,7 @@ describe('encodeDiffDocUri', () => { // Assert expect(uri.scheme).toBe('git-graph'); expect(uri.fsPath).toBe('file'); - expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZSIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIiLCJyZXBvIjoiL3JlcG8ifQ=='); + expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZSIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIiLCJyZXBvIjoiL3JlcG8iLCJleGlzdHMiOnRydWV9'); }); }); @@ -206,14 +206,15 @@ describe('decodeDiffDocUri', () => { // Run const value = decodeDiffDocUri(vscode.Uri.file('file.txt').with({ scheme: 'git-graph', - query: 'eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIn0=' + query: 'eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZS50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiIiwicmVwbyI6Ii9yZXBvIiwiZXhpc3RzIjp0cnVlfQ==' })); // Assert expect(value).toStrictEqual({ filePath: 'path/to/file.txt', commit: '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', - repo: '/repo' + repo: '/repo', + exists: true }); }); }); diff --git a/tests/mocks/vscode.ts b/tests/mocks/vscode.ts index aad5f4db..bbe30aee 100644 --- a/tests/mocks/vscode.ts +++ b/tests/mocks/vscode.ts @@ -119,7 +119,7 @@ export enum StatusBarAlignment { Right = 2 } -export const version = '1.51.0'; +export let version = '1.51.0'; export enum ViewColumn { Active = -1, @@ -136,7 +136,7 @@ export enum ViewColumn { } export const window = { - activeTextEditor: { document: { uri: Uri.file('/path/to/workspace-folder/active-file.txt') } }, + activeTextEditor: { document: { uri: Uri.file('/path/to/workspace-folder/active-file.txt') } } as any, createOutputChannel: jest.fn(() => mocks.outputChannel), createStatusBarItem: jest.fn(() => mocks.statusBarItem), createTerminal: jest.fn(() => mocks.terminal), @@ -170,8 +170,14 @@ beforeEach(() => { Object.keys(mockedExtensionSettingValues).forEach((section) => { delete mockedExtensionSettingValues[section]; }); + + version = '1.51.0'; }); export function mockExtensionSettingReturnValue(section: string, value: any) { mockedExtensionSettingValues[section] = value; } + +export function mockVscodeVersion(newVersion: string) { + version = newVersion; +} diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 73a13bfb..3f88595a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -24,7 +24,7 @@ import { DataSource } from '../src/dataSource'; import { ExtensionState } from '../src/extensionState'; import { Logger } from '../src/logger'; import { GitFileStatus, PullRequestProvider } from '../src/types'; -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, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; +import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, doesVersionMeetRequirement, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; const extensionContext = vscode.mocks.extensionContext; @@ -940,7 +940,7 @@ describe('openExternalUrl', () => { }); describe('openFile', () => { - it('Should open the file in vscode', async () => { + it('Should open the file in vscode (with the user defined ViewColumn)', async () => { // Setup mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); vscode.commands.executeCommand.mockResolvedValueOnce(null); @@ -959,6 +959,25 @@ describe('openFile', () => { expect(result).toBe(null); }); + it('Should open the file in vscode (in the specified ViewColumn)', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + + // Run + const result = await openFile('/path/to/repo', 'file.txt', vscode.ViewColumn.Beside); + + // Assert + const [command, uri, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.open'); + expect(getPathFromUri(uri)).toBe('/path/to/repo/file.txt'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Beside + }); + expect(result).toBe(null); + }); + it('Should return an error message if vscode was unable to open the file', async () => { // Setup mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); @@ -989,13 +1008,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file?bnVsbA=='); - expect(rightUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9hZGRlZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b^', '/path/to/repo', false)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); expect(title).toBe('added.txt (Added in 1a2b3c4d)'); expect(config).toStrictEqual({ preview: true, @@ -1009,13 +1028,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmZeIiwicmVwbyI6Ii9wYXRoL3RvL3JlcG8ifQ=='); - expect(rightUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b^', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); expect(title).toBe('modified.txt (1a2b3c4d^ ↔ 1a2b3c4d)'); expect(config).toStrictEqual({ preview: true, @@ -1029,13 +1048,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2Zl4iLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); - expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b^', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', false)); expect(title).toBe('deleted.txt (Deleted in 1a2b3c4d)'); expect(config).toStrictEqual({ preview: true, @@ -1049,13 +1068,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file?bnVsbA=='); - expect(rightUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9hZGRlZC50eHQiLCJjb21taXQiOiJhMWIyYzNkNGU1ZjZhMWIyYzNkNGU1ZjZhMWIyYzNkNGU1ZjZhMWIyIiwicmVwbyI6Ii9wYXRoL3RvL3JlcG8ifQ=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', false)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', '/path/to/repo', true)); expect(title).toBe('added.txt (Added between 1a2b3c4d & a1b2c3d4)'); expect(config).toStrictEqual({ preview: true, @@ -1069,13 +1088,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); - expect(rightUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiJhMWIyYzNkNGU1ZjZhMWIyYzNkNGU1ZjZhMWIyYzNkNGU1ZjZhMWIyIiwicmVwbyI6Ii9wYXRoL3RvL3JlcG8ifQ=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', '/path/to/repo', true)); expect(title).toBe('modified.txt (1a2b3c4d ↔ a1b2c3d4)'); expect(config).toStrictEqual({ preview: true, @@ -1089,13 +1108,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZiIsInJlcG8iOiIvcGF0aC90by9yZXBvIn0='); - expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', '/path/to/repo', false)); expect(title).toBe('deleted.txt (Deleted between 1a2b3c4d & a1b2c3d4)'); expect(config).toStrictEqual({ preview: true, @@ -1109,12 +1128,12 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', UNCOMMITTED, 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', UNCOMMITTED, 'subfolder/added.txt', 'subfolder/added.txt', GitFileStatus.Added); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', false)); expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/added.txt'); expect(title).toBe('added.txt (Added between 1a2b3c4d & Present)'); expect(config).toStrictEqual({ @@ -1129,12 +1148,12 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', UNCOMMITTED, 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', UNCOMMITTED, 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/modified.txt'); expect(title).toBe('modified.txt (1a2b3c4d ↔ Present)'); expect(config).toStrictEqual({ @@ -1149,13 +1168,13 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', UNCOMMITTED, 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', UNCOMMITTED, 'subfolder/deleted.txt', 'subfolder/deleted.txt', GitFileStatus.Deleted); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZiIsInJlcG8iOiIvcGF0aC90by9yZXBvIn0='); - expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '*', '/path/to/repo', false)); expect(title).toBe('deleted.txt (Deleted between 1a2b3c4d & Present)'); expect(config).toStrictEqual({ preview: true, @@ -1174,7 +1193,7 @@ describe('viewDiff', () => { // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/added.txt', 'HEAD', '/path/to/repo', false)); expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/added.txt'); expect(title).toBe('added.txt (Uncommitted)'); expect(config).toStrictEqual({ @@ -1194,7 +1213,7 @@ describe('viewDiff', () => { // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiJIRUFEIiwicmVwbyI6Ii9wYXRoL3RvL3JlcG8ifQ=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', 'HEAD', '/path/to/repo', true)); expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/modified.txt'); expect(title).toBe('modified.txt (Uncommitted)'); expect(config).toStrictEqual({ @@ -1214,8 +1233,8 @@ describe('viewDiff', () => { // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IkhFQUQiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); - expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', 'HEAD', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '*', '/path/to/repo', false)); expect(title).toBe('deleted.txt (Uncommitted)'); expect(config).toStrictEqual({ preview: true, @@ -1229,7 +1248,7 @@ describe('viewDiff', () => { vscode.commands.executeCommand.mockRejectedValueOnce(null); // Run - const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); + const result = await viewDiff('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt', 'subfolder/modified.txt', GitFileStatus.Modified); // Assert expect(result).toBe('Visual Studio Code was unable to load the diff editor for subfolder/modified.txt.'); @@ -1262,12 +1281,12 @@ describe('viewDiffWithWorkingFile', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt'); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9tb2RpZmllZC50eHQiLCJjb21taXQiOiIxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYxYTJiM2M0ZDVlNmYiLCJyZXBvIjoiL3BhdGgvdG8vcmVwbyJ9'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/modified.txt'); expect(title).toBe('modified.txt (1a2b3c4d ↔ Present)'); expect(config).toStrictEqual({ @@ -1283,13 +1302,13 @@ describe('viewDiffWithWorkingFile', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/deleted.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/deleted.txt'); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.diff'); - expect(leftUri.toString()).toBe('git-graph://file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9kZWxldGVkLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZiIsInJlcG8iOiIvcGF0aC90by9yZXBvIn0='); - expect(rightUri.toString()).toBe('git-graph://file?bnVsbA=='); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/deleted.txt', '*', '/path/to/repo', false)); expect(title).toBe('deleted.txt (Deleted between 1a2b3c4d & Present)'); expect(config).toStrictEqual({ preview: true, @@ -1304,7 +1323,7 @@ describe('viewDiffWithWorkingFile', () => { vscode.commands.executeCommand.mockRejectedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/modified.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt'); // Assert expect(result).toBe('Visual Studio Code was unable to load the diff editor for subfolder/modified.txt.'); @@ -1317,12 +1336,12 @@ describe('viewFileAtRevision', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewFileAtRevision('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/file.txt'); + const result = await viewFileAtRevision('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/file.txt'); // Assert const [command, uri, config] = vscode.commands.executeCommand.mock.calls[0]; expect(command).toBe('vscode.open'); - expect(uri.toString()).toBe('git-graph://1a2b3c4d: file.txt?eyJmaWxlUGF0aCI6InN1YmZvbGRlci9maWxlLnR4dCIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZiIsInJlcG8iOiIvcGF0aC90by9yZXBvIn0='); + expect(uri.toString()).toBe(expectedValueGitGraphUri('subfolder/file.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true).replace('file.txt', '1a2b3c4d: file.txt')); expect(config).toStrictEqual({ preview: true, viewColumn: vscode.ViewColumn.Active @@ -1335,7 +1354,7 @@ describe('viewFileAtRevision', () => { vscode.commands.executeCommand.mockRejectedValueOnce(null); // Run - const result = await viewFileAtRevision('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f', 'subfolder/file.txt'); + const result = await viewFileAtRevision('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/file.txt'); // Assert expect(result).toBe('Visual Studio Code was unable to open subfolder/file.txt at commit 1a2b3c4d.'); @@ -2163,10 +2182,10 @@ describe('getGitExecutableFromPaths', () => { }); }); -describe('isGitAtLeastVersion', () => { +describe('doesVersionMeetRequirement', () => { it('Should correctly determine major newer', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6.windows.0', path: '' }, '1.4.6'); + const result = doesVersionMeetRequirement('2.4.6.windows.0', '1.4.6'); // Assert expect(result).toBe(true); @@ -2174,7 +2193,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine major older', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6.windows.0', path: '' }, '3.4.6'); + const result = doesVersionMeetRequirement('2.4.6.windows.0', '3.4.6'); // Assert expect(result).toBe(false); @@ -2182,7 +2201,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine minor newer', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6 (Apple Git-122.3)', path: '' }, '2.3.6'); + const result = doesVersionMeetRequirement('2.4.6 (Apple Git-122.3)', '2.3.6'); // Assert expect(result).toBe(true); @@ -2190,7 +2209,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine minor older', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6 (Apple Git-122.3)', path: '' }, '2.5.6'); + const result = doesVersionMeetRequirement('2.4.6 (Apple Git-122.3)', '2.5.6'); // Assert expect(result).toBe(false); @@ -2198,7 +2217,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine patch newer', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6', path: '' }, '2.4.5'); + const result = doesVersionMeetRequirement('2.4.6', '2.4.5'); // Assert expect(result).toBe(true); @@ -2206,7 +2225,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine patch older', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6', path: '' }, '2.4.7'); + const result = doesVersionMeetRequirement('2.4.6', '2.4.7'); // Assert expect(result).toBe(false); @@ -2214,7 +2233,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine same version', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6', path: '' }, '2.4.6'); + const result = doesVersionMeetRequirement('2.4.6', '2.4.6'); // Assert expect(result).toBe(true); @@ -2222,7 +2241,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine major newer if missing patch version', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4', path: '' }, '1.4'); + const result = doesVersionMeetRequirement('2.4', '1.4'); // Assert expect(result).toBe(true); @@ -2230,7 +2249,7 @@ describe('isGitAtLeastVersion', () => { it('Should correctly determine major newer if missing minor & patch versions', () => { // Run - const result = isGitAtLeastVersion({ version: '2', path: '' }, '1'); + const result = doesVersionMeetRequirement('2', '1'); // Assert expect(result).toBe(true); @@ -2238,13 +2257,13 @@ describe('isGitAtLeastVersion', () => { it('Should only use the valid portion of the version number to compute the result', () => { // Run - const result1 = isGitAtLeastVersion({ version: '2.4..6-windows.0', path: '' }, '2.4.1'); + const result1 = doesVersionMeetRequirement('2.4..6-windows.0', '2.4.1'); // Assert expect(result1).toBe(false); // Run - const result2 = isGitAtLeastVersion({ version: '2.4..6-windows.0', path: '' }, '2.4.0'); + const result2 = doesVersionMeetRequirement('2.4..6-windows.0', '2.4.0'); // Assert expect(result2).toBe(true); @@ -2252,7 +2271,7 @@ describe('isGitAtLeastVersion', () => { it('Should return TRUE if executable version is invalid', () => { // Run - const result = isGitAtLeastVersion({ version: 'a2.4.6', path: '' }, '1.4.6'); + const result = doesVersionMeetRequirement('a2.4.6', '1.4.6'); // Assert expect(result).toBe(true); @@ -2260,7 +2279,7 @@ describe('isGitAtLeastVersion', () => { it('Should return TRUE if version is invalid', () => { // Run - const result = isGitAtLeastVersion({ version: '2.4.6', path: '' }, 'a1.4.6'); + const result = doesVersionMeetRequirement('2.4.6', 'a1.4.6'); // Assert expect(result).toBe(true); @@ -2276,3 +2295,9 @@ describe('constructIncompatibleGitVersionMessage', () => { expect(result).toBe('A newer version of Git (>= 3.0.0) is required for this feature. Git 2.4.5 is currently installed. Please install a newer version of Git to use this feature.'); }); }); + +function expectedValueGitGraphUri(filePath: string, commit: string, repo: string, exists: boolean) { + const extIndex = filePath.indexOf('.', filePath.lastIndexOf('/') + 1); + const extension = exists && extIndex > -1 ? filePath.substring(extIndex) : ''; + return 'git-graph://file' + extension + '?' + Buffer.from(JSON.stringify({ filePath: filePath, commit: commit, repo: repo, exists: exists })).toString('base64'); +} From d0412a190c3d27a22a8ecc588488d52c8a50c930 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Sat, 13 Mar 2021 16:07:28 +1100 Subject: [PATCH 06/15] #480 When loading the Working File for a file from a historical commit, and the file has since been renamed, Git is now used to detect renames and enable the Working File to be opened. --- src/commands.ts | 2 +- src/dataSource.ts | 31 ++++++-- src/gitGraphView.ts | 4 +- src/types.ts | 1 + src/utils.ts | 83 ++++++++++++++------ tests/commands.test.ts | 6 +- tests/dataSource.test.ts | 38 +++++++++ tests/utils.test.ts | 164 +++++++++++++++++++++++++++++++++++++-- web/main.ts | 5 +- 9 files changed, 289 insertions(+), 45 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index bc77347c..fccc0e2c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -324,7 +324,7 @@ export class CommandManager extends Disposable { if (typeof uri === 'object' && uri.scheme === DiffDocProvider.scheme) { // A Git Graph URI has been provided const request = decodeDiffDocUri(uri); - return openFile(request.repo, request.filePath, vscode.ViewColumn.Active).then((errorInfo) => { + return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { if (errorInfo !== null) { return showErrorMessage('Unable to Open File: ' + errorInfo); } diff --git a/src/dataSource.ts b/src/dataSource.ts index d1a0be67..a9d4cf87 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -473,6 +473,20 @@ export class DataSource extends Disposable { }).then((url) => url, () => null); } + /** + * Check to see if a file has been renamed between a commit and the working tree, and return the new file path. + * @param repo The path of the repository. + * @param commitHash The commit hash where `oldFilePath` is known to have existed. + * @param oldFilePath The file path that may have been renamed. + * @returns The new renamed file path, or NULL if either: the file wasn't renamed or the Git command failed to execute. + */ + public getNewPathOfRenamedFile(repo: string, commitHash: string, oldFilePath: string) { + return this.getDiffNameStatus(repo, commitHash, '', 'R').then((renamed) => { + const renamedRecordForFile = renamed.find((record) => record.oldFilePath === oldFilePath); + return renamedRecordForFile ? renamedRecordForFile.newFilePath : null; + }).catch(() => null); + } + /** * Get the details of a tag. * @param repo The path of the repository. @@ -1386,10 +1400,11 @@ export class DataSource extends Disposable { * @param repo The path of the repository. * @param fromHash The revision the diff is from. * @param toHash The revision the diff is to. + * @param filter The types of file changes to retrieve (defaults to `AMDR`). * @returns An array of `--name-status` records. */ - private getDiffNameStatus(repo: string, fromHash: string, toHash: string) { - return this.execDiff(repo, fromHash, toHash, '--name-status').then((output) => { + private getDiffNameStatus(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') { + return this.execDiff(repo, fromHash, toHash, '--name-status', filter).then((output) => { let records: DiffNameStatusRecord[] = [], i = 0; while (i < output.length && output[i] !== '') { let type = output[i][0]; @@ -1415,10 +1430,11 @@ export class DataSource extends Disposable { * @param repo The path of the repository. * @param fromHash The revision the diff is from. * @param toHash The revision the diff is to. + * @param filter The types of file changes to retrieve (defaults to `AMDR`). * @returns An array of `--numstat` records. */ - private getDiffNumStat(repo: string, fromHash: string, toHash: string) { - return this.execDiff(repo, fromHash, toHash, '--numstat').then((output) => { + private getDiffNumStat(repo: string, fromHash: string, toHash: string, filter: string = 'AMDR') { + return this.execDiff(repo, fromHash, toHash, '--numstat', filter).then((output) => { let records: DiffNumStatRecord[] = [], i = 0; while (i < output.length && output[i] !== '') { let fields = output[i].split('\t'); @@ -1656,14 +1672,15 @@ export class DataSource extends Disposable { * @param fromHash The revision the diff is from. * @param toHash The revision the diff is to. * @param arg Sets the data reported from the diff. + * @param filter The types of file changes to retrieve. * @returns The diff output. */ - private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status') { + private execDiff(repo: string, fromHash: string, toHash: string, arg: '--numstat' | '--name-status', filter: string) { let args: string[]; if (fromHash === toHash) { - args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=AMDR', '-z', fromHash]; + args = ['diff-tree', arg, '-r', '--root', '--find-renames', '--diff-filter=' + filter, '-z', fromHash]; } else { - args = ['diff', arg, '--find-renames', '--diff-filter=AMDR', '-z', fromHash]; + args = ['diff', arg, '--find-renames', '--diff-filter=' + filter, '-z', fromHash]; if (toHash !== '') args.push(toHash); } diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 70244c04..fc5f815a 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -467,7 +467,7 @@ export class GitGraphView extends Disposable { case 'openFile': this.sendMessage({ command: 'openFile', - error: await openFile(msg.repo, msg.filePath) + error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource) }); break; case 'openTerminal': @@ -585,7 +585,7 @@ export class GitGraphView extends Disposable { case 'viewDiffWithWorkingFile': this.sendMessage({ command: 'viewDiffWithWorkingFile', - error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath) + error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource) }); break; case 'viewFileAtRevision': diff --git a/src/types.ts b/src/types.ts index 1e066a61..6e28f507 100644 --- a/src/types.ts +++ b/src/types.ts @@ -978,6 +978,7 @@ export interface ResponseOpenExternalUrl extends ResponseWithErrorInfo { export interface RequestOpenFile extends RepoRequest { readonly command: 'openFile'; + readonly hash: string; readonly filePath: string; } export interface ResponseOpenFile extends ResponseWithErrorInfo { diff --git a/src/utils.ts b/src/utils.ts index 30a48206..7bb59f31 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -105,6 +105,17 @@ export async function resolveToSymbolicPath(path: string) { return path; } +/** + * Checks whether a file exists, and the user has access to read it. + * @param path The path of the file. + * @returns Promise resolving to a boolean: TRUE => File exists, FALSE => File doesn't exist. + */ +export function doesFileExist(path: string) { + return new Promise((resolve) => { + fs.access(path, fs.constants.R_OK, (err) => resolve(err === null)); + }); +} + /* General Methods */ @@ -334,26 +345,38 @@ export function openExternalUrl(url: string, type: string = 'External URL'): The * Open a file within a repository in Visual Studio Code. * @param repo The repository the file is contained in. * @param filePath The relative path of the file within the repository. + * @param hash An optional commit hash where the file is known to have existed. + * @param dataSource An optional DataSource instance, that's used to check if the file has been renamed. * @param viewColumn An optional ViewColumn that the file should be opened in. * @returns A promise resolving to the ErrorInfo of the executed command. */ -export function openFile(repo: string, filePath: string, viewColumn: vscode.ViewColumn | null = null) { - return new Promise(resolve => { - const p = path.join(repo, filePath); - fs.access(p, fs.constants.R_OK, (err) => { - if (err === null) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.file(p), { - preview: true, - viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn - }).then( - () => resolve(null), - () => resolve('Visual Studio Code was unable to open ' + filePath + '.') - ); - } else { - resolve('The file ' + filePath + ' doesn\'t currently exist in this repository.'); +export async function openFile(repo: string, filePath: string, hash: string | null = null, dataSource: DataSource | null = null, viewColumn: vscode.ViewColumn | null = null) { + let newFilePath = filePath; + let newAbsoluteFilePath = path.join(repo, newFilePath); + let fileExists = await doesFileExist(newAbsoluteFilePath); + if (!fileExists && hash !== null && dataSource !== null) { + const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath); + if (renamedFilePath !== null) { + const renamedAbsoluteFilePath = path.join(repo, renamedFilePath); + if (await doesFileExist(renamedAbsoluteFilePath)) { + newFilePath = renamedFilePath; + newAbsoluteFilePath = renamedAbsoluteFilePath; + fileExists = true; } - }); - }); + } + } + + if (fileExists) { + return vscode.commands.executeCommand('vscode.open', vscode.Uri.file(newAbsoluteFilePath), { + preview: true, + viewColumn: viewColumn === null ? getConfig().openNewTabEditorGroup : viewColumn + }).then( + () => null, + () => 'Visual Studio Code was unable to open ' + newFilePath + '.' + ); + } else { + return 'The file ' + newFilePath + ' doesn\'t currently exist in this repository.'; + } } /** @@ -394,15 +417,27 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile * @param repo The repository the file is contained in. * @param hash The revision of the left-side of the Diff View. * @param filePath The relative path of the file within the repository. + * @param dataSource A DataSource instance, that's used to check if the file has been renamed. * @returns A promise resolving to the ErrorInfo of the executed command. */ -export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string) { - return new Promise((resolve) => { - const p = path.join(repo, filePath); - fs.access(p, fs.constants.R_OK, (err) => { - resolve(viewDiff(repo, hash, UNCOMMITTED, filePath, filePath, err === null ? GitFileStatus.Modified : GitFileStatus.Deleted)); - }); - }); +export async function viewDiffWithWorkingFile(repo: string, hash: string, filePath: string, dataSource: DataSource) { + let newFilePath = filePath; + let fileExists = await doesFileExist(path.join(repo, newFilePath)); + if (!fileExists) { + const renamedFilePath = await dataSource.getNewPathOfRenamedFile(repo, hash, filePath); + if (renamedFilePath !== null && await doesFileExist(path.join(repo, renamedFilePath))) { + newFilePath = renamedFilePath; + fileExists = true; + } + } + + const type = fileExists + ? filePath === newFilePath + ? GitFileStatus.Modified + : GitFileStatus.Renamed + : GitFileStatus.Deleted; + + return viewDiff(repo, hash, UNCOMMITTED, filePath, newFilePath, type); } /** @@ -412,7 +447,7 @@ export function viewDiffWithWorkingFile(repo: string, hash: string, filePath: st * @param filePath The relative path of the file within the repository. * @returns A promise resolving to the ErrorInfo of the executed command. */ -export async function viewFileAtRevision(repo: string, hash: string, filePath: string) { +export function viewFileAtRevision(repo: string, hash: string, filePath: string) { const pathComponents = filePath.split('/'); const title = abbrevCommit(hash) + ': ' + pathComponents[pathComponents.length - 1]; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 3ebbf415..f3304341 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -1210,7 +1210,7 @@ describe('CommandManager', () => { await vscode.commands.executeCommand('git-graph.openFile', encodeDiffDocUri('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', GitFileStatus.Modified, DiffSide.New)); // Assert - expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active); }); it('Should open the file of the active text editor', async () => { @@ -1221,7 +1221,7 @@ describe('CommandManager', () => { await vscode.commands.executeCommand('git-graph.openFile'); // Assert - expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active); }); it('Should display an error message when no URI is provided', async () => { @@ -1257,7 +1257,7 @@ describe('CommandManager', () => { await vscode.commands.executeCommand('git-graph.openFile'); // Assert - expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', vscode.ViewColumn.Active); + expect(spyOnOpenFile).toHaveBeenCalledWith('/path/to/repo', 'subfolder/modified.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active); expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: Error Message'); }); }); diff --git a/tests/dataSource.test.ts b/tests/dataSource.test.ts index 10b9e940..d722503a 100644 --- a/tests/dataSource.test.ts +++ b/tests/dataSource.test.ts @@ -3796,6 +3796,44 @@ describe('DataSource', () => { }); }); + describe('getNewPathOfRenamedFile', () => { + it('Should return the new path of a file that was renamed', async () => { + // Setup + mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0')); + + // Run + const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/renamed-old.txt'); + + // Assert + expect(result).toBe('dir/renamed-new.txt'); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should return NULL when a file wasn\'t renamed', async () => { + // Setup + mockGitSuccessOnce(['R100', 'dir/renamed-old.txt', 'dir/renamed-new.txt', ''].join('\0')); + + // Run + const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt'); + + // Assert + expect(result).toBe(null); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should return NULL when git threw an error', async () => { + // Setup + mockGitThrowingErrorOnce(); + + // Run + const result = await dataSource.getNewPathOfRenamedFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'dir/deleted.txt'); + + // Assert + expect(result).toBe(null); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['diff', '--name-status', '--find-renames', '--diff-filter=R', '-z', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + }); + describe('getTagDetails', () => { it('Should return the tags details', async () => { // Setup diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 3f88595a..0878df0a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -24,7 +24,7 @@ import { DataSource } from '../src/dataSource'; import { ExtensionState } from '../src/extensionState'; import { Logger } from '../src/logger'; import { GitFileStatus, PullRequestProvider } from '../src/types'; -import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, doesVersionMeetRequirement, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; +import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, doesFileExist, doesVersionMeetRequirement, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; const extensionContext = vscode.mocks.extensionContext; @@ -309,6 +309,32 @@ describe('resolveToSymbolicPath', () => { }); }); +describe('doesFileExist', () => { + it('Should return TRUE when the file exists', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + + // Run + const result = await doesFileExist('file.txt'); + + // Assert + expect(result).toBe(true); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, 'file.txt', fs.constants.R_OK, expect.anything()); + }); + + it('Should return FILE when the file doesn\'t exist', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + + // Run + const result = await doesFileExist('file.txt'); + + // Assert + expect(result).toBe(false); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, 'file.txt', fs.constants.R_OK, expect.anything()); + }); +}); + describe('abbrevCommit', () => { it('Truncates a commit hash to eight characters', () => { // Run @@ -957,6 +983,7 @@ describe('openFile', () => { viewColumn: vscode.ViewColumn.Active }); expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'file.txt'), fs.constants.R_OK, expect.anything()); }); it('Should open the file in vscode (in the specified ViewColumn)', async () => { @@ -965,7 +992,7 @@ describe('openFile', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await openFile('/path/to/repo', 'file.txt', vscode.ViewColumn.Beside); + const result = await openFile('/path/to/repo', 'file.txt', null, null, vscode.ViewColumn.Beside); // Assert const [command, uri, config] = vscode.commands.executeCommand.mock.calls[0]; @@ -976,6 +1003,32 @@ describe('openFile', () => { viewColumn: vscode.ViewColumn.Beside }); expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'file.txt'), fs.constants.R_OK, expect.anything()); + }); + + it('Should open a renamed file in vscode', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce('renamed-new.txt'); + + // Run + const result = await openFile('/path/to/repo', 'renamed-old.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource); + + // Assert + const [command, uri, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.open'); + expect(getPathFromUri(uri)).toBe('/path/to/repo/renamed-new.txt'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Active + }); + expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'renamed-old.txt'), fs.constants.R_OK, expect.anything()); + expect(spyOnGetNewPathOfRenamedFile).toHaveBeenCalledWith('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'renamed-old.txt'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(2, path.join('/path/to/repo', 'renamed-new.txt'), fs.constants.R_OK, expect.anything()); }); it('Should return an error message if vscode was unable to open the file', async () => { @@ -988,6 +1041,7 @@ describe('openFile', () => { // Assert expect(result).toBe('Visual Studio Code was unable to open file.txt.'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'file.txt'), fs.constants.R_OK, expect.anything()); }); it('Should return an error message if the file doesn\'t exist in the repository', async () => { @@ -995,10 +1049,45 @@ describe('openFile', () => { mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); // Run - const result = await openFile('/path/to/repo', 'file.txt'); + const result = await openFile('/path/to/repo', 'deleted.txt'); + + // Assert + expect(result).toBe('The file deleted.txt doesn\'t currently exist in this repository.'); + expect(mockedFileSystemModule.access).toHaveBeenCalledTimes(1); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'deleted.txt'), fs.constants.R_OK, expect.anything()); + }); + + it('Should return an error message if the file doesn\'t exist in the repository, and it wasn\'t renamed', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce(null); + + // Run + const result = await openFile('/path/to/repo', 'deleted.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource); // Assert - expect(result).toBe('The file file.txt doesn\'t currently exist in this repository.'); + expect(result).toBe('The file deleted.txt doesn\'t currently exist in this repository.'); + expect(mockedFileSystemModule.access).toHaveBeenCalledTimes(1); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'deleted.txt'), fs.constants.R_OK, expect.anything()); + expect(spyOnGetNewPathOfRenamedFile).toHaveBeenCalledWith('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'deleted.txt'); + }); + + it('Should return an error message if the file doesn\'t exist in the repository, and it was renamed', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce('renamed-new.txt'); + + // Run + const result = await openFile('/path/to/repo', 'renamed-old.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource); + + // Assert + expect(result).toBe('The file renamed-old.txt doesn\'t currently exist in this repository.'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'renamed-old.txt'), fs.constants.R_OK, expect.anything()); + expect(spyOnGetNewPathOfRenamedFile).toHaveBeenCalledWith('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'renamed-old.txt'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(2, path.join('/path/to/repo', 'renamed-new.txt'), fs.constants.R_OK, expect.anything()); }); }); @@ -1271,6 +1360,7 @@ describe('viewDiff', () => { viewColumn: vscode.ViewColumn.Active }); expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/untracked.txt'), fs.constants.R_OK, expect.anything()); }); }); @@ -1281,7 +1371,7 @@ describe('viewDiffWithWorkingFile', () => { vscode.commands.executeCommand.mockResolvedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt', dataSource); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; @@ -1294,15 +1384,45 @@ describe('viewDiffWithWorkingFile', () => { viewColumn: vscode.ViewColumn.Active }); expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/modified.txt'), fs.constants.R_OK, expect.anything()); + }); + + it('Should load the vscode diff view (renamed file)', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(null)); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce('subfolder/renamed-new.txt'); + + // Run + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/renamed-old.txt', dataSource); + + // Assert + const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.diff'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/renamed-old.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(getPathFromUri(rightUri)).toBe('/path/to/repo/subfolder/renamed-new.txt'); + expect(title).toBe('renamed-new.txt (1a2b3c4d ↔ Present)'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Active + }); + expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/renamed-old.txt'), fs.constants.R_OK, expect.anything()); + expect(spyOnGetNewPathOfRenamedFile).toHaveBeenCalledWith('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/renamed-old.txt'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(2, path.join('/path/to/repo', 'subfolder/renamed-new.txt'), fs.constants.R_OK, expect.anything()); }); it('Should load the vscode diff view (deleted file)', async () => { // Setup mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); vscode.commands.executeCommand.mockResolvedValueOnce(null); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/deleted.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/deleted.txt', dataSource); // Assert const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; @@ -1315,6 +1435,35 @@ describe('viewDiffWithWorkingFile', () => { viewColumn: vscode.ViewColumn.Active }); expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenCalledTimes(1); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/deleted.txt'), fs.constants.R_OK, expect.anything()); + }); + + it('Should load the vscode diff view (renamed and deleted file)', async () => { + // Setup + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); + vscode.commands.executeCommand.mockResolvedValueOnce(null); + const spyOnGetNewPathOfRenamedFile = jest.spyOn(dataSource, 'getNewPathOfRenamedFile'); + spyOnGetNewPathOfRenamedFile.mockResolvedValueOnce('subfolder/renamed-new.txt'); + + // Run + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/renamed-old.txt', dataSource); + + // Assert + const [command, leftUri, rightUri, title, config] = vscode.commands.executeCommand.mock.calls[0]; + expect(command).toBe('vscode.diff'); + expect(leftUri.toString()).toBe(expectedValueGitGraphUri('subfolder/renamed-old.txt', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', '/path/to/repo', true)); + expect(rightUri.toString()).toBe(expectedValueGitGraphUri('subfolder/renamed-old.txt', '*', '/path/to/repo', false)); + expect(title).toBe('renamed-old.txt (Deleted between 1a2b3c4d & Present)'); + expect(config).toStrictEqual({ + preview: true, + viewColumn: vscode.ViewColumn.Active + }); + expect(result).toBe(null); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/renamed-old.txt'), fs.constants.R_OK, expect.anything()); + expect(spyOnGetNewPathOfRenamedFile).toHaveBeenCalledWith('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/renamed-old.txt'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(2, path.join('/path/to/repo', 'subfolder/renamed-new.txt'), fs.constants.R_OK, expect.anything()); }); it('Should return an error message when vscode was unable to load the diff view', async () => { @@ -1323,10 +1472,11 @@ describe('viewDiffWithWorkingFile', () => { vscode.commands.executeCommand.mockRejectedValueOnce(null); // Run - const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt'); + const result = await viewDiffWithWorkingFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.txt', dataSource); // Assert expect(result).toBe('Visual Studio Code was unable to load the diff editor for subfolder/modified.txt.'); + expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, path.join('/path/to/repo', 'subfolder/modified.txt'), fs.constants.R_OK, expect.anything()); }); }); diff --git a/web/main.ts b/web/main.ts index b6c0a7a4..56a21752 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2855,8 +2855,11 @@ class GitGraphView { }; const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + this.cdvFileViewed(file.newFilePath, fileElem, true); - sendMessage({ command: 'openFile', repo: this.currentRepo, filePath: file.newFilePath }); + sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; addListenerToClass('fileTreeFolder', 'click', (e) => { From 556e4949e3791ad67ec516e7ef990a08676890be Mon Sep 17 00:00:00 2001 From: Dan Arad Date: Sun, 14 Mar 2021 13:53:02 +1100 Subject: [PATCH 07/15] #482 New "Mark as Reviewed" & "Mark as Not Reviewed" actions on the file context menu in the Commit Details View when a Code Review is in progress. Squash Merge of @dan1994's PR #483. --- src/extensionState.ts | 33 +++++---- src/gitGraphView.ts | 9 ++- src/types.ts | 20 ++++-- tests/extensionState.test.ts | 136 ++++++++++++++++++----------------- web/main.ts | 90 ++++++++++++++++------- 5 files changed, 177 insertions(+), 111 deletions(-) diff --git a/src/extensionState.ts b/src/extensionState.ts index 3ccfaf2b..56dbc7d0 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -359,24 +359,31 @@ export class ExtensionState extends Disposable { } /** - * Record that a file has been reviewed in a Code Review. + * Update information for a specific Code Review. * @param repo The repository the Code Review is in. * @param id The ID of the Code Review. - * @param file The file that has been reviewed. + * @param remainingFiles The files remaining for review. + * @param lastViewedFile The last viewed file. If null, don't change the last viewed file. + * @returns An error message if request can't be completed. */ - public updateCodeReviewFileReviewed(repo: string, id: string, file: string) { - let reviews = this.getCodeReviews(); - if (typeof reviews[repo] !== 'undefined' && typeof reviews[repo][id] !== 'undefined') { - let i = reviews[repo][id].remainingFiles.indexOf(file); - if (i > -1) reviews[repo][id].remainingFiles.splice(i, 1); - if (reviews[repo][id].remainingFiles.length > 0) { - reviews[repo][id].lastViewedFile = file; - reviews[repo][id].lastActive = (new Date()).getTime(); - } else { - removeCodeReview(reviews, repo, id); + public updateCodeReview(repo: string, id: string, remainingFiles: string[], lastViewedFile: string | null) { + const reviews = this.getCodeReviews(); + + if (typeof reviews[repo] === 'undefined' || typeof reviews[repo][id] === 'undefined') { + return Promise.resolve('The Code Review could not be found.'); + } + + if (remainingFiles.length > 0) { + reviews[repo][id].remainingFiles = remainingFiles; + reviews[repo][id].lastActive = (new Date()).getTime(); + if (lastViewedFile !== null) { + reviews[repo][id].lastViewedFile = lastViewedFile; } - this.setCodeReviews(reviews); + } else { + removeCodeReview(reviews, repo, id); } + + return this.setCodeReviews(reviews); } /** diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index fc5f815a..47f23ad2 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -227,9 +227,6 @@ export class GitGraphView extends Disposable { error: await this.dataSource.cleanUntrackedFiles(msg.repo, msg.directories) }); break; - case 'codeReviewFileReviewed': - this.extensionState.updateCodeReviewFileReviewed(msg.repo, msg.id, msg.filePath); - break; case 'commitDetails': let data = await Promise.all([ msg.commitHash === UNCOMMITTED @@ -576,6 +573,12 @@ export class GitGraphView extends Disposable { ...await this.dataSource.getTagDetails(msg.repo, msg.tagName) }); break; + case 'updateCodeReview': + this.sendMessage({ + command: 'updateCodeReview', + error: await this.extensionState.updateCodeReview(msg.repo, msg.id, msg.remainingFiles, msg.lastViewedFile) + }); + break; case 'viewDiff': this.sendMessage({ command: 'viewDiff', diff --git a/src/types.ts b/src/types.ts index 6e28f507..81233fcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -645,12 +645,6 @@ export interface ResponseCleanUntrackedFiles extends ResponseWithErrorInfo { readonly command: 'cleanUntrackedFiles'; } -export interface RequestCodeReviewFileReviewed extends RepoRequest { - readonly command: 'codeReviewFileReviewed'; - readonly id: string; - readonly filePath: string; -} - export interface RequestCommitDetails extends RepoRequest { readonly command: 'commitDetails'; readonly commitHash: string; @@ -1161,6 +1155,17 @@ export interface ResponseTagDetails extends ResponseWithErrorInfo { readonly message: string; } +export interface RequestUpdateCodeReview extends RepoRequest { + readonly command: 'updateCodeReview'; + readonly id: string; + readonly remainingFiles: string[]; + readonly lastViewedFile: string | null; +} + +export interface ResponseUpdateCodeReview extends ResponseWithErrorInfo { + readonly command: 'updateCodeReview'; +} + export interface RequestViewDiff extends RepoRequest { readonly command: 'viewDiff'; readonly fromHash: string; @@ -1207,7 +1212,6 @@ export type RequestMessage = | RequestCheckoutCommit | RequestCherrypickCommit | RequestCleanUntrackedFiles - | RequestCodeReviewFileReviewed | RequestCommitDetails | RequestCompareCommits | RequestCopyFilePath @@ -1256,6 +1260,7 @@ export type RequestMessage = | RequestShowErrorDialog | RequestStartCodeReview | RequestTagDetails + | RequestUpdateCodeReview | RequestViewDiff | RequestViewDiffWithWorkingFile | RequestViewFileAtRevision @@ -1315,6 +1320,7 @@ export type ResponseMessage = | ResponseSetWorkspaceViewState | ResponseStartCodeReview | ResponseTagDetails + | ResponseUpdateCodeReview | ResponseViewDiff | ResponseViewDiffWithWorkingFile | ResponseViewFileAtRevision diff --git a/tests/extensionState.test.ts b/tests/extensionState.test.ts index 96ad0547..77ba7f46 100644 --- a/tests/extensionState.test.ts +++ b/tests/extensionState.test.ts @@ -1167,29 +1167,37 @@ describe('ExtensionState', () => { }); }); - describe('updateCodeReviewFileReviewed', () => { - it('Should remove the reviewed file, set it as the last viewed file, and update the last active time', () => { - // Setup + describe('updateCodeReview', () => { + const repo = '/path/to/repo'; + const id = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + const startTime = 1587559257000; + const endTime = startTime + 1000; + + beforeEach(() => { const codeReviews = { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559257000, + [repo]: { + [id]: { + lastActive: startTime, lastViewedFile: 'file1.txt', remainingFiles: ['file2.txt', 'file3.txt'] } } }; + extensionContext.workspaceState.get.mockReturnValueOnce(codeReviews); extensionContext.workspaceState.update.mockResolvedValueOnce(null); + }); + it('Should update the reviewed files and change the last viewed file', async () => { // Run - extensionState.updateCodeReviewFileReviewed('/path/to/repo', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'file2.txt'); + const result = await extensionState.updateCodeReview(repo, id, ['file3.txt'], 'file2.txt'); - // Assert + // Asset + expect(result).toBeNull(); expect(extensionContext.workspaceState.update).toHaveBeenCalledWith('codeReviews', { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559258000, + [repo]: { + [id]: { + lastActive: endTime, lastViewedFile: 'file2.txt', remainingFiles: ['file3.txt'] } @@ -1197,94 +1205,94 @@ describe('ExtensionState', () => { }); }); - it('Should ignore removing reviewed files if it has already be stored as reviewed', () => { - // Setup - const codeReviews = { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559257000, + it('Should update the reviewed files without changing the last viewed file', async () => { + // Run + const result = await extensionState.updateCodeReview(repo, id, ['file3.txt'], null); + + // Assert + expect(result).toBeNull(); + expect(extensionContext.workspaceState.update).toHaveBeenCalledWith('codeReviews', { + [repo]: { + [id]: { + lastActive: endTime, lastViewedFile: 'file1.txt', remainingFiles: ['file3.txt'] } } - }; - extensionContext.workspaceState.get.mockReturnValueOnce(codeReviews); - extensionContext.workspaceState.update.mockResolvedValueOnce(null); + }); + }); + it('Should update the not reviewed files without changing the last viewed file', async () => { // Run - extensionState.updateCodeReviewFileReviewed('/path/to/repo', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'file2.txt'); + const result = await extensionState.updateCodeReview(repo, id, ['file2.txt', 'file3.txt', 'file4.txt'], null); // Assert + expect(result).toBeNull(); expect(extensionContext.workspaceState.update).toHaveBeenCalledWith('codeReviews', { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559258000, - lastViewedFile: 'file2.txt', - remainingFiles: ['file3.txt'] + [repo]: { + [id]: { + lastActive: endTime, + lastViewedFile: 'file1.txt', + remainingFiles: ['file2.txt', 'file3.txt', 'file4.txt'] } } }); }); - it('Should remove the code review the last file in it has been reviewed', () => { - // Setup - const codeReviews = { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559257000, + it('Should set the last viewed file', async () => { + // Run + const result = await extensionState.updateCodeReview(repo, id, ['file2.txt', 'file3.txt'], 'file2.txt'); + + // Assert + expect(result).toBeNull(); + expect(extensionContext.workspaceState.update).toHaveBeenCalledWith('codeReviews', { + [repo]: { + [id]: { + lastActive: endTime, lastViewedFile: 'file2.txt', - remainingFiles: ['file3.txt'] + remainingFiles: ['file2.txt', 'file3.txt'] } } - }; - extensionContext.workspaceState.get.mockReturnValueOnce(codeReviews); - extensionContext.workspaceState.update.mockResolvedValueOnce(null); + }); + }); + it('Should remove the code review the last file in it has been reviewed', async () => { // Run - extensionState.updateCodeReviewFileReviewed('/path/to/repo', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'file3.txt'); + const result = await extensionState.updateCodeReview(repo, id, [], null); // Assert + expect(result).toBeNull(); expect(extensionContext.workspaceState.update).toHaveBeenCalledWith('codeReviews', {}); }); - it('Shouldn\'t change the state if no code review could be found in the specified repository', () => { - // Setup - const codeReviews = { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559258000, - lastViewedFile: 'file1.txt', - remainingFiles: ['file2.txt', 'file3.txt'] - } - } - }; - extensionContext.workspaceState.get.mockReturnValueOnce(codeReviews); + it('Shouldn\'t change the state if no code review could be found in the specified repository', async () => { + // Run + const result = await extensionState.updateCodeReview(repo + '1', id, ['file3.txt'], null); + + // Assert + expect(result).toBe('The Code Review could not be found.'); + expect(extensionContext.workspaceState.update).toHaveBeenCalledTimes(0); + }); + it('Shouldn\'t change the state if no code review could be found with the specified id', async () => { // Run - extensionState.updateCodeReviewFileReviewed('/path/to/repo1', 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', 'file2.txt'); + const result = await extensionState.updateCodeReview(repo, id + '1', ['file3.txt'], null); // Assert + expect(result).toBe('The Code Review could not be found.'); expect(extensionContext.workspaceState.update).toHaveBeenCalledTimes(0); }); - it('Shouldn\'t change the state if no code review could be found with the specified id', () => { + it('Should return an error message when workspaceState.update rejects', async () => { // Setup - const codeReviews = { - '/path/to/repo': { - 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2': { - lastActive: 1587559258000, - lastViewedFile: 'file1.txt', - remainingFiles: ['file2.txt', 'file3.txt'] - } - } - }; - extensionContext.workspaceState.get.mockReturnValueOnce(codeReviews); + extensionContext.workspaceState.update.mockReset(); + extensionContext.workspaceState.update.mockRejectedValueOnce(null); // Run - extensionState.updateCodeReviewFileReviewed('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'file2.txt'); + const result = await extensionState.updateCodeReview(repo, id, ['file3.txt'], 'file2.txt'); - // Assert - expect(extensionContext.workspaceState.update).toHaveBeenCalledTimes(0); + // Asset + expect(result).toBe('Visual Studio Code was unable to save the Git Graph Workspace State Memento.'); }); }); diff --git a/web/main.ts b/web/main.ts index 56a21752..88b3d6fc 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2714,9 +2714,9 @@ class GitGraphView { }); } - private cdvFileViewed(filePath: string, fileElem: HTMLElement, markAsCodeReviewed: boolean) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFiles'); - if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; + private cdvSetLastViewedFile(filePath: string, fileElem: HTMLElement) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileTree === null) return; expandedCommit.lastViewedFile = filePath; let lastViewedElem = document.getElementById('cdvLastFileViewed'); @@ -2726,20 +2726,42 @@ class GitGraphView { lastViewedElem.title = 'Last File Viewed'; lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); + } - if (expandedCommit.codeReview !== null && markAsCodeReviewed) { - let i = expandedCommit.codeReview.remainingFiles.indexOf(filePath); - if (i > -1) { - sendMessage({ command: 'codeReviewFileReviewed', repo: this.currentRepo, id: expandedCommit.codeReview.id, filePath: filePath }); - alterFileTreeFileReviewed(expandedCommit.fileTree, filePath); - updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); - expandedCommit.codeReview.remainingFiles.splice(i, 1); - if (expandedCommit.codeReview.remainingFiles.length === 0) { - expandedCommit.codeReview = null; - this.renderCodeReviewBtn(); - } - } + private cdvChangeFileReviewedState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean, fileWasViewed: boolean) { + const expandedCommit = this.expandedCommit, filePath = file.newFilePath; + const filesElem = document.getElementById('cdvFiles'); + + if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.codeReview === null || filesElem === null) { + return; } + + if (isReviewed) { + expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); + } else { + expandedCommit.codeReview.remainingFiles.push(filePath); + } + + if (fileWasViewed) { + this.cdvSetLastViewedFile(filePath, fileElem); + } + + sendMessage({ + command: 'updateCodeReview', + repo: this.currentRepo, + id: expandedCommit.codeReview.id, + remainingFiles: expandedCommit.codeReview.remainingFiles, + lastViewedFile: expandedCommit.lastViewedFile + }); + + alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); + updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); + + if (expandedCommit.codeReview.remainingFiles.length === 0) { + expandedCommit.codeReview = null; + this.renderCodeReviewBtn(); + } + this.saveState(); } @@ -2822,7 +2844,7 @@ class GitGraphView { toHash = expandedCommit.commitHash; } - this.cdvFileViewed(file.newFilePath, fileElem, true); + this.cdvChangeFileReviewedState(file, fileElem, true, true); sendMessage({ command: 'viewDiff', repo: this.currentRepo, @@ -2842,7 +2864,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvFileViewed(file.newFilePath, fileElem, true); + this.cdvChangeFileReviewedState(file, fileElem, true, true); sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2850,7 +2872,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvFileViewed(file.newFilePath, fileElem, false); + this.cdvChangeFileReviewedState(file, fileElem, false, true); sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2858,7 +2880,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvFileViewed(file.newFilePath, fileElem, true); + this.cdvChangeFileReviewedState(file, fileElem, true, true); sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2936,6 +2958,7 @@ class GitGraphView { }; const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); const fileExistsAtThisRevisionAndDiffPossible = file.type !== GG.GitFileStatus.Deleted && diffPossible && !isUncommitted; + const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); contextMenu.show([ [ @@ -2960,6 +2983,18 @@ class GitGraphView { onClick: () => triggerOpenFile(file, fileElem) } ], + [ + { + title: 'Mark as Reviewed', + visible: codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvChangeFileReviewedState(file, fileElem, true, false) + }, + { + title: 'Mark as Not Reviewed', + visible: expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvChangeFileReviewedState(file, fileElem, false, false) + } + ], [ { title: 'Copy Absolute File Path to Clipboard', @@ -3258,6 +3293,11 @@ window.addEventListener('load', () => { dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); } break; + case 'updateCodeReview': + if (msg.error !== null) { + dialog.showError('Unable to update Code Review', msg.error, null, null); + } + break; case 'viewDiff': finishOrDisplayError(msg.error, 'Unable to View Diff'); break; @@ -3427,7 +3467,7 @@ function alterFileTreeFolderOpen(folder: FileTreeFolder, folderPath: string, ope } } -function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string) { +function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string, reviewed: boolean) { let path = filePath.split('/'), i, cur = folder, folders = [folder]; for (i = 0; i < path.length; i++) { if (typeof cur.contents[path[i]] !== 'undefined') { @@ -3435,22 +3475,24 @@ function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string) { cur = cur.contents[path[i]]; folders.push(cur); } else { - (cur.contents[path[i]]).reviewed = true; + (cur.contents[path[i]]).reviewed = reviewed; } } else { break; } } + + // Recalculate whether each of the folders leading to the file are now reviewed (deepest first). for (i = folders.length - 1; i >= 0; i--) { - let keys = Object.keys(folders[i].contents), reviewed = true; + let keys = Object.keys(folders[i].contents), entireFolderReviewed = true; for (let j = 0; j < keys.length; j++) { let cur = folders[i].contents[keys[j]]; if ((cur.type === 'folder' || cur.type === 'file') && !cur.reviewed) { - reviewed = false; + entireFolderReviewed = false; break; } } - folders[i].reviewed = reviewed; + folders[i].reviewed = entireFolderReviewed; } } From 245863645afcdbf5fd6f0fb851dca4dbfdba2045 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Sun, 14 Mar 2021 19:09:19 +1100 Subject: [PATCH 08/15] #479 New Repository Dropdown Order option "Workspace Full Path", that sorts repositories according to the workspace folder order, then alphabetically by the full path of the repository. This is the new default order. --- package.json | 8 +- src/commands.ts | 6 +- src/config.ts | 9 +- src/extensionState.ts | 3 +- src/repoManager.ts | 79 ++++++++-- src/types.ts | 4 +- src/utils.ts | 32 +++- tests/commands.test.ts | 81 +++++----- tests/config.test.ts | 28 +++- tests/extensionState.test.ts | 28 ++-- tests/mocks/vscode.ts | 2 +- tests/repoManager.test.ts | 277 ++++++++++++++++++++++++++--------- tests/utils.test.ts | 84 +++++++++-- web/main.ts | 18 +-- web/utils.ts | 30 +++- 15 files changed, 513 insertions(+), 176 deletions(-) diff --git a/package.json b/package.json index bb356a29..1505422a 100644 --- a/package.json +++ b/package.json @@ -1068,13 +1068,15 @@ "type": "string", "enum": [ "Full Path", - "Name" + "Name", + "Workspace Full Path" ], "enumDescriptions": [ "Sort repositories alphabetically by the full path of the repository.", - "Sort repositories alphabetically by the name of the repository." + "Sort repositories alphabetically by the name of the repository.", + "Sort repositories according to the workspace folder order, then alphabetically by the full path of the repository." ], - "default": "Full Path", + "default": "Workspace Full Path", "description": "Specifies the order that repositories are sorted in the repository dropdown on the Git Graph View (only visible when more than one repository exists in the current Visual Studio Code Workspace)." }, "git-graph.retainContextWhenHidden": { diff --git a/src/commands.ts b/src/commands.ts index fccc0e2c..25b01518 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,7 +8,7 @@ import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; import { GitGraphView } from './gitGraphView'; import { Logger } from './logger'; import { RepoManager } from './repoManager'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; import { Disposable } from './utils/disposable'; import { Event } from './utils/event'; @@ -157,7 +157,7 @@ export class CommandManager extends Disposable { } const repos = this.repoManager.getRepos(); - const items: vscode.QuickPickItem[] = Object.keys(repos).map((path) => ({ + const items: vscode.QuickPickItem[] = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder).map((path) => ({ label: repos[path].name || getRepoName(path), description: path })); @@ -188,7 +188,7 @@ export class CommandManager extends Disposable { */ private fetch() { const repos = this.repoManager.getRepos(); - const repoPaths = Object.keys(repos); + const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); if (repoPaths.length > 1) { const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ diff --git a/src/config.ts b/src/config.ts index 4e957745..ed482331 100644 --- a/src/config.ts +++ b/src/config.ts @@ -523,9 +523,12 @@ class Config { * Get the value of the `git-graph.repositoryDropdownOrder` Extension Setting. */ get repoDropdownOrder(): RepoDropdownOrder { - return this.config.get('repositoryDropdownOrder', 'Full Path') === 'Name' - ? RepoDropdownOrder.Name - : RepoDropdownOrder.FullPath; + const order = this.config.get('repositoryDropdownOrder', 'Workspace Full Path'); + return order === 'Full Path' + ? RepoDropdownOrder.FullPath + : order === 'Name' + ? RepoDropdownOrder.Name + : RepoDropdownOrder.WorkspaceFullPath; } /** diff --git a/src/extensionState.ts b/src/extensionState.ts index 56dbc7d0..1b39346d 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -35,7 +35,8 @@ export const DEFAULT_REPO_STATE: GitRepoState = { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null }; const DEFAULT_GIT_GRAPH_VIEW_GLOBAL_STATE: GitGraphViewGlobalState = { diff --git a/src/repoManager.ts b/src/repoManager.ts index 1198db24..29bae56e 100644 --- a/src/repoManager.ts +++ b/src/repoManager.ts @@ -66,7 +66,10 @@ export class RepoManager extends Disposable { this.startupTasks(); this.registerDisposables( - // Monitor changes to the workspace folders to: search added folders for repositories, remove repositories within deleted folders + // Monitor changes to the workspace folders to: + // - search added folders for repositories + // - remove repositories within deleted folders + // - apply changes to the order of workspace folders vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { let changes = false, path; if (e.added.length > 0) { @@ -83,7 +86,11 @@ export class RepoManager extends Disposable { this.stopWatchingFolder(path); } } - if (changes) this.sendRepos(); + changes = this.updateReposWorkspaceFolderIndex() || changes; + + if (changes) { + this.sendRepos(); + } }), // Monitor changes to the maxDepthOfRepoSearch Extension Setting, and trigger a new search if needed @@ -143,6 +150,7 @@ export class RepoManager extends Disposable { */ private async startupTasks() { this.removeReposNotInWorkspace(); + this.updateReposWorkspaceFolderIndex(); if (!await this.checkReposExist()) this.sendRepos(); this.checkReposForNewConfig(); await this.checkReposForNewSubmodules(); @@ -154,16 +162,10 @@ export class RepoManager extends Disposable { * Remove any repositories that are no longer in the current workspace. */ private removeReposNotInWorkspace() { - let rootsExact = [], rootsFolder = [], workspaceFolders = vscode.workspace.workspaceFolders, repoPaths = Object.keys(this.repos), path; - if (typeof workspaceFolders !== 'undefined') { - for (let i = 0; i < workspaceFolders.length; i++) { - path = getPathFromUri(workspaceFolders[i].uri); - rootsExact.push(path); - rootsFolder.push(pathWithTrailingSlash(path)); - } - } + const workspaceFolderInfo = getWorkspaceFolderInfoForRepoInclusionMapping(); + const rootsExact = workspaceFolderInfo.rootsExact, rootsFolder = workspaceFolderInfo.rootsFolder, repoPaths = Object.keys(this.repos); for (let i = 0; i < repoPaths.length; i++) { - let repoPathFolder = pathWithTrailingSlash(repoPaths[i]); + const repoPathFolder = pathWithTrailingSlash(repoPaths[i]); if (rootsExact.indexOf(repoPaths[i]) === -1 && !rootsFolder.find(root => repoPaths[i].startsWith(root)) && !rootsExact.find(root => root.startsWith(repoPathFolder))) { this.removeRepo(repoPaths[i]); } @@ -219,11 +221,7 @@ export class RepoManager extends Disposable { * @returns The set of repositories. */ public getRepos() { - let repoPaths = Object.keys(this.repos).sort((a, b) => a.localeCompare(b)), repos: GitRepoSet = {}; - for (let i = 0; i < repoPaths.length; i++) { - repos[repoPaths[i]] = this.repos[repoPaths[i]]; - } - return repos; + return Object.assign({}, this.repos); } /** @@ -303,6 +301,7 @@ export class RepoManager extends Disposable { return false; } else { this.repos[repo] = Object.assign({}, DEFAULT_REPO_STATE); + this.updateReposWorkspaceFolderIndex(repo); this.extensionState.saveRepos(this.repos); this.logger.log('Added new repo: ' + repo); await this.checkRepoForNewConfig(repo, true); @@ -382,6 +381,36 @@ export class RepoManager extends Disposable { }); } + /** + * Update each repositories workspaceFolderIndex based on the current workspace. + * @param repo If provided, only update this specific repository. + * @returns TRUE => At least one repository was changed, FALSE => No repositories were changed. + */ + private updateReposWorkspaceFolderIndex(repo: string | null = null) { + const workspaceFolderInfo = getWorkspaceFolderInfoForRepoInclusionMapping(); + const rootsExact = workspaceFolderInfo.rootsExact, rootsFolder = workspaceFolderInfo.rootsFolder, workspaceFolders = workspaceFolderInfo.workspaceFolders; + const repoPaths = repo !== null && this.isKnownRepo(repo) ? [repo] : Object.keys(this.repos); + let changes = false, rootIndex: number, workspaceFolderIndex: number | null; + for (let i = 0; i < repoPaths.length; i++) { + rootIndex = rootsExact.indexOf(repoPaths[i]); + if (rootIndex === -1) { + // Find a workspace folder that contains the repository + rootIndex = rootsFolder.findIndex((root) => repoPaths[i].startsWith(root)); + } + if (rootIndex === -1) { + // Find a workspace folder that is contained within the repository + const repoPathFolder = pathWithTrailingSlash(repoPaths[i]); + rootIndex = rootsExact.findIndex((root) => root.startsWith(repoPathFolder)); + } + workspaceFolderIndex = rootIndex > -1 ? workspaceFolders[rootIndex].index : null; + if (this.repos[repoPaths[i]].workspaceFolderIndex !== workspaceFolderIndex) { + this.repos[repoPaths[i]].workspaceFolderIndex = workspaceFolderIndex; + changes = true; + } + } + return changes; + } + /** * Set the state of a known repository. * @param repo The repository the state belongs to. @@ -659,6 +688,24 @@ export class RepoManager extends Disposable { } } +/** + * Gets the current workspace folders, and generates information required to identify whether a repository is within any of the workspace folders. + * @returns The Workspace Folder Information. + */ +function getWorkspaceFolderInfoForRepoInclusionMapping() { + let rootsExact = [], rootsFolder = [], workspaceFolders = vscode.workspace.workspaceFolders || [], path; + for (let i = 0; i < workspaceFolders.length; i++) { + path = getPathFromUri(workspaceFolders[i].uri); + rootsExact.push(path); + rootsFolder.push(pathWithTrailingSlash(path)); + } + return { + workspaceFolders: workspaceFolders, + rootsExact: rootsExact, + rootsFolder: rootsFolder + }; +} + /** * Check if the specified path is a directory. * @param path The path to check. diff --git a/src/types.ts b/src/types.ts index 81233fcf..2b8a0032 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,6 +207,7 @@ export interface GitRepoState { showRemoteBranchesV2: BooleanOverride; showStashes: BooleanOverride; showTags: BooleanOverride; + workspaceFolderIndex: number | null; } @@ -515,7 +516,8 @@ export const enum RepoCommitOrdering { export const enum RepoDropdownOrder { FullPath, - Name + Name, + WorkspaceFullPath } export const enum SquashMessageFormat { diff --git a/src/utils.ts b/src/utils.ts index 7bb59f31..a364617d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,7 @@ import { getConfig } from './config'; import { DataSource } from './dataSource'; import { DiffSide, encodeDiffDocUri } from './diffDocProvider'; import { ExtensionState } from './extensionState'; -import { ErrorInfo, GitFileStatus, PullRequestConfig, PullRequestProvider } from './types'; +import { ErrorInfo, GitFileStatus, GitRepoSet, PullRequestConfig, PullRequestProvider, RepoDropdownOrder } from './types'; export const UNCOMMITTED = '*'; export const UNABLE_TO_FIND_GIT_MSG = 'Unable to find a Git executable. Either: Set the Visual Studio Code Setting "git.path" to the path and filename of an existing Git executable, or install Git and restart Visual Studio Code.'; @@ -210,15 +210,41 @@ export function getNonce() { * @returns The short name. */ export function getRepoName(path: string) { - let firstSep = path.indexOf('/'); + const firstSep = path.indexOf('/'); if (firstSep === path.length - 1 || firstSep === -1) { return path; // Path has no slashes, or a single trailing slash ==> use the path } else { - let p = path.endsWith('/') ? path.substring(0, path.length - 1) : path; // Remove trailing slash if it exists + const p = path.endsWith('/') ? path.substring(0, path.length - 1) : path; // Remove trailing slash if it exists return p.substring(p.lastIndexOf('/') + 1); } } +/** + * Get a sorted list of repository paths from a given GitRepoSet. + * @param repos The set of repositories. + * @param order The order to sort the repositories. + * @returns An array of ordered repository paths. + */ +export function getSortedRepositoryPaths(repos: GitRepoSet, order: RepoDropdownOrder): ReadonlyArray { + const repoPaths = Object.keys(repos); + if (order === RepoDropdownOrder.WorkspaceFullPath) { + return repoPaths.sort((a, b) => repos[a].workspaceFolderIndex === repos[b].workspaceFolderIndex + ? a.localeCompare(b) + : repos[a].workspaceFolderIndex === null + ? 1 + : repos[b].workspaceFolderIndex === null + ? -1 + : repos[a].workspaceFolderIndex! - repos[b].workspaceFolderIndex! + ); + } else if (order === RepoDropdownOrder.FullPath) { + return repoPaths.sort((a, b) => a.localeCompare(b)); + } else { + return repoPaths.map((path) => ({ name: repos[path].name || getRepoName(path), path: path })) + .sort((a, b) => a.name !== b.name ? a.name.localeCompare(b.name) : a.path.localeCompare(b.path)) + .map((x) => x.path); + } +} + /* Visual Studio Code Command Wrappers */ diff --git a/tests/commands.test.ts b/tests/commands.test.ts index f3304341..6873ba03 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -20,7 +20,7 @@ import { DEFAULT_REPO_STATE, ExtensionState } from '../src/extensionState'; import { GitGraphView } from '../src/gitGraphView'; import { Logger } from '../src/logger'; import { RepoManager } from '../src/repoManager'; -import { GitFileStatus } from '../src/types'; +import { GitFileStatus, RepoDropdownOrder } from '../src/types'; import * as utils from '../src/utils'; import { EventEmitter } from '../src/utils/event'; @@ -345,22 +345,25 @@ describe('CommandManager', () => { it('Should ignore the selected repository', async () => { // Setup - spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } - }); + const repos = { + '/path/to/repo2': mockRepoState('Custom Name', 1), + '/path/to/repo1': mockRepoState(null, 0) + }; + spyOnGetRepos.mockReturnValueOnce(repos); vscode.window.showQuickPick.mockResolvedValueOnce({ label: 'repo1', description: '/path/to/repo1' }); spyOnIgnoreRepo.mockReturnValueOnce(true); vscode.window.showInformationMessage.mockResolvedValueOnce(null); + const spyOnGetSortedRepositoryPaths = jest.spyOn(utils, 'getSortedRepositoryPaths'); // Run vscode.commands.executeCommand('git-graph.removeGitRepository'); // Assert await waitForExpect(() => { + expect(spyOnGetSortedRepositoryPaths).toHaveBeenCalledWith(repos, RepoDropdownOrder.WorkspaceFullPath); expect(vscode.window.showQuickPick).toHaveBeenCalledWith( [ { @@ -384,8 +387,8 @@ describe('CommandManager', () => { it('Should display an error message if the selected repository no longer exists', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); vscode.window.showQuickPick.mockResolvedValueOnce({ label: 'repo1', @@ -422,8 +425,8 @@ describe('CommandManager', () => { it('Shouldn\'t attempt to ignore a repository if none was selected', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); vscode.window.showQuickPick.mockResolvedValueOnce(null); @@ -455,8 +458,8 @@ describe('CommandManager', () => { it('Should handle if showQuickPick rejects', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); vscode.window.showQuickPick.mockRejectedValueOnce(null); @@ -520,21 +523,25 @@ describe('CommandManager', () => { it('Should display a quick pick to select a repository to open in the Git Graph View (with last active repository first)', async () => { // Setup - spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } - }); + const repos = { + '/path/to/repo3': mockRepoState(null, 2), + '/path/to/repo2': mockRepoState('Custom Name', 1), + '/path/to/repo1': mockRepoState(null, 0) + }; + spyOnGetRepos.mockReturnValueOnce(repos); spyOnGetLastActiveRepo.mockReturnValueOnce('/path/to/repo2'); vscode.window.showQuickPick.mockResolvedValueOnce({ label: 'repo1', description: '/path/to/repo1' }); + const spyOnGetSortedRepositoryPaths = jest.spyOn(utils, 'getSortedRepositoryPaths'); // Run vscode.commands.executeCommand('git-graph.fetch'); // Assert await waitForExpect(() => { + expect(spyOnGetSortedRepositoryPaths).toHaveBeenCalledWith(repos, RepoDropdownOrder.WorkspaceFullPath); expect(vscode.window.showQuickPick).toHaveBeenCalledWith( [ { @@ -544,6 +551,10 @@ describe('CommandManager', () => { { label: 'repo1', description: '/path/to/repo1' + }, + { + label: 'repo3', + description: '/path/to/repo3' } ], { @@ -558,8 +569,8 @@ describe('CommandManager', () => { it('Should display a quick pick to select a repository to open in the Git Graph View (no last active repository)', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); spyOnGetLastActiveRepo.mockReturnValueOnce(null); vscode.window.showQuickPick.mockResolvedValueOnce({ @@ -595,8 +606,8 @@ describe('CommandManager', () => { it('Should display a quick pick to select a repository to open in the Git Graph View (last active repository is unknown)', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); spyOnGetLastActiveRepo.mockReturnValueOnce('/path/to/repo3'); vscode.window.showQuickPick.mockResolvedValueOnce({ @@ -632,8 +643,8 @@ describe('CommandManager', () => { it('Shouldn\'t open the Git Graph View when no item is selected in the quick pick', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); spyOnGetLastActiveRepo.mockReturnValueOnce('/path/to/repo3'); vscode.window.showQuickPick.mockResolvedValueOnce(null); @@ -666,8 +677,8 @@ describe('CommandManager', () => { it('Should display an error message when showQuickPick rejects', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': { name: null }, - '/path/to/repo2': { name: 'Custom Name' } + '/path/to/repo1': mockRepoState(null, 0), + '/path/to/repo2': mockRepoState('Custom Name', 0) }); spyOnGetLastActiveRepo.mockReturnValueOnce('/path/to/repo2'); vscode.window.showQuickPick.mockRejectedValueOnce(null); @@ -702,7 +713,7 @@ describe('CommandManager', () => { it('Should open the Git Graph View immediately when there is only one repository', async () => { // Setup spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo1': DEFAULT_REPO_STATE + '/path/to/repo1': mockRepoState(null, 0) }); // Run @@ -756,7 +767,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockImplementationOnce((items: Promise, _: any) => items.then((items) => items[0])); @@ -812,7 +823,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockResolvedValueOnce(null); @@ -839,7 +850,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockImplementationOnce((items: Promise, _: any) => items.then((items) => items[0])); @@ -868,7 +879,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockImplementationOnce((items: Promise, _: any) => items.then((items) => items[0])); @@ -896,7 +907,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockRejectedValueOnce(null); @@ -937,7 +948,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockImplementationOnce((_: string, hash: string) => hash === '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b' ? 'subject-' + hash : null); spyOnGetCommitSubject.mockImplementationOnce((_: string, hash: string) => hash === '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b' ? 'subject-' + hash : null); @@ -993,7 +1004,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockImplementationOnce((_: string, hash: string) => 'subject-' + hash); spyOnGetCommitSubject.mockImplementationOnce((_: string, hash: string) => 'subject-' + hash); @@ -1039,7 +1050,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockResolvedValueOnce(null); @@ -1079,7 +1090,7 @@ describe('CommandManager', () => { } }); spyOnGetRepos.mockReturnValueOnce({ - '/path/to/repo': DEFAULT_REPO_STATE + '/path/to/repo': mockRepoState(null, 0) }); spyOnGetCommitSubject.mockResolvedValueOnce('Commit Subject'); vscode.window.showQuickPick.mockRejectedValueOnce(null); @@ -1262,3 +1273,7 @@ describe('CommandManager', () => { }); }); }); + +function mockRepoState(name: string | null, workspaceFolderIndex: number | null) { + return Object.assign({}, DEFAULT_REPO_STATE, { name: name, workspaceFolderIndex: workspaceFolderIndex }); +} diff --git a/tests/config.test.ts b/tests/config.test.ts index 92de8e20..0586cc5e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2743,7 +2743,7 @@ describe('Config', () => { const value = config.repoDropdownOrder; // Assert - expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Full Path'); + expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Workspace Full Path'); expect(value).toBe(RepoDropdownOrder.Name); }); @@ -2755,11 +2755,23 @@ describe('Config', () => { const value = config.repoDropdownOrder; // Assert - expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Full Path'); + expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Workspace Full Path'); expect(value).toBe(RepoDropdownOrder.FullPath); }); - it('Should return the default value (RepoDropdownOrder.FullPath) when the configuration value is invalid', () => { + it('Should return RepoDropdownOrder.WorkspaceFullPath when the configuration value is "Workspace Full Path"', () => { + // Setup + vscode.mockExtensionSettingReturnValue('repositoryDropdownOrder', 'Workspace Full Path'); + + // Run + const value = config.repoDropdownOrder; + + // Assert + expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Workspace Full Path'); + expect(value).toBe(RepoDropdownOrder.WorkspaceFullPath); + }); + + it('Should return the default value (RepoDropdownOrder.WorkspaceFullPath) when the configuration value is invalid', () => { // Setup vscode.mockExtensionSettingReturnValue('repositoryDropdownOrder', 'invalid'); @@ -2767,17 +2779,17 @@ describe('Config', () => { const value = config.repoDropdownOrder; // Assert - expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Full Path'); - expect(value).toBe(RepoDropdownOrder.FullPath); + expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Workspace Full Path'); + expect(value).toBe(RepoDropdownOrder.WorkspaceFullPath); }); - it('Should return the default value (RepoDropdownOrder.FullPath) when the configuration value is not set', () => { + it('Should return the default value (RepoDropdownOrder.WorkspaceFullPath) when the configuration value is not set', () => { // Run const value = config.repoDropdownOrder; // Assert - expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Full Path'); - expect(value).toBe(RepoDropdownOrder.FullPath); + expect(workspaceConfiguration.get).toBeCalledWith('repositoryDropdownOrder', 'Workspace Full Path'); + expect(value).toBe(RepoDropdownOrder.WorkspaceFullPath); }); }); diff --git a/tests/extensionState.test.ts b/tests/extensionState.test.ts index 77ba7f46..b749f7d5 100644 --- a/tests/extensionState.test.ts +++ b/tests/extensionState.test.ts @@ -5,7 +5,7 @@ jest.mock('fs'); import * as fs from 'fs'; import { ExtensionState } from '../src/extensionState'; -import { BooleanOverride, FileViewType, GitGraphViewGlobalState, GitGraphViewWorkspaceState, RepoCommitOrdering } from '../src/types'; +import { BooleanOverride, FileViewType, GitGraphViewGlobalState, GitGraphViewWorkspaceState, GitRepoState, RepoCommitOrdering } from '../src/types'; import { GitExecutable } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; @@ -56,7 +56,7 @@ describe('ExtensionState', () => { describe('getRepos', () => { it('Should return the stored repositories', () => { // Setup - const repoState = { + const repoState: GitRepoState = { cdvDivider: 0.5, cdvHeight: 250, columnWidths: null, @@ -74,7 +74,8 @@ describe('ExtensionState', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Enabled, showStashes: BooleanOverride.Enabled, - showTags: BooleanOverride.Enabled + showTags: BooleanOverride.Enabled, + workspaceFolderIndex: 0 }; extensionContext.workspaceState.get.mockReturnValueOnce({ '/path/to/repo': repoState @@ -121,7 +122,8 @@ describe('ExtensionState', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); }); @@ -158,7 +160,8 @@ describe('ExtensionState', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); }); @@ -195,7 +198,8 @@ describe('ExtensionState', () => { showRemoteBranches: false, showRemoteBranchesV2: BooleanOverride.Disabled, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); }); @@ -232,7 +236,8 @@ describe('ExtensionState', () => { showRemoteBranches: false, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); }); @@ -269,7 +274,8 @@ describe('ExtensionState', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Enabled, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); }); @@ -309,7 +315,8 @@ describe('ExtensionState', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null }, '/path/to/repo-2': { cdvDivider: 0.5, @@ -329,7 +336,8 @@ describe('ExtensionState', () => { showRemoteBranches: false, showRemoteBranchesV2: BooleanOverride.Disabled, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: null } }); expect(workspaceConfiguration.get).toHaveBeenCalledTimes(1); diff --git a/tests/mocks/vscode.ts b/tests/mocks/vscode.ts index bbe30aee..7801a02c 100644 --- a/tests/mocks/vscode.ts +++ b/tests/mocks/vscode.ts @@ -157,7 +157,7 @@ export const workspace = { getConfiguration: jest.fn(() => mocks.workspaceConfiguration), onDidChangeWorkspaceFolders: jest.fn((_: () => Promise) => ({ dispose: jest.fn() })), onDidCloseTextDocument: jest.fn((_: () => void) => ({ dispose: jest.fn() })), - workspaceFolders: <{ uri: Uri }[] | undefined>undefined + workspaceFolders: <{ uri: Uri, index: number }[] | undefined>undefined }; diff --git a/tests/repoManager.test.ts b/tests/repoManager.test.ts index 32f8a2cd..0112278c 100644 --- a/tests/repoManager.test.ts +++ b/tests/repoManager.test.ts @@ -86,16 +86,53 @@ describe('RepoManager', () => { mockRepositoryWithNoSubmodules(); // Run + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }]; await emitOnDidChangeWorkspaceFolders!({ added: [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }], removed: [] }); // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) + }, + numRepos: 1, + loadRepo: null + } + ]); + expect(spyOnLog).toHaveBeenCalledWith('Added new repo: /path/to/workspace-folder1'); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + + // Teardown + repoManager.dispose(); + }); + + it('Should add repositories contained within an added workspace folder (unable to find workspaceFolderIndex)', async () => { + // Setup + let emitOnDidChangeWorkspaceFolders: (event: { added: { uri: vscode.Uri }[], removed: { uri: vscode.Uri }[] }) => Promise; + vscode.workspace.onDidChangeWorkspaceFolders.mockImplementationOnce((listener) => { + emitOnDidChangeWorkspaceFolders = listener as () => Promise; + return { dispose: jest.fn() }; + }); + const repoManager = await constructRepoManagerAndWaitUntilStarted([], []); + + const onDidChangeReposEvents: RepoChangeEvent[] = []; + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + mockRepositoryWithNoSubmodules(); + + // Run + await emitOnDidChangeWorkspaceFolders!({ added: [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }], removed: [] }); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1': mockRepoState(null) + }); + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: { + '/path/to/workspace-folder1': mockRepoState(null) }, numRepos: 1, loadRepo: null @@ -155,12 +192,12 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -194,7 +231,7 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([]); @@ -202,6 +239,61 @@ describe('RepoManager', () => { repoManager.dispose(); }); + it('Should update all repositories workspaceFolderIndex\'s when workspace folders have been reordered', async () => { + // Setup + mockRepositoryWithNoSubmodules(); + mockRepositoryWithNoSubmodules(); + mockRepositoryWithNoSubmodules(); + let emitOnDidChangeWorkspaceFolders: (event: { added: { uri: vscode.Uri }[], removed: { uri: vscode.Uri }[] }) => Promise; + vscode.workspace.onDidChangeWorkspaceFolders.mockImplementationOnce((listener) => { + emitOnDidChangeWorkspaceFolders = listener as () => Promise; + return { dispose: jest.fn() }; + }); + const repoManager = await constructRepoManagerAndWaitUntilStarted( + ['/path/to/workspace-folder1', '/path/to/workspace-folder2', '/path/to/workspace-folder3'], + ['/path/to/workspace-folder1', '/path/to/workspace-folder2', '/path/to/workspace-folder3'] + ); + + const onDidChangeReposEvents: RepoChangeEvent[] = []; + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1': mockRepoState(0), + '/path/to/workspace-folder2': mockRepoState(1), + '/path/to/workspace-folder3': mockRepoState(2) + }); + + // Run + vscode.workspace.workspaceFolders = [ + { uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }, + { uri: vscode.Uri.file('/path/to/workspace-folder3'), index: 1 }, + { uri: vscode.Uri.file('/path/to/workspace-folder2'), index: 2 } + ]; + await emitOnDidChangeWorkspaceFolders!({ added: [], removed: [] }); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1': mockRepoState(0), + '/path/to/workspace-folder2': mockRepoState(2), + '/path/to/workspace-folder3': mockRepoState(1) + }); + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: { + '/path/to/workspace-folder1': mockRepoState(0), + '/path/to/workspace-folder2': mockRepoState(2), + '/path/to/workspace-folder3': mockRepoState(1) + }, + numRepos: 3, + loadRepo: null + } + ]); + + // Teardown + repoManager.dispose(); + }); + it('Should not emit repo events is no changes occurred', async () => { // Setup mockRepositoryWithNoSubmodules(); @@ -224,8 +316,8 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0), + '/path/to/workspace-folder2': mockRepoState(1) }); expect(onDidChangeReposEvents).toStrictEqual([]); @@ -278,21 +370,27 @@ describe('RepoManager', () => { describe('startupTasks', () => { it('Should run startup tasks', async () => { // Setup - mockRepositoryWithNoSubmodules(); - mockDirectoryThatsNotRepository(); - mockDirectoryThatsNotRepository(); + mockRepositoryWithNoSubmodules(); // Exists: /path/to/workspace-folder1/repo1 + mockDirectoryThatsNotRepository(); // Removed: Re/path/to/workspace-folder1/repo2 + mockRepositoryWithNoSubmodules(); // New: /path/to/workspace-folder3 + mockRepositoryWithNoSubmodules(); // New: /path/to/another + mockDirectoryThatsNotRepository(); // Not Repo: /path/to/workspace-folder1 // Run const repoManager = await constructRepoManagerAndWaitUntilStarted( - ['/path/to/workspace-folder1'], - ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2', '/path/to/workspace-folder2'] + ['/path/to/workspace-folder1', '/path/to/another/workspace-folder', '/path/to/workspace-folder3'], + ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2', '/path/to/workspace-folder3', '/path/to/workspace-folder4', '/path/to/another'] ); // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder3': mockRepoState(2), + '/path/to/another': mockRepoState(1) }); expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/another/workspace-folder/**'); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder3/**'); // Run repoManager.dispose(); @@ -312,10 +410,10 @@ describe('RepoManager', () => { // Assert expect(spyOnSaveRepos).toHaveBeenCalledWith({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(spyOnLog).toHaveBeenCalledWith('Removed repo: /path/to/workspace-folder2'); @@ -351,7 +449,7 @@ describe('RepoManager', () => { expect(spyOnRepoRoot).toHaveBeenCalledWith('/path/to/workspace-folder1/repo'); expect(spyOnRepoRoot).toHaveBeenCalledWith('/path/to/workspace-folder1'); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); }); @@ -370,7 +468,7 @@ describe('RepoManager', () => { expect(spyOnRepoRoot).toHaveBeenCalledTimes(1); expect(spyOnRepoRoot).toHaveBeenCalledWith('/path/to'); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to': DEFAULT_REPO_STATE + '/path/to': mockRepoState(0) }); // Teardown @@ -382,33 +480,34 @@ describe('RepoManager', () => { it('Should register a new repository', async () => { // Setup mockDirectoryThatsNotRepository(); - const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1'], []); + mockDirectoryThatsNotRepository(); + const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1', '/path/to/workspace-folder2'], []); mockRepositoryWithNoSubmodules(); const onDidChangeReposEvents: RepoChangeEvent[] = []; repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); // Run - const result = await repoManager.registerRepo('/path/to/workspace-folder1/repo', false); + const result = await repoManager.registerRepo('/path/to/workspace-folder2/repo', false); // Assert - expect(spyOnRepoRoot).toHaveBeenCalledWith('/path/to/workspace-folder1/repo'); + expect(spyOnRepoRoot).toHaveBeenCalledWith('/path/to/workspace-folder2/repo'); expect(result).toStrictEqual({ - root: '/path/to/workspace-folder1/repo', + root: '/path/to/workspace-folder2/repo', error: null }); - expect(spyOnLog).toHaveBeenCalledWith('Added new repo: /path/to/workspace-folder1/repo'); + expect(spyOnLog).toHaveBeenCalledWith('Added new repo: /path/to/workspace-folder2/repo'); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder2/repo': mockRepoState(1) }, numRepos: 1, loadRepo: null } ]); expect(spyOnReadFile).toHaveBeenCalledTimes(1); - expect(utils.getPathFromStr(spyOnReadFile.mock.calls[0][0])).toStrictEqual('/path/to/workspace-folder1/repo/.vscode/vscode-git-graph.json'); + expect(utils.getPathFromStr(spyOnReadFile.mock.calls[0][0])).toStrictEqual('/path/to/workspace-folder2/repo/.vscode/vscode-git-graph.json'); // Teardown repoManager.dispose(); @@ -437,7 +536,7 @@ describe('RepoManager', () => { expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }, numRepos: 1, loadRepo: '/path/to/workspace-folder1/repo' @@ -472,7 +571,7 @@ describe('RepoManager', () => { expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }, numRepos: 1, loadRepo: '/path/to/workspace-folder1/repo' @@ -483,6 +582,36 @@ describe('RepoManager', () => { repoManager.dispose(); }); + it('Should register a new repository (should only update the workspaceFolderIndex for the repository that was added)', async () => { + // Setup + mockRepositoryWithNoSubmodules(); + mockDirectoryThatsNotRepository(); + const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1', '/path/to/workspace-folder2'], ['/path/to/workspace-folder1']); + repoManager['repos']['/path/to/workspace-folder1'].workspaceFolderIndex = null; + + mockRepositoryWithNoSubmodules(); + const onDidChangeReposEvents: RepoChangeEvent[] = []; + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + + // Run + await repoManager.registerRepo('/path/to/workspace-folder2/repo', false); + + // Assert + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: { + '/path/to/workspace-folder1': mockRepoState(null), + '/path/to/workspace-folder2/repo': mockRepoState(1) + }, + numRepos: 2, + loadRepo: null + } + ]); + + // Teardown + repoManager.dispose(); + }); + it('Should return an error message when the path being registered is not a Git repository', async () => { // Setup mockDirectoryThatsNotRepository(); @@ -598,7 +727,7 @@ describe('RepoManager', () => { }); describe('getRepos', () => { - it('Should get the sorted set of repositories', async () => { + it('Should get the set of repositories', async () => { // Setup mockRepositoryWithNoSubmodules(); mockRepositoryWithNoSubmodules(); @@ -613,8 +742,8 @@ describe('RepoManager', () => { // Assert expect(result).toStrictEqual({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder1/repo2': mockRepoState(0) }); // Teardown @@ -817,7 +946,7 @@ describe('RepoManager', () => { expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -849,16 +978,16 @@ describe('RepoManager', () => { // Assert expect(result).toBe(true); expect(spyOnSaveRepos).toHaveBeenCalledWith({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo2-new': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder1/repo2-new': mockRepoState(0) }); expect(spyOnTransferRepo).toHaveBeenCalledWith('/path/to/workspace-folder1/repo2', '/path/to/workspace-folder1/repo2-new'); expect(spyOnLog).toHaveBeenCalledWith('Transferred repo state: /path/to/workspace-folder1/repo2 -> /path/to/workspace-folder1/repo2-new'); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo2-new': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder1/repo2-new': mockRepoState(0) }, numRepos: 2, loadRepo: null @@ -926,7 +1055,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 }; // Run @@ -934,11 +1064,11 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE, + '/path/to/workspace-folder1/repo1': mockRepoState(0), '/path/to/workspace-folder1/repo2': newRepoState }); expect(spyOnSaveRepos).toHaveBeenCalledWith({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE, + '/path/to/workspace-folder1/repo1': mockRepoState(0), '/path/to/workspace-folder1/repo2': newRepoState }); @@ -963,12 +1093,12 @@ describe('RepoManager', () => { // Assert expect(result).toBe(true); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1001,12 +1131,12 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1128,9 +1258,9 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo/submodule1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo/submodule2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0), + '/path/to/workspace-folder1/repo/submodule1': mockRepoState(0), + '/path/to/workspace-folder1/repo/submodule2': mockRepoState(0) }); // Teardown @@ -1152,9 +1282,9 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo/submodule1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo/submodule2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0), + '/path/to/workspace-folder1/repo/submodule1': mockRepoState(0), + '/path/to/workspace-folder1/repo/submodule2': mockRepoState(0) }); // Teardown @@ -1172,8 +1302,8 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo/submodule1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0), + '/path/to/workspace-folder1/repo/submodule1': mockRepoState(0) }); // Teardown @@ -1207,12 +1337,12 @@ describe('RepoManager', () => { // Assert await waitForExpect(() => expect(spyOnLog).toHaveBeenCalledWith('Added new repo: /path/to/workspace-folder1/repo')); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1249,12 +1379,12 @@ describe('RepoManager', () => { // Assert await waitForExpect(() => expect(spyOnLog).toHaveBeenCalledWith('Added new repo: /path/to/workspace-folder1/repo')); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1473,7 +1603,7 @@ describe('RepoManager', () => { // Assert await waitForExpect(() => expect(repoManager['onWatcherChangeQueue']['processing']).toBe(false)); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([]); expect(repoManager['onWatcherChangeQueue']['queue']).toStrictEqual([]); @@ -1507,7 +1637,7 @@ describe('RepoManager', () => { // Assert await waitForExpect(() => expect(repoManager['onWatcherChangeQueue']['processing']).toBe(false)); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([]); expect(repoManager['onWatcherChangeQueue']['queue']).toStrictEqual([]); @@ -1540,12 +1670,12 @@ describe('RepoManager', () => { expect(spyOnLog).toHaveBeenCalledWith('Removed repo: /path/to/workspace-folder1/dir/repo1'); expect(spyOnLog).toHaveBeenCalledWith('Removed repo: /path/to/workspace-folder1/dir/repo2'); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo3': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo3': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo3': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo3': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1578,12 +1708,12 @@ describe('RepoManager', () => { expect(spyOnLog).toHaveBeenCalledWith('Removed repo: /path/to/workspace-folder1/repo1'); expect(spyOnLog).toHaveBeenCalledWith('Removed repo: /path/to/workspace-folder1/repo1/submodule'); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo2': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { - '/path/to/workspace-folder1/repo2': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo2': mockRepoState(0) }, numRepos: 1, loadRepo: null @@ -1614,8 +1744,8 @@ describe('RepoManager', () => { // Assert expect(spyOnLog).not.toHaveBeenCalledWith('/path/to/workspace-folder1/dir/repo1/.git/folder/repo'); expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/dir/repo1': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/dir/repo1/.git/folder/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/dir/repo1': mockRepoState(0), + '/path/to/workspace-folder1/dir/repo1/.git/folder/repo': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([]); @@ -1638,7 +1768,7 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0) }); expect(onDidChangeReposEvents).toStrictEqual([]); @@ -1683,7 +1813,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 } }; expected['/path/to/workspace-folder1/repo'][stateKey] = stateValue; @@ -1851,9 +1982,9 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); - expect(spyOnIsKnownRepo).not.toHaveBeenCalled(); + expect(spyOnIsKnownRepo).toHaveBeenCalledTimes(1); // Teardown repoManager.dispose(); @@ -1872,9 +2003,9 @@ describe('RepoManager', () => { // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo': mockRepoState(0) }); - expect(spyOnIsKnownRepo).not.toHaveBeenCalled(); + expect(spyOnIsKnownRepo).toHaveBeenCalledTimes(1); // Teardown repoManager.dispose(); @@ -2034,7 +2165,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 } }); @@ -2109,7 +2241,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 } }); @@ -2322,7 +2455,7 @@ async function constructRepoManagerAndWaitUntilStarted(workspaceFolders: string[ vscode.mockExtensionSettingReturnValue('maxDepthOfRepoSearch', 0); vscode.workspace.workspaceFolders = workspaceFolders - ? workspaceFolders.map((path) => ({ uri: vscode.Uri.file(path) })) + ? workspaceFolders.map((path, index) => ({ uri: vscode.Uri.file(path), index: index })) : undefined; const repoManager = new RepoManager(dataSource, extensionState, onDidChangeConfiguration.subscribe, logger); @@ -2331,3 +2464,7 @@ async function constructRepoManagerAndWaitUntilStarted(workspaceFolders: string[ return repoManager; } + +function mockRepoState(workspaceFolderIndex: number | null) { + return Object.assign({}, DEFAULT_REPO_STATE, { workspaceFolderIndex: workspaceFolderIndex }); +} diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 0878df0a..0c1cd2ed 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -21,10 +21,10 @@ import * as cp from 'child_process'; import * as path from 'path'; import { ConfigurationChangeEvent } from 'vscode'; import { DataSource } from '../src/dataSource'; -import { ExtensionState } from '../src/extensionState'; +import { DEFAULT_REPO_STATE, ExtensionState } from '../src/extensionState'; import { Logger } from '../src/logger'; -import { GitFileStatus, PullRequestProvider } from '../src/types'; -import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, doesFileExist, doesVersionMeetRequirement, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; +import { GitFileStatus, PullRequestProvider, RepoDropdownOrder } from '../src/types'; +import { GitExecutable, UNCOMMITTED, abbrevCommit, abbrevText, archive, constructIncompatibleGitVersionMessage, copyFilePathToClipboard, copyToClipboard, createPullRequest, doesFileExist, doesVersionMeetRequirement, evalPromises, findGit, getExtensionVersion, getGitExecutable, getGitExecutableFromPaths, getNonce, getPathFromStr, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openExtensionSettings, openExternalUrl, openFile, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, resolveToSymbolicPath, showErrorMessage, showInformationMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from '../src/utils'; import { EventEmitter } from '../src/utils/event'; const extensionContext = vscode.mocks.extensionContext; @@ -166,7 +166,7 @@ describe('realpath', () => { describe('isPathInWorkspace', () => { it('Should return TRUE if a path is a workspace folder', () => { // Setup - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }, { uri: vscode.Uri.file('/path/to/workspace-folder2') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }, { uri: vscode.Uri.file('/path/to/workspace-folder2'), index: 1 }]; // Run const result = isPathInWorkspace('/path/to/workspace-folder1'); @@ -177,7 +177,7 @@ describe('isPathInWorkspace', () => { it('Should return TRUE if a path is within a workspace folder', () => { // Setup - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }, { uri: vscode.Uri.file('/path/to/workspace-folder2') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }, { uri: vscode.Uri.file('/path/to/workspace-folder2'), index: 1 }]; // Run const result = isPathInWorkspace('/path/to/workspace-folder1/subfolder'); @@ -188,7 +188,7 @@ describe('isPathInWorkspace', () => { it('Should return FALSE if a path is not within a workspace folder', () => { // Setup - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }, { uri: vscode.Uri.file('/path/to/workspace-folder2') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }, { uri: vscode.Uri.file('/path/to/workspace-folder2'), index: 1 }]; // Run const result = isPathInWorkspace('/path/to/workspace-folder3/file'); @@ -213,7 +213,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the original path if it matches a vscode workspace folder', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, path as string)); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/path/to/workspace-folder1'); @@ -225,7 +225,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the symbolic path if a vscode workspace folder resolves to it', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, (path as string).replace('symbolic', 'workspace'))); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder1') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder1'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/path/to/workspace-folder1'); @@ -237,7 +237,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the original path if it is within a vscode workspace folder', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, path as string)); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/workspace-folder1'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/path/to/workspace-folder1/subfolder/file.txt'); @@ -249,7 +249,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the symbolic path if a vscode workspace folder resolves to contain it', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, (path as string).replace('symbolic', 'workspace'))); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder1') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder1'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/path/to/workspace-folder1/subfolder/file.txt'); @@ -261,7 +261,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the symbolic path if the vscode workspace folder resolves to be contained within it', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, (path as string).replace('symbolic', 'workspace'))); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder/dir') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder/dir'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/path/to/workspace-folder'); @@ -276,7 +276,7 @@ describe('resolveToSymbolicPath', () => { path = path as string; callback(null, path === '/symbolic-folder/path/to/dir' ? path.replace('symbolic', 'workspace') : path); }); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/symbolic-folder/path/to/dir') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/symbolic-folder/path/to/dir'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/workspace-folder/path'); @@ -288,7 +288,7 @@ describe('resolveToSymbolicPath', () => { it('Should return the original path if it is unrelated to the vscode workspace folders', async () => { // Setup mockedFileSystemModule.realpath.mockImplementation((path: fs.PathLike, callback: (err: NodeJS.ErrnoException | null, resolvedPath: string) => void) => callback(null, (path as string).replace('symbolic', 'workspace'))); - vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder/dir') }]; + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file('/path/to/symbolic-folder/dir'), index: 0 }]; // Run const result = await resolveToSymbolicPath('/an/unrelated/directory'); @@ -576,6 +576,64 @@ describe('getRepoName', () => { }); }); +describe('getSortedRepositoryPaths', () => { + const mockRepoState = (name: string | null, workspaceFolderIndex: number | null) => Object.assign({}, DEFAULT_REPO_STATE, { name: name, workspaceFolderIndex: workspaceFolderIndex }); + + it('Should sort by RepoDropdownOrder.WorkspaceFullPath', () => { + // Run + const repoPaths = getSortedRepositoryPaths({ + '/path/to/workspace-1/a': mockRepoState(null, 1), + '/path/to/workspace-2/c': mockRepoState(null, 0), + '/path/to/workspace-3/a': mockRepoState(null, null), + '/path/to/workspace-2/b': mockRepoState(null, 0), + '/path/to/workspace-1/b': mockRepoState(null, 1), + '/path/to/workspace-1/d': mockRepoState(null, 1), + '/path/to/workspace-3/b': mockRepoState(null, null), + '/path/to/workspace-3/d': mockRepoState(null, null), + '/path/to/workspace-2/a': mockRepoState(null, 0), + '/path/to/workspace-1/c': mockRepoState(null, 1), + '/path/to/workspace-3/c': mockRepoState(null, null), + '/path/to/workspace-2/d': mockRepoState(null, 0) + }, RepoDropdownOrder.WorkspaceFullPath); + + // Assert + expect(repoPaths).toStrictEqual(['/path/to/workspace-2/a', '/path/to/workspace-2/b', '/path/to/workspace-2/c', '/path/to/workspace-2/d', '/path/to/workspace-1/a', '/path/to/workspace-1/b', '/path/to/workspace-1/c', '/path/to/workspace-1/d', '/path/to/workspace-3/a', '/path/to/workspace-3/b', '/path/to/workspace-3/c', '/path/to/workspace-3/d']); + }); + + it('Should sort by RepoDropdownOrder.FullPath', () => { + // Run + const repoPaths = getSortedRepositoryPaths({ + '/path/to/a': mockRepoState(null, 1), + '/path/to/f': mockRepoState(null, 2), + '/path/to/D': mockRepoState(null, 3), + '/path/to/b': mockRepoState(null, 4), + '/path/to/é': mockRepoState(null, 5), + '/path/to/C': mockRepoState(null, 6), + '/path/a': mockRepoState(null, 1) + }, RepoDropdownOrder.FullPath); + + // Assert + expect(repoPaths).toStrictEqual(['/path/a', '/path/to/a', '/path/to/b', '/path/to/C', '/path/to/D', '/path/to/é', '/path/to/f']); + }); + + it('Should sort by RepoDropdownOrder.Name', () => { + // Run + const repoPaths = getSortedRepositoryPaths({ + '/path/to/a': mockRepoState(null, 1), + '/path/to/x': mockRepoState('f', 2), + '/path/to/y': mockRepoState('D', 3), + '/path/to/b': mockRepoState(null, 4), + '/path/to/z': mockRepoState('é', 5), + '/path/to/C': mockRepoState(null, 6), + '/path/to/another/A': mockRepoState(null, 7), + '/path/a': mockRepoState(null, 1) + }, RepoDropdownOrder.Name); + + // Assert + expect(repoPaths).toStrictEqual(['/path/a', '/path/to/a', '/path/to/another/A', '/path/to/b', '/path/to/C', '/path/to/y', '/path/to/z', '/path/to/x']); + }); +}); + describe('archive', () => { it('Should trigger the creation of the archive (tar)', async () => { // Setup diff --git a/web/main.ts b/web/main.ts index 88b3d6fc..b8f6689f 100644 --- a/web/main.ts +++ b/web/main.ts @@ -174,7 +174,9 @@ class GitGraphView { if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { newRepo = loadViewTo.repo; } else if (typeof repos[this.currentRepo] === 'undefined') { - newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' ? lastActiveRepo : repoPaths[0]; + newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' + ? lastActiveRepo + : getSortedRepositoryPaths(repos, this.config.repoDropdownOrder)[0]; } else { newRepo = this.currentRepo; } @@ -3672,8 +3674,8 @@ function abbrevCommit(commitHash: string) { } function getRepoDropdownOptions(repos: Readonly) { - const repoPaths = Object.keys(repos); - let paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; + const repoPaths = getSortedRepositoryPaths(repos, initialState.config.repoDropdownOrder); + const paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; const resolveAmbiguous = (indexes: number[]) => { // Find ambiguous names within indexes let firstOccurrence: { [name: string]: number } = {}, ambiguous: { [name: string]: number[] } = {}; @@ -3714,11 +3716,11 @@ function getRepoDropdownOptions(repos: Readonly) { }; // Initialise recursion - let indexes = []; + const indexes = []; for (let i = 0; i < repoPaths.length; i++) { firstSep.push(repoPaths[i].indexOf('/')); const repo = repos[repoPaths[i]]; - if (repo.name !== null) { + if (repo.name) { // A name has been set for the repository paths.push(repoPaths[i]); names.push(repo.name); @@ -3738,7 +3740,7 @@ function getRepoDropdownOptions(repos: Readonly) { } resolveAmbiguous(indexes); - let options: DropdownOption[] = []; + const options: DropdownOption[] = []; for (let i = 0; i < repoPaths.length; i++) { let hint; if (names[i] === distinctNames[i]) { @@ -3759,9 +3761,7 @@ function getRepoDropdownOptions(repos: Readonly) { options.push({ name: names[i], value: repoPaths[i], hint: hint }); } - return initialState.config.repoDropdownOrder === GG.RepoDropdownOrder.Name - ? options.sort((a, b) => a.name !== b.name ? a.name.localeCompare(b.name) : a.value.localeCompare(b.value)) - : options; + return options; } function runAction(msg: GG.RequestMessage, action: string) { diff --git a/web/utils.ts b/web/utils.ts index 7c314a3c..a63b64c8 100644 --- a/web/utils.ts +++ b/web/utils.ts @@ -186,15 +186,41 @@ function pad2(i: number) { * @returns The short name. */ function getRepoName(path: string) { - let firstSep = path.indexOf('/'); + const firstSep = path.indexOf('/'); if (firstSep === path.length - 1 || firstSep === -1) { return path; // Path has no slashes, or a single trailing slash ==> use the path } else { - let p = path.endsWith('/') ? path.substring(0, path.length - 1) : path; // Remove trailing slash if it exists + const p = path.endsWith('/') ? path.substring(0, path.length - 1) : path; // Remove trailing slash if it exists return p.substring(p.lastIndexOf('/') + 1); } } +/** + * Get a sorted list of repository paths from a given GitRepoSet. + * @param repos The set of repositories. + * @param order The order to sort the repositories. + * @returns An array of ordered repository paths. + */ +function getSortedRepositoryPaths(repos: GG.GitRepoSet, order: GG.RepoDropdownOrder): ReadonlyArray { + const repoPaths = Object.keys(repos); + if (order === GG.RepoDropdownOrder.WorkspaceFullPath) { + return repoPaths.sort((a, b) => repos[a].workspaceFolderIndex === repos[b].workspaceFolderIndex + ? a.localeCompare(b) + : repos[a].workspaceFolderIndex === null + ? 1 + : repos[b].workspaceFolderIndex === null + ? -1 + : repos[a].workspaceFolderIndex! - repos[b].workspaceFolderIndex! + ); + } else if (order === GG.RepoDropdownOrder.FullPath) { + return repoPaths.sort((a, b) => a.localeCompare(b)); + } else { + return repoPaths.map((path) => ({ name: repos[path].name || getRepoName(path), path: path })) + .sort((a, b) => a.name !== b.name ? a.name.localeCompare(b.name) : a.path.localeCompare(b.path)) + .map((x) => x.path); + } +} + /* HTML Escape / Unescape */ From 4960650579213235d8cff611bcb4e11213fdb3f7 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 17 Mar 2021 19:16:35 +1100 Subject: [PATCH 09/15] ESLint Rule Set Changes: Use warnings instead of errors to indicate code style issues, expansion of linting rules (with the required changes to the codebase). --- .eslintrc.json | 119 ++++++++++++++++++++++++---------- package.json | 4 +- src/avatarManager.ts | 2 +- src/commands.ts | 2 +- src/dataSource.ts | 8 +-- src/gitGraphView.ts | 4 +- src/life-cycle/startup.ts | 2 +- src/life-cycle/uninstall.ts | 2 +- src/life-cycle/utils.ts | 2 +- src/logger.ts | 4 +- src/repoManager.ts | 4 +- src/statusBarItem.ts | 4 +- tests/repoFileWatcher.test.ts | 2 +- web/contextMenu.ts | 2 +- web/dialog.ts | 4 +- web/findWidget.ts | 10 +-- web/global.d.ts | 6 +- web/graph.ts | 2 +- web/main.ts | 28 ++++---- web/textFormatter.ts | 2 +- 20 files changed, 132 insertions(+), 81 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 20b839af..09d82a90 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,85 +2,123 @@ "root": true, "parser": "@typescript-eslint/parser", "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" + "project": [ + "./src/tsconfig.json", + "./tests/tsconfig.json", + "./web/tsconfig.json" + ] }, "plugins": [ "@typescript-eslint" ], "rules": { + "arrow-spacing": [ + "warn", + { + "before": true, + "after": true + } + ], "brace-style": [ - "error", + "warn", "1tbs", { "allowSingleLine": true } ], - "comma-dangle": "error", - "comma-spacing": "error", - "comma-style": "error", + "comma-dangle": "warn", + "comma-spacing": "warn", + "comma-style": "warn", "dot-location": [ - "error", + "warn", "property" ], - "eol-last": "error", - "eqeqeq": "error", - "func-call-spacing": "error", + "eol-last": "warn", + "eqeqeq": "warn", + "func-call-spacing": "warn", "indent": [ - "error", + "warn", "tab", { "SwitchCase": 1 } ], - "key-spacing": "error", + "key-spacing": "warn", "linebreak-style": [ - "error", + "warn", "windows" ], - "new-cap": "error", - "new-parens": "error", - "no-console": "warn", + "new-cap": "warn", + "new-parens": "warn", + "no-alert": "error", + "no-console": "error", "no-eval": "error", + "no-extra-boolean-cast": "warn", + "no-implied-eval": "error", + "no-irregular-whitespace": "warn", "no-labels": "error", - "no-multi-spaces": "error", + "no-multi-spaces": "warn", + "no-proto": "error", + "no-prototype-builtins": "error", "no-redeclare": "error", + "no-global-assign": "error", + "no-return-await": "warn", "no-shadow-restricted-names": "error", - "no-throw-literal": "error", - "no-unused-expressions": "error", - "no-whitespace-before-property": "error", + "no-script-url": "error", + "no-sparse-arrays": "warn", + "no-throw-literal": "warn", + "no-trailing-spaces": "warn", + "no-unneeded-ternary": "warn", + "no-unsafe-negation": "warn", + "no-unused-expressions": "warn", + "no-var": "warn", + "no-whitespace-before-property": "warn", + "no-with": "error", + "padded-blocks": [ + "warn", + { + "classes": "never", + "switches": "never" + } + ], "quotes": [ - "error", + "warn", "single" ], - "rest-spread-spacing": "error", - "semi": "error", + "rest-spread-spacing": "warn", + "semi": "warn", "sort-imports": [ - "error", + "warn", { "allowSeparatedGroups": true, "ignoreDeclarationSort": true } ], "space-before-function-paren": [ - "error", + "warn", { "anonymous": "always", "named": "never", "asyncArrow": "always" } ], - "space-before-blocks": "error", - "space-infix-ops": "error", - "spaced-comment": "error", - "template-curly-spacing": "error", + "space-before-blocks": "warn", + "space-infix-ops": "warn", + "spaced-comment": "warn", + "template-curly-spacing": "warn", "wrap-iife": [ - "error", + "warn", "inside" ], - "yoda": "error", + "yoda": "warn", + "@typescript-eslint/await-thenable": "warn", + "@typescript-eslint/ban-ts-comment": "error", + "@typescript-eslint/class-literal-property-style": [ + "warn", + "fields" + ], "@typescript-eslint/explicit-member-accessibility": [ - "error", + "warn", { "overrides": { "accessors": "off", @@ -88,8 +126,12 @@ } } ], + "@typescript-eslint/method-signature-style": [ + "warn", + "property" + ], "@typescript-eslint/naming-convention": [ - "error", + "warn", { "selector": "class", "format": [ @@ -102,7 +144,10 @@ "camelCase" ] } - ] + ], + "@typescript-eslint/no-misused-new": "warn", + "@typescript-eslint/no-this-alias": "warn", + "@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn" }, "overrides": [ { @@ -111,6 +156,12 @@ "no-console": "off", "spaced-comment": "off" } + }, + { + "files": "./tests/mocks/*.ts", + "rules": { + "no-global-assign": "off" + } } ] } \ No newline at end of file diff --git a/package.json b/package.json index 1505422a..233faed8 100644 --- a/package.json +++ b/package.json @@ -1466,8 +1466,8 @@ "compile-src": "tsc -p ./src && node ./.vscode/package-src.js", "compile-web": "tsc -p ./web && node ./.vscode/package-web.js", "compile-web-debug": "tsc -p ./web && node ./.vscode/package-web.js debug", - "lint": "eslint -c .eslintrc.json --ext .ts ./src ./tests ./web", - "package": "npm run clean && vsce package", + "lint": "eslint -c .eslintrc.json --max-warnings 0 --ext .ts ./src ./tests ./web", + "package": "vsce package", "package-and-install": "npm run package && node ./.vscode/install-package.js", "test": "jest --verbose", "test-and-report-coverage": "jest --verbose --coverage" diff --git a/src/avatarManager.ts b/src/avatarManager.ts index 82d2dd14..0572bfac 100644 --- a/src/avatarManager.ts +++ b/src/avatarManager.ts @@ -530,7 +530,7 @@ class AvatarRequestQueue { * @param item The avatar request item. */ private insertItem(item: AvatarRequestItem) { - var l = 0, r = this.queue.length - 1, c, prevLength = this.queue.length; + let l = 0, r = this.queue.length - 1, c, prevLength = this.queue.length; while (l <= r) { c = l + r >> 1; if (this.queue[c].checkAfter <= item.checkAfter) { diff --git a/src/commands.ts b/src/commands.ts index 25b01518..87c8def4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -317,7 +317,7 @@ export class CommandManager extends Disposable { /** * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). * The method run when the `git-graph.openFile` command is invoked. - * @param arg The Git Graph URI. + * @param arg The Git Graph URI. */ private openFile(arg?: vscode.Uri) { const uri = arg || vscode.window.activeTextEditor?.document.uri; diff --git a/src/dataSource.ts b/src/dataSource.ts index a9d4cf87..92bd6b6e 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -624,8 +624,8 @@ export class DataSource extends Disposable { /** * Edit an existing remote of a repository. * @param repo The path of the repository. - * @param nameOld The old name of the remote. - * @param nameNew The new name of the remote. + * @param nameOld The old name of the remote. + * @param nameNew The new name of the remote. * @param urlOld The old URL of the remote. * @param urlNew The new URL of the remote. * @param pushUrlOld The old Push URL of the remote. @@ -1703,7 +1703,7 @@ export class DataSource extends Disposable { /** * Spawn Git, with the return value resolved from `stdout` as a string. - * @param args The arguments to pass to Git. + * @param args The arguments to pass to Git. * @param repo The repository to run the command in. * @param resolveValue A callback invoked to resolve the data from `stdout`. */ @@ -1713,7 +1713,7 @@ export class DataSource extends Disposable { /** * Spawn Git, with the return value resolved from `stdout` as a buffer. - * @param args The arguments to pass to Git. + * @param args The arguments to pass to Git. * @param repo The repository to run the command in. * @param resolveValue A callback invoked to resolve the data from `stdout`. */ diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 47f23ad2..3fa92f21 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -54,7 +54,7 @@ export class GitGraphView extends Disposable { GitGraphView.currentPanel.respondLoadRepos(repoManager.getRepos(), loadViewTo); } } else { - // If the Git Graph panel is not visible + // If the Git Graph panel is not visible GitGraphView.currentPanel.loadViewTo = loadViewTo; } GitGraphView.currentPanel.panel.reveal(column); @@ -150,7 +150,7 @@ export class GitGraphView extends Disposable { this.panel ); - // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View + // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View this.repoFileWatcher = new RepoFileWatcher(logger, () => { if (this.panel.visible) { this.sendMessage({ command: 'refresh' }); diff --git a/src/life-cycle/startup.ts b/src/life-cycle/startup.ts index eaab6cbc..0dcb7f40 100644 --- a/src/life-cycle/startup.ts +++ b/src/life-cycle/startup.ts @@ -1,7 +1,7 @@ /** * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. - * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are + * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about diff --git a/src/life-cycle/uninstall.ts b/src/life-cycle/uninstall.ts index f7b0656c..f463244b 100644 --- a/src/life-cycle/uninstall.ts +++ b/src/life-cycle/uninstall.ts @@ -1,7 +1,7 @@ /** * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. - * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are + * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about diff --git a/src/life-cycle/utils.ts b/src/life-cycle/utils.ts index 4be9b94c..032aefde 100644 --- a/src/life-cycle/utils.ts +++ b/src/life-cycle/utils.ts @@ -1,7 +1,7 @@ /** * Git Graph generates an event when it is installed, updated, or uninstalled, that is anonymous, non-personal, and cannot be correlated. * - Each event only contains the Git Graph and Visual Studio Code version numbers, and a 256 bit cryptographically strong pseudo-random nonce. - * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are + * - The two version numbers recorded in these events only allow aggregate compatibility information to be generated (e.g. 50% of users are * using Visual Studio Code >= 1.41.0). These insights enable Git Graph to utilise the latest features of Visual Studio Code as soon as * the majority of users are using a compatible version. The data cannot, and will not, be used for any other purpose. * - Full details are available at: https://api.mhutchie.com/vscode-git-graph/about diff --git a/src/logger.ts b/src/logger.ts index a240b549..bc9b1ccf 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -48,7 +48,7 @@ export class Logger extends Disposable { } } -/** +/** * Pad a number with a leading zero if it is less than two digits long. * @param n The number to be padded. * @returns The padded number. @@ -57,7 +57,7 @@ function pad2(n: number) { return (n > 9 ? '' : '0') + n; } -/** +/** * Pad a number with leading zeros if it is less than three digits long. * @param n The number to be padded. * @returns The padded number. diff --git a/src/repoManager.ts b/src/repoManager.ts index 29bae56e..230f1d8c 100644 --- a/src/repoManager.ts +++ b/src/repoManager.ts @@ -811,7 +811,7 @@ function readExternalConfigFile(repo: string) { /** * Writes the External Configuration File of a repository to the File System. * @param repo The path of the repository. - * @param file The file contents. + * @param file The file contents. * @returns A promise that resolves to a success message, or rejects to an error message. */ function writeExternalConfigFile(repo: string, file: ExternalRepoConfig.File) { @@ -910,7 +910,7 @@ function generateExternalConfigFile(state: GitRepoState): Readonly Value, String => The first field that is invalid. + * @returns NULL => Value, String => The first field that is invalid. */ function validateExternalConfigFile(file: Readonly) { if (typeof file.commitOrdering !== 'undefined' && file.commitOrdering !== RepoCommitOrdering.Date && file.commitOrdering !== RepoCommitOrdering.AuthorDate && file.commitOrdering !== RepoCommitOrdering.Topological) { diff --git a/src/statusBarItem.ts b/src/statusBarItem.ts index 79e5cac3..bab09245 100644 --- a/src/statusBarItem.ts +++ b/src/statusBarItem.ts @@ -44,7 +44,7 @@ export class StatusBarItem extends Disposable { this.setNumRepos(initialNumRepos); } - /** + /** * Sets the number of repositories known to Git Graph, before refreshing the Status Bar Item. * @param numRepos The number of repositories known to Git Graph. */ @@ -53,7 +53,7 @@ export class StatusBarItem extends Disposable { this.refresh(); } - /** + /** * Show or hide the Status Bar Item according to the configured value of `git-graph.showStatusBarItem`, and the number of repositories known to Git Graph. */ private refresh() { diff --git a/tests/repoFileWatcher.test.ts b/tests/repoFileWatcher.test.ts index b44507c7..af81b3ed 100644 --- a/tests/repoFileWatcher.test.ts +++ b/tests/repoFileWatcher.test.ts @@ -90,7 +90,7 @@ describe('RepoFileWatcher', () => { const onDidCreate = (>repoFileWatcher['fsWatcher']!.onDidCreate).mock.calls[0][0]; const onDidChange = (>repoFileWatcher['fsWatcher']!.onDidChange).mock.calls[0][0]; - // Run + // Run repoFileWatcher.mute(); repoFileWatcher.unmute(); onDidCreate(vscode.Uri.file('/path/to/repo/file')); diff --git a/web/contextMenu.ts b/web/contextMenu.ts index d52526d3..927d771c 100644 --- a/web/contextMenu.ts +++ b/web/contextMenu.ts @@ -140,7 +140,7 @@ class ContextMenu { } return; } else { - // ContextMenu is dependent on the commit and ref + // ContextMenu is dependent on the commit and ref const elems = >commitElem.querySelectorAll('[data-fullref]'); for (let i = 0; i < elems.length; i++) { if (elems[i].dataset.fullref! === this.target.ref) { diff --git a/web/dialog.ts b/web/dialog.ts index e1385a99..92d762d5 100644 --- a/web/dialog.ts +++ b/web/dialog.ts @@ -194,7 +194,7 @@ class Dialog { * @param target The target that the dialog was triggered on. * @param secondaryActionName An optional name for the secondary action. * @param secondaryActioned An optional callback to be invoked when the secondary action is selected by the user. - * @param includeLineBreak Should a line break be added between the message and form inputs. + * @param includeLineBreak Should a line break be added between the message and form inputs. */ public showForm(message: string, inputs: ReadonlyArray, actionName: string, actioned: (values: DialogInputValue[]) => void, target: DialogTarget | null, secondaryActionName: string = 'Cancel', secondaryActioned: ((values: DialogInputValue[]) => void) | null = null, includeLineBreak: boolean = true) { const multiElement = inputs.length > 1; @@ -419,7 +419,7 @@ class Dialog { } return; } else { - // Dialog is dependent on the commit and ref + // Dialog is dependent on the commit and ref const elems = >commitElem.querySelectorAll('[data-fullref]'); for (let i = 0; i < elems.length; i++) { if (elems[i].dataset.fullref! === this.target.ref) { diff --git a/web/findWidget.ts b/web/findWidget.ts index a79ad36c..ff0e0974 100644 --- a/web/findWidget.ts +++ b/web/findWidget.ts @@ -234,15 +234,16 @@ class FindWidget { for (let i = 0; i < commits.length; i++) { commit = commits[i]; let branchLabels = getBranchLabels(commit.heads, commit.remotes); - if (commit.hash !== UNCOMMITTED && ((colVisibility.author && findPattern.test(commit.author)) + if (commit.hash !== UNCOMMITTED && ( + (colVisibility.author && findPattern.test(commit.author)) || (colVisibility.commit && (commit.hash.search(findPattern) === 0 || findPattern.test(abbrevCommit(commit.hash)))) || findPattern.test(commit.message) || branchLabels.heads.some(head => findPattern!.test(head.name) || head.remotes.some(remote => findPattern!.test(remote))) || branchLabels.remotes.some(remote => findPattern!.test(remote.name)) || commit.tags.some(tag => findPattern!.test(tag.name)) || (colVisibility.date && findPattern.test(formatShortDate(commit.date).formatted)) - || (commit.stash !== null && findPattern.test(commit.stash.selector)))) { - + || (commit.stash !== null && findPattern.test(commit.stash.selector)) + )) { let idStr = i.toString(); while (j < commitElems.length && commitElems[j].dataset.id !== idStr) j++; if (j === commitElems.length) continue; @@ -388,7 +389,7 @@ class FindWidget { } /** - * If the Find Widget is configured to open the Commit Details View for the current find match, load the Commit Details View accordingly. + * If the Find Widget is configured to open the Commit Details View for the current find match, load the Commit Details View accordingly. */ private openCommitDetailsViewForCurrentMatchIfEnabled() { if (workspaceState.findOpenCommitDetailsView) { @@ -413,5 +414,4 @@ class FindWidget { span.innerHTML = text; return span; } - } diff --git a/web/global.d.ts b/web/global.d.ts index e8a5cf5f..5e1072ff 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -5,9 +5,9 @@ declare global { /* Visual Studio Code API Types */ function acquireVsCodeApi(): { - getState(): WebViewState | null, - postMessage(message: GG.RequestMessage): void, - setState(state: WebViewState): void + getState: () => WebViewState | null, + postMessage: (message: GG.RequestMessage) => void, + setState: (state: WebViewState) => void }; diff --git a/web/graph.ts b/web/graph.ts index 1d576723..415dfd2e 100644 --- a/web/graph.ts +++ b/web/graph.ts @@ -102,7 +102,7 @@ class Branch { lines.push({ p1: { x: x1, y: y1 }, p2: { x: x2, y: y2 }, isCommitted: i >= this.numUncommitted, lockedFirst: line.lockedFirst }); } - // Simplify consecutive lines that are straight by removing the 'middle' point + // Simplify consecutive lines that are straight by removing the 'middle' point i = 0; while (i < lines.length - 1) { line = lines[i]; diff --git a/web/main.ts b/web/main.ts index b8f6689f..1ef4b5c0 100644 --- a/web/main.ts +++ b/web/main.ts @@ -627,7 +627,7 @@ class GitGraphView { refreshState.hard = refreshState.hard || hard; refreshState.configChanges = refreshState.configChanges || configChanges; if (!skipRepoInfo) { - // This request will trigger a loadCommit request after the loadRepoInfo request has completed. + // This request will trigger a loadCommit request after the loadRepoInfo request has completed. // Invalidate any previous commit requests in progress. refreshState.loadCommitsRefreshId++; } @@ -1663,7 +1663,7 @@ class GitGraphView { } if (columnWidths[0] !== COLUMN_AUTO) { - // Table should have fixed layout + // Table should have fixed layout makeTableFixedLayout(); } else { // Table should have automatic layout @@ -2070,7 +2070,7 @@ class GitGraphView { const isExternalUrl = isExternalUrlElem(eventTarget), isInternalUrl = isInternalUrlElem(eventTarget); if (isExternalUrl || isInternalUrl) { const viewElem: HTMLElement | null = eventTarget.closest('#view'); - let eventElem; + let eventElem: HTMLElement | null; let target: (ContextMenuTarget & CommitTarget) | RepoTarget, isInDialog = false; if (this.expandedCommit !== null && eventTarget.closest('#cdv') !== null) { @@ -2083,7 +2083,7 @@ class GitGraphView { }; GitGraphView.closeCdvContextMenuIfOpen(this.expandedCommit); this.expandedCommit.contextMenuOpen.summary = true; - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { // URL is in the Commits const commit = this.getCommitOfElem(eventElem); if (commit === null) return; @@ -2140,16 +2140,16 @@ class GitGraphView { if (e.target === null) return; const eventTarget = e.target; if (isUrlElem(eventTarget)) return; - let eventElem; + let eventElem: HTMLElement | null; - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { // .gitRef was clicked e.stopPropagation(); if (contextMenu.isOpen()) { contextMenu.close(); } - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { // .commit was clicked if (this.expandedCommit !== null) { const commit = this.getCommitOfElem(eventElem); @@ -2177,9 +2177,9 @@ class GitGraphView { if (e.target === null) return; const eventTarget = e.target; if (isUrlElem(eventTarget)) return; - let eventElem; + let eventElem: HTMLElement | null; - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { // .gitRef was double clicked e.stopPropagation(); closeDialogAndContextMenu(); @@ -2214,9 +2214,9 @@ class GitGraphView { if (e.target === null) return; const eventTarget = e.target; if (isUrlElem(eventTarget)) return; - let eventElem; + let eventElem: HTMLElement | null; - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { // .gitRef was right clicked handledEvent(e); const commitElem = eventElem.closest('.commit')!; @@ -2253,7 +2253,7 @@ class GitGraphView { contextMenu.show(actions, false, target, e, this.viewElem); - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { // .commit was right clicked handledEvent(e); const commit = this.getCommitOfElem(eventElem); @@ -2706,10 +2706,10 @@ class GitGraphView { }; document.getElementById('cdvDivider')!.addEventListener('mousedown', () => { - let contentElem = document.getElementById('cdvContent')!; + const contentElem = document.getElementById('cdvContent'); if (contentElem === null) return; - let bounds = contentElem.getBoundingClientRect(); + const bounds = contentElem.getBoundingClientRect(); minX = bounds.left; width = bounds.width; eventOverlay.create('colResize', processDraggingCdvDivider, stopDraggingCdvDivider); diff --git a/web/textFormatter.ts b/web/textFormatter.ts index 70287642..6230693f 100644 --- a/web/textFormatter.ts +++ b/web/textFormatter.ts @@ -556,7 +556,7 @@ class TextFormatter { } /** - * Is a range included (partially or completely) within a tree. + * Is a range included (partially or completely) within a tree. * @param tree The tree to check. * @param start The index defining the start of the range. * @param end The index defining the end of the range. From 5ca83ed4412a377020a9aa8a284944e07cb01922 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 17 Mar 2021 20:14:52 +1100 Subject: [PATCH 10/15] Visual Studio Code Development Integration Improvements --- .vscode/launch.json | 5 ++-- .vscode/settings.json | 6 ++++- .vscode/tasks.json | 57 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 511e08e6..4572b7c7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,7 @@ { "version": "0.2.0", - "configurations": [{ + "configurations": [ + { "name": "Run Extension", "type": "extensionHost", "request": "launch", @@ -13,4 +14,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e8b1b1e8..3957fb38 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,9 @@ }, "search.exclude": { "out": true - } + }, + "task.problemMatchers.neverPrompt": { + "npm": true + }, + "typescript.tsdk": "./node_modules/typescript/lib" } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..8bdfd802 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,57 @@ +{ + "version": "2.0.0", + "presentation": { + "reveal": "always", + "focus": false, + "echo": true, + "showReuseMessage": false, + "panel": "shared" + }, + "tasks": [ + { + "type": "npm", + "script": "compile", + "label": "Compile" + }, + { + "type": "npm", + "script": "compile-src", + "label": "Compile (Back-End Only)" + }, + { + "type": "npm", + "script": "compile-web", + "label": "Compile (Front-End Only)" + }, + { + "type": "npm", + "script": "compile-web-debug", + "label": "Compile (Front-End Only) - Debug" + }, + { + "type": "npm", + "script": "lint", + "label": "Lint" + }, + { + "type": "npm", + "script": "package", + "label": "Package VSIX" + }, + { + "type": "npm", + "script": "package-and-install", + "label": "Package VSIX (Install on Completion)" + }, + { + "type": "npm", + "script": "test", + "label": "Run Unit Tests" + }, + { + "type": "npm", + "script": "test-and-report-coverage", + "label": "Run Unit Tests (Report Code Coverage)" + } + ] +} \ No newline at end of file From 31806caa6218944f0ba3ad47d849a9d465fc8889 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Wed, 17 Mar 2021 21:10:51 +1100 Subject: [PATCH 11/15] #486 All Git Graph View Keyboard Shortcut extension settings can now alternatively be set to "UNASSIGNED", if you don't want to have a keybinding for a specific Keyboard Shortcut. --- package.json | 4 ++++ src/config.ts | 11 ++++++---- src/types.ts | 8 ++++---- tests/config.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ web/main.ts | 12 +++++------ 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 233faed8..de0cc39d 100644 --- a/package.json +++ b/package.json @@ -730,6 +730,7 @@ "git-graph.keyboardShortcut.find": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -763,6 +764,7 @@ "git-graph.keyboardShortcut.refresh": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -796,6 +798,7 @@ "git-graph.keyboardShortcut.scrollToHead": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -829,6 +832,7 @@ "git-graph.keyboardShortcut.scrollToStash": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", diff --git a/src/config.ts b/src/config.ts index ed482331..d5dc870b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -578,11 +578,14 @@ class Config { */ private getKeybinding(section: string, defaultValue: string) { const configValue = this.config.get(section); - if (typeof configValue === 'string' && Config.KEYBINDING_REGEXP.test(configValue)) { - return configValue.substring(11).toLowerCase(); - } else { - return defaultValue; + if (typeof configValue === 'string') { + if (configValue === 'UNASSIGNED') { + return null; + } else if (Config.KEYBINDING_REGEXP.test(configValue)) { + return configValue.substring(11).toLowerCase(); + } } + return defaultValue; } /** diff --git a/src/types.ts b/src/types.ts index 2b8a0032..80176974 100644 --- a/src/types.ts +++ b/src/types.ts @@ -279,10 +279,10 @@ export interface GraphConfig { } export interface KeybindingConfig { - readonly find: string; - readonly refresh: string; - readonly scrollToHead: string; - readonly scrollToStash: string; + readonly find: string | null; + readonly refresh: string | null; + readonly scrollToHead: string | null; + readonly scrollToStash: string | null; } export type LoadGitGraphViewTo = { diff --git a/tests/config.test.ts b/tests/config.test.ts index 0586cc5e..18cb70cd 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1825,6 +1825,18 @@ describe('Config', () => { expect(value).toBe('a'); }); + it('Should return the configured keybinding (unassigned)', () => { + // Setup + vscode.mockExtensionSettingReturnValue('keyboardShortcut.find', 'UNASSIGNED'); + + // Run + const value = config.keybindings.find; + + // Assert + expect(workspaceConfiguration.get).toBeCalledWith('keyboardShortcut.find'); + expect(value).toBeNull(); + }); + it('Should return the default keybinding when the value is not one of the available keybindings', () => { // Setup vscode.mockExtensionSettingReturnValue('keyboardShortcut.find', 'CTRL/CMD + Shift + A'); @@ -1872,6 +1884,18 @@ describe('Config', () => { expect(value).toBe('a'); }); + it('Should return the configured keybinding (unassigned)', () => { + // Setup + vscode.mockExtensionSettingReturnValue('keyboardShortcut.refresh', 'UNASSIGNED'); + + // Run + const value = config.keybindings.refresh; + + // Assert + expect(workspaceConfiguration.get).toBeCalledWith('keyboardShortcut.refresh'); + expect(value).toBeNull(); + }); + it('Should return the default keybinding when the value is not one of the available keybindings', () => { // Setup vscode.mockExtensionSettingReturnValue('keyboardShortcut.refresh', 'CTRL/CMD + Shift + A'); @@ -1919,6 +1943,18 @@ describe('Config', () => { expect(value).toBe('a'); }); + it('Should return the configured keybinding (unassigned)', () => { + // Setup + vscode.mockExtensionSettingReturnValue('keyboardShortcut.scrollToHead', 'UNASSIGNED'); + + // Run + const value = config.keybindings.scrollToHead; + + // Assert + expect(workspaceConfiguration.get).toBeCalledWith('keyboardShortcut.scrollToHead'); + expect(value).toBeNull(); + }); + it('Should return the default keybinding when the value is not one of the available keybindings', () => { // Setup vscode.mockExtensionSettingReturnValue('keyboardShortcut.scrollToHead', 'CTRL/CMD + Shift + A'); @@ -1966,6 +2002,18 @@ describe('Config', () => { expect(value).toBe('a'); }); + it('Should return the configured keybinding (unassigned)', () => { + // Setup + vscode.mockExtensionSettingReturnValue('keyboardShortcut.scrollToStash', 'UNASSIGNED'); + + // Run + const value = config.keybindings.scrollToStash; + + // Assert + expect(workspaceConfiguration.get).toBeCalledWith('keyboardShortcut.scrollToStash'); + expect(value).toBeNull(); + }); + it('Should return the default keybinding when the value is not one of the available keybindings', () => { // Setup vscode.mockExtensionSettingReturnValue('keyboardShortcut.scrollToStash', 'CTRL/CMD + Shift + A'); diff --git a/web/main.ts b/web/main.ts index 1ef4b5c0..95fcaf9c 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2008,19 +2008,19 @@ class GitGraphView { const elem = findCommitElemWithId(getCommitElems(), newHashIndex); if (elem !== null) this.loadCommitDetails(elem); } - } else if (e.ctrlKey || e.metaKey) { - const key = e.key.toLowerCase(); - if (key === this.config.keybindings.scrollToStash) { + } else if (e.key && (e.ctrlKey || e.metaKey)) { + const key = e.key.toLowerCase(), keybindings = this.config.keybindings; + if (key === keybindings.scrollToStash) { this.scrollToStash(!e.shiftKey); handledEvent(e); } else if (!e.shiftKey) { - if (key === this.config.keybindings.refresh) { + if (key === keybindings.refresh) { this.refresh(true, true); handledEvent(e); - } else if (key === this.config.keybindings.find) { + } else if (key === keybindings.find) { this.findWidget.show(true); handledEvent(e); - } else if (key === this.config.keybindings.scrollToHead && this.commitHead !== null) { + } else if (key === keybindings.scrollToHead && this.commitHead !== null) { this.scrollToCommit(this.commitHead, true, true); handledEvent(e); } From 88f873a5a3083a68d0348226af58e5aad316ef28 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Tue, 30 Mar 2021 19:49:17 +1100 Subject: [PATCH 12/15] #479 Additional RepoManager adjustments and improvements. --- src/repoManager.ts | 44 +++++--- tests/repoManager.test.ts | 222 +++++++++++++++++++++++++++++++++++--- web/main.ts | 5 +- 3 files changed, 235 insertions(+), 36 deletions(-) diff --git a/src/repoManager.ts b/src/repoManager.ts index 230f1d8c..5e863eb5 100644 --- a/src/repoManager.ts +++ b/src/repoManager.ts @@ -86,7 +86,10 @@ export class RepoManager extends Disposable { this.stopWatchingFolder(path); } } - changes = this.updateReposWorkspaceFolderIndex() || changes; + if (this.updateReposWorkspaceFolderIndex()) { + this.extensionState.saveRepos(this.repos); + changes = true; + } if (changes) { this.sendRepos(); @@ -150,8 +153,13 @@ export class RepoManager extends Disposable { */ private async startupTasks() { this.removeReposNotInWorkspace(); - this.updateReposWorkspaceFolderIndex(); - if (!await this.checkReposExist()) this.sendRepos(); + if (this.updateReposWorkspaceFolderIndex()) { + this.extensionState.saveRepos(this.repos); + } + if (!await this.checkReposExist()) { + // On startup, ensure that sendRepo is called (even if no changes were made) + this.sendRepos(); + } this.checkReposForNewConfig(); await this.checkReposForNewSubmodules(); await this.searchWorkspaceForRepos(); @@ -363,21 +371,22 @@ export class RepoManager extends Disposable { * @returns TRUE => At least one repository was removed or transferred, FALSE => No repositories were removed. */ public checkReposExist() { - return new Promise(resolve => { - let repoPaths = Object.keys(this.repos), changes = false; - evalPromises(repoPaths, 3, path => this.dataSource.repoRoot(path)).then(results => { - for (let i = 0; i < repoPaths.length; i++) { - if (results[i] === null) { - this.removeRepo(repoPaths[i]); - changes = true; - } else if (repoPaths[i] !== results[i]) { - this.transferRepoState(repoPaths[i], results[i]!); - changes = true; - } + let repoPaths = Object.keys(this.repos), changes = false; + return evalPromises(repoPaths, 3, (path) => this.dataSource.repoRoot(path)).then((results) => { + for (let i = 0; i < repoPaths.length; i++) { + if (results[i] === null) { + this.removeRepo(repoPaths[i]); + changes = true; + } else if (repoPaths[i] !== results[i]) { + this.transferRepoState(repoPaths[i], results[i]!); + changes = true; } - if (changes) this.sendRepos(); - resolve(changes); - }); + } + }).catch(() => { }).then(() => { + if (changes) { + this.sendRepos(); + } + return changes; }); } @@ -429,6 +438,7 @@ export class RepoManager extends Disposable { private transferRepoState(oldRepo: string, newRepo: string) { this.repos[newRepo] = this.repos[oldRepo]; delete this.repos[oldRepo]; + this.updateReposWorkspaceFolderIndex(newRepo); this.extensionState.saveRepos(this.repos); this.extensionState.transferRepo(oldRepo, newRepo); diff --git a/tests/repoManager.test.ts b/tests/repoManager.test.ts index 0112278c..1a03cefa 100644 --- a/tests/repoManager.test.ts +++ b/tests/repoManager.test.ts @@ -278,6 +278,11 @@ describe('RepoManager', () => { '/path/to/workspace-folder2': mockRepoState(2), '/path/to/workspace-folder3': mockRepoState(1) }); + expect(spyOnSaveRepos).toHaveBeenCalledWith({ + '/path/to/workspace-folder1': mockRepoState(0), + '/path/to/workspace-folder2': mockRepoState(2), + '/path/to/workspace-folder3': mockRepoState(1) + }); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { @@ -372,21 +377,27 @@ describe('RepoManager', () => { // Setup mockRepositoryWithNoSubmodules(); // Exists: /path/to/workspace-folder1/repo1 mockDirectoryThatsNotRepository(); // Removed: Re/path/to/workspace-folder1/repo2 - mockRepositoryWithNoSubmodules(); // New: /path/to/workspace-folder3 - mockRepositoryWithNoSubmodules(); // New: /path/to/another + mockRepositoryWithNoSubmodules(); // Exists: /path/to/another mockDirectoryThatsNotRepository(); // Not Repo: /path/to/workspace-folder1 + mockRepositoryWithNoSubmodules(); // New: /path/to/workspace-folder3 // Run const repoManager = await constructRepoManagerAndWaitUntilStarted( ['/path/to/workspace-folder1', '/path/to/another/workspace-folder', '/path/to/workspace-folder3'], - ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2', '/path/to/workspace-folder3', '/path/to/workspace-folder4', '/path/to/another'] + ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2', '/path/to/workspace-folder4', '/path/to/another'] ); // Assert expect(repoManager.getRepos()).toStrictEqual({ '/path/to/workspace-folder1/repo1': mockRepoState(0), - '/path/to/workspace-folder3': mockRepoState(2), - '/path/to/another': mockRepoState(1) + '/path/to/another': mockRepoState(1), + '/path/to/workspace-folder3': mockRepoState(2) + }); + expect(spyOnSaveRepos).toHaveBeenCalledTimes(4); + expect(spyOnSaveRepos).toHaveBeenCalledWith({ + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/another': mockRepoState(1), + '/path/to/workspace-folder3': mockRepoState(2) }); expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/another/workspace-folder/**'); @@ -395,6 +406,111 @@ describe('RepoManager', () => { // Run repoManager.dispose(); }); + + it('Should run startup tasks (calls saveRepos when updateReposWorkspaceFolderIndex makes changes)', async () => { + // Setup + mockRepositoryWithNoSubmodules(); + + // Run + const repoManager = await constructRepoManagerAndWaitUntilStarted( + ['/path/to/workspace-folder1'], + { + '/path/to/workspace-folder1': mockRepoState(null) + } + ); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1': mockRepoState(0) + }); + expect(spyOnSaveRepos).toHaveBeenCalledWith({ + '/path/to/workspace-folder1': mockRepoState(0) + }); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + + // Run + repoManager.dispose(); + }); + + it('Should run startup tasks (doesn\'t call saveRepos when updateReposWorkspaceFolderIndex doesn\'t make changes)', async () => { + // Setup + mockRepositoryWithNoSubmodules(); + + // Run + const repoManager = await constructRepoManagerAndWaitUntilStarted( + ['/path/to/workspace-folder1'], + { + '/path/to/workspace-folder1': mockRepoState(0) + } + ); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1': mockRepoState(0) + }); + expect(spyOnSaveRepos).not.toHaveBeenCalled(); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + + // Run + repoManager.dispose(); + }); + + it('Should run startup tasks (doesn\'t call sendRepos when checkReposExist makes changes)', async () => { + // Setup + mockDirectoryThatsNotRepository(); + mockDirectoryThatsNotRepository(); + + // Run + const onDidChangeReposEvents: RepoChangeEvent[] = []; + const repoManager = constructRepoManager( + ['/path/to/workspace-folder1'], + ['/path/to/workspace-folder1'] + ); + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + await waitForRepoManagerToStart(); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({}); + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: {}, + numRepos: 0, + loadRepo: null + } + ]); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + + // Run + repoManager.dispose(); + }); + + it('Should run startup tasks (calls sendRepos when checkReposExist doesn\'t make changes)', async () => { + // Setup + mockDirectoryThatsNotRepository(); + + // Run + const onDidChangeReposEvents: RepoChangeEvent[] = []; + const repoManager = constructRepoManager( + ['/path/to/workspace-folder1'], + [] + ); + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + await waitForRepoManagerToStart(); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({}); + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: {}, + numRepos: 0, + loadRepo: null + } + ]); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder1/**'); + + // Run + repoManager.dispose(); + }); }); describe('removeReposNotInWorkspace', () => { @@ -962,32 +1078,44 @@ describe('RepoManager', () => { mockRepositoryWithNoSubmodules(); mockRepositoryWithNoSubmodules(); mockDirectoryThatsNotRepository(); + mockDirectoryThatsNotRepository(); const repoManager = await constructRepoManagerAndWaitUntilStarted( - ['/path/to/workspace-folder1'], + ['/path/to/workspace-folder1', '/path/to/workspace-folder2'], ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2'] ); + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder1/repo2': mockRepoState(0) + }); + + // Setup const onDidChangeReposEvents: RepoChangeEvent[] = []; repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); mockRepository(); - mockRepository((path) => path + '-new'); + mockRepository((path) => path.replace('workspace-folder1', 'workspace-folder2')); // Run const result = await repoManager.checkReposExist(); // Assert expect(result).toBe(true); + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder2/repo2': mockRepoState(1) + }); expect(spyOnSaveRepos).toHaveBeenCalledWith({ '/path/to/workspace-folder1/repo1': mockRepoState(0), - '/path/to/workspace-folder1/repo2-new': mockRepoState(0) + '/path/to/workspace-folder2/repo2': mockRepoState(1) }); - expect(spyOnTransferRepo).toHaveBeenCalledWith('/path/to/workspace-folder1/repo2', '/path/to/workspace-folder1/repo2-new'); - expect(spyOnLog).toHaveBeenCalledWith('Transferred repo state: /path/to/workspace-folder1/repo2 -> /path/to/workspace-folder1/repo2-new'); + expect(spyOnTransferRepo).toHaveBeenCalledWith('/path/to/workspace-folder1/repo2', '/path/to/workspace-folder2/repo2'); + expect(spyOnLog).toHaveBeenCalledWith('Transferred repo state: /path/to/workspace-folder1/repo2 -> /path/to/workspace-folder2/repo2'); expect(onDidChangeReposEvents).toStrictEqual([ { repos: { '/path/to/workspace-folder1/repo1': mockRepoState(0), - '/path/to/workspace-folder1/repo2-new': mockRepoState(0) + '/path/to/workspace-folder2/repo2': mockRepoState(1) }, numRepos: 2, loadRepo: null @@ -1024,6 +1152,52 @@ describe('RepoManager', () => { // Teardown repoManager.dispose(); }); + + it('Should return gracefully when an exception occurs', async () => { + // Setup + mockRepositoryWithNoSubmodules(); + mockRepositoryWithNoSubmodules(); + mockRepositoryWithNoSubmodules(); + mockDirectoryThatsNotRepository(); + mockDirectoryThatsNotRepository(); + const repoManager = await constructRepoManagerAndWaitUntilStarted( + ['/path/to/workspace-folder1', '/path/to/workspace-folder2'], + ['/path/to/workspace-folder1/repo1', '/path/to/workspace-folder1/repo2', '/path/to/workspace-folder1/repo3'] + ); + + const onDidChangeReposEvents: RepoChangeEvent[] = []; + repoManager.onDidChangeRepos((event) => onDidChangeReposEvents.push(event)); + mockRepository((path) => path.replace('workspace-folder1', 'workspace-folder2')); + mockRepository((path) => path.replace('workspace-folder1', 'workspace-folder2')); + mockRepository((path) => path.replace('workspace-folder1', 'workspace-folder2')); + spyOnTransferRepo.mockImplementationOnce(() => { }); + spyOnTransferRepo.mockImplementationOnce(() => { throw new Error(); }); + + // Run + const result = await repoManager.checkReposExist(); + + // Assert + expect(result).toBe(true); + expect(spyOnSaveRepos).toHaveBeenCalledWith({ + '/path/to/workspace-folder2/repo1': mockRepoState(1), + '/path/to/workspace-folder2/repo2': mockRepoState(1), + '/path/to/workspace-folder1/repo3': mockRepoState(0) + }); + expect(onDidChangeReposEvents).toStrictEqual([ + { + repos: { + '/path/to/workspace-folder2/repo1': mockRepoState(1), + '/path/to/workspace-folder2/repo2': mockRepoState(1), + '/path/to/workspace-folder1/repo3': mockRepoState(0) + }, + numRepos: 3, + loadRepo: null + } + ]); + + // Teardown + repoManager.dispose(); + }); }); describe('setRepoState', () => { @@ -2134,6 +2308,7 @@ describe('RepoManager', () => { mockWriteExternalConfigFileOnce(); vscode.window.showInformationMessage.mockResolvedValueOnce(null); const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1'], ['/path/to/workspace-folder1/repo1']); + spyOnSaveRepos.mockClear(); // Run const result = await repoManager.exportRepoConfig('/path/to/workspace-folder1/repo1'); @@ -2184,6 +2359,7 @@ describe('RepoManager', () => { delete repoManager['repos']['/path/to/workspace-folder1/repo1']; return Promise.resolve(); }); + spyOnSaveRepos.mockClear(); // Run const result = await repoManager.exportRepoConfig('/path/to/workspace-folder1/repo1'); @@ -2210,6 +2386,7 @@ describe('RepoManager', () => { mockFsWriteFileOnce(null); vscode.window.showInformationMessage.mockResolvedValueOnce(null); const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1'], ['/path/to/workspace-folder1/repo1']); + spyOnSaveRepos.mockClear(); // Run const result = await repoManager.exportRepoConfig('/path/to/workspace-folder1/repo1'); @@ -2256,6 +2433,7 @@ describe('RepoManager', () => { mockDirectoryThatsNotRepository(); mockFsMkdirOnce(new Error()); const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1'], ['/path/to/workspace-folder1/repo1']); + spyOnSaveRepos.mockClear(); // Run const result = await repoManager.exportRepoConfig('/path/to/workspace-folder1/repo1'); @@ -2275,6 +2453,7 @@ describe('RepoManager', () => { mockFsMkdirOnce(null); mockFsWriteFileOnce(new Error()); const repoManager = await constructRepoManagerAndWaitUntilStarted(['/path/to/workspace-folder1'], ['/path/to/workspace-folder1/repo1']); + spyOnSaveRepos.mockClear(); // Run const result = await repoManager.exportRepoConfig('/path/to/workspace-folder1/repo1'); @@ -2445,9 +2624,14 @@ function mockWriteExternalConfigFileOnce() { mockFsWriteFileOnce(null); } -async function constructRepoManagerAndWaitUntilStarted(workspaceFolders: string[] | undefined, repos: string[], ignoreRepos: string[] = []) { - const repoSet: GitRepoSet = {}; - repos.forEach((repo) => repoSet[repo] = Object.assign({}, DEFAULT_REPO_STATE)); +function constructRepoManager(workspaceFolders: string[] | undefined, repos: string[] | GitRepoSet, ignoreRepos: string[] = []) { + let repoSet: GitRepoSet = {}; + if (Array.isArray(repos)) { + repos.forEach((repo) => repoSet[repo] = mockRepoState(null)); + } else { + repoSet = Object.assign({}, repos); + } + spyOnGetRepos.mockReturnValueOnce(repoSet); spyOnGetIgnoredRepos.mockReturnValueOnce(ignoreRepos); @@ -2458,10 +2642,16 @@ async function constructRepoManagerAndWaitUntilStarted(workspaceFolders: string[ ? workspaceFolders.map((path, index) => ({ uri: vscode.Uri.file(path), index: index })) : undefined; - const repoManager = new RepoManager(dataSource, extensionState, onDidChangeConfiguration.subscribe, logger); + return new RepoManager(dataSource, extensionState, onDidChangeConfiguration.subscribe, logger); +} - await waitForExpect(() => expect(spyOnLog).toHaveBeenCalledWith('Completed searching workspace for new repos')); +function waitForRepoManagerToStart() { + return waitForExpect(() => expect(spyOnLog).toHaveBeenCalledWith('Completed searching workspace for new repos')); +} +async function constructRepoManagerAndWaitUntilStarted(workspaceFolders: string[] | undefined, repos: string[] | GitRepoSet, ignoreRepos: string[] = []) { + const repoManager = constructRepoManager(workspaceFolders, repos, ignoreRepos); + await waitForRepoManagerToStart(); return repoManager; } diff --git a/web/main.ts b/web/main.ts index 95fcaf9c..514afac7 100644 --- a/web/main.ts +++ b/web/main.ts @@ -170,7 +170,7 @@ class GitGraphView { this.gitRepos = repos; this.saveState(); - let repoPaths: ReadonlyArray = Object.keys(repos), newRepo: string; + let newRepo: string; if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { newRepo = loadViewTo.repo; } else if (typeof repos[this.currentRepo] === 'undefined') { @@ -181,7 +181,7 @@ class GitGraphView { newRepo = this.currentRepo; } - alterClass(this.controlsElem, 'singleRepo', repoPaths.length === 1); + alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); this.renderRepoDropdownOptions(newRepo); if (loadViewTo !== null) { @@ -3760,7 +3760,6 @@ function getRepoDropdownOptions(repos: Readonly) { } options.push({ name: names[i], value: repoPaths[i], hint: hint }); } - return options; } From 9c74e1b921b1d1bc1170ec43abdc434d7fd7d2b3 Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Tue, 30 Mar 2021 20:24:08 +1100 Subject: [PATCH 13/15] #482 Adjustments to GitGraphView.cdvUpdateFileState logic and refactoring. --- web/main.ts | 94 ++++++++++++++++++++++++++--------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/web/main.ts b/web/main.ts index 514afac7..07c73adb 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2716,52 +2716,52 @@ class GitGraphView { }); } - private cdvSetLastViewedFile(filePath: string, fileElem: HTMLElement) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileTree === null) return; - - expandedCommit.lastViewedFile = filePath; - let lastViewedElem = document.getElementById('cdvLastFileViewed'); - if (lastViewedElem !== null) lastViewedElem.remove(); - lastViewedElem = document.createElement('span'); - lastViewedElem.id = 'cdvLastFileViewed'; - lastViewedElem.title = 'Last File Viewed'; - lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; - insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); - } - - private cdvChangeFileReviewedState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean, fileWasViewed: boolean) { - const expandedCommit = this.expandedCommit, filePath = file.newFilePath; - const filesElem = document.getElementById('cdvFiles'); - - if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.codeReview === null || filesElem === null) { - return; - } - - if (isReviewed) { - expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); - } else { - expandedCommit.codeReview.remainingFiles.push(filePath); - } + /** + * Updates the state of a file in the Commit Details View. + * @param file The file that was affected. + * @param fileElem The HTML Element of the file. + * @param isReviewed TRUE/FALSE => Set the files reviewed state accordingly, NULL => Don't update the files reviewed state. + * @param fileWasViewed Was the file viewed - if so, set it to be the last viewed file. + */ + private cdvUpdateFileState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean | null, fileWasViewed: boolean) { + const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFiles'), filePath = file.newFilePath; + if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; if (fileWasViewed) { - this.cdvSetLastViewedFile(filePath, fileElem); - } + expandedCommit.lastViewedFile = filePath; + let lastViewedElem = document.getElementById('cdvLastFileViewed'); + if (lastViewedElem !== null) lastViewedElem.remove(); + lastViewedElem = document.createElement('span'); + lastViewedElem.id = 'cdvLastFileViewed'; + lastViewedElem.title = 'Last File Viewed'; + lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; + insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); + } + + if (expandedCommit.codeReview !== null) { + if (isReviewed !== null) { + if (isReviewed) { + expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); + } else { + expandedCommit.codeReview.remainingFiles.push(filePath); + } - sendMessage({ - command: 'updateCodeReview', - repo: this.currentRepo, - id: expandedCommit.codeReview.id, - remainingFiles: expandedCommit.codeReview.remainingFiles, - lastViewedFile: expandedCommit.lastViewedFile - }); + alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); + updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); + } - alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); - updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); + sendMessage({ + command: 'updateCodeReview', + repo: this.currentRepo, + id: expandedCommit.codeReview.id, + remainingFiles: expandedCommit.codeReview.remainingFiles, + lastViewedFile: expandedCommit.lastViewedFile + }); - if (expandedCommit.codeReview.remainingFiles.length === 0) { - expandedCommit.codeReview = null; - this.renderCodeReviewBtn(); + if (expandedCommit.codeReview.remainingFiles.length === 0) { + expandedCommit.codeReview = null; + this.renderCodeReviewBtn(); + } } this.saveState(); @@ -2846,7 +2846,7 @@ class GitGraphView { toHash = expandedCommit.commitHash; } - this.cdvChangeFileReviewedState(file, fileElem, true, true); + this.cdvUpdateFileState(file, fileElem, true, true); sendMessage({ command: 'viewDiff', repo: this.currentRepo, @@ -2866,7 +2866,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvChangeFileReviewedState(file, fileElem, true, true); + this.cdvUpdateFileState(file, fileElem, true, true); sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2874,7 +2874,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvChangeFileReviewedState(file, fileElem, false, true); + this.cdvUpdateFileState(file, fileElem, null, true); sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2882,7 +2882,7 @@ class GitGraphView { const expandedCommit = this.expandedCommit; if (expandedCommit === null) return; - this.cdvChangeFileReviewedState(file, fileElem, true, true); + this.cdvUpdateFileState(file, fileElem, true, true); sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; @@ -2989,12 +2989,12 @@ class GitGraphView { { title: 'Mark as Reviewed', visible: codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvChangeFileReviewedState(file, fileElem, true, false) + onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) }, { title: 'Mark as Not Reviewed', visible: expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvChangeFileReviewedState(file, fileElem, false, false) + onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) } ], [ From 4db26bed6b4fd203818ef75257a1d1cc8cce3fcf Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Tue, 30 Mar 2021 20:45:08 +1100 Subject: [PATCH 14/15] #491 Standardise the cross-platform rendering of Markdown inline code blocks, to ensure they don't affect the height of each commit. --- web/styles/main.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/styles/main.css b/web/styles/main.css index 2da61699..2ef10396 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -39,6 +39,10 @@ body.selection-background-color-exists ::selection{ background-color: var(--vscode-selection-background); } +code{ + line-height:1; +} + /* Content */ From e44a48bb99b53522dd596b8a3fddffc9299c388b Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Fri, 2 Apr 2021 13:42:37 +1100 Subject: [PATCH 15/15] Release 1.30.0 package & documentation changes. --- CHANGELOG.md | 13 +++++++++++++ README.md | 2 +- package.json | 4 ++-- src/commands.ts | 2 +- tests/commands.test.ts | 1 + tests/diffDocProvider.test.ts | 13 +------------ tests/utils.test.ts | 2 +- web/main.ts | 14 ++++++++++---- 8 files changed, 30 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a121b48..96d7015b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 1.30.0 - 2021-04-05 +* #395 Added a "Force Fetch" option onto the "Fetch into Local Branch" Dialog, allowing any local branch (that's not checked out) to be reset to the remote branch. This dialog is accessed via the Remote Branch Context Menu. +* #457 New "View Diff with Working File" action on the File Context Menu in the Commit Details View. +* #466 New "Copy Relative File Path to Clipboard" action on the File Context Menu in the Commit Details View. +* #471 Spaces can be automatically substituted with hyphens or underscores in reference inputs on dialogs (e.g. Create Branch, Add Tag, etc.), by configuring the new extension setting `git-graph.dialog.general.referenceInputSpaceSubstitution`. +* #476 "Open File" action is now available in the Visual Studio Code Diff View Title Menu, when the Diff View is opened from the Git Graph View. (Requires Visual Studio Code >= 1.42.0) +* #479 New Repository Dropdown Order option "Workspace Full Path", that sorts repositories according to the Visual Studio Code Workspace Folder order, then alphabetically by the full path of the repository. This is the new default order for the `git-graph.repositoryDropdownOrder` extension setting. +* #480 When loading the Working File for a file from a historical commit, and the file has since been renamed, Git is now used to detect renames and enable the Working File to be opened. For example: from the "Open File" & "View Diff with Working File" actions on the File Context Menu in the Commit Details View. +* #482 New "Mark as Reviewed" & "Mark as Not Reviewed" actions on the File Context Menu in the Commit Details View, when a Code Review is in progress. Thanks [Dan Arad (@dan1994)](https://github.com/dan1994) for implementing this! +* #486 All Git Graph View Keyboard Shortcut extension settings can now alternatively be set to "UNASSIGNED", if you don't want to have a keybinding for a specific Keyboard Shortcut. +* #491 Standardise the cross-platform rendering of Markdown inline code blocks, to ensure they don't affect the height of each commit. +* Various code improvements. + ## 1.29.0 - 2021-02-28 * #390 When creating a branch or adding a tag, the name is now checked against all existing branches and tags in the repository. If a branch / tag already exists with the same name, a new dialog is displayed that allows you to: replace the existing branch / tag, or to choose another name. * #402 New mode for the Find Widget, which will additionally open the Commit Details View as you navigate through each of the matched commits. This mode is enabled / disabled via a new button on the Find Widget. diff --git a/README.md b/README.md index d7bad428..022a5c4f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ A summary of the Git Graph extension settings are: * **Format**: Specifies the date format to be used in the "Date" column on the Git Graph View. * **Type**: Specifies the date type to be displayed in the "Date" column on the Git Graph View, either the author or commit date. * **Default Column Visibility**: An object specifying the default visibility of the Date, Author & Commit columns. Example: `{"Date": true, "Author": true, "Commit": true}` -* **Dialog > \***: Set the default options on the following dialogs: Add Tag, Apply Stash, Cherry Pick, Create Branch, Delete Branch, Fetch Remote, Merge, Pop Stash, Pull Branch, Rebase, Reset, and Stash Uncommitted Changes +* **Dialog > \***: Set the default options on the following dialogs: Add Tag, Apply Stash, Cherry Pick, Create Branch, Delete Branch, Fetch into Local Branch, Fetch Remote, Merge, Pop Stash, Pull Branch, Rebase, Reset, and Stash Uncommitted Changes * **Enhanced Accessibility**: Visual file change A|M|D|R|U indicators in the Commit Details View for users with colour blindness. In the future, this setting will enable any additional accessibility related features of Git Graph that aren't enabled by default. * **File Encoding**: The character set encoding used when retrieving a specific version of repository files (e.g. in the Diff View). A list of all supported encodings can be found [here](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). * **Graph**: diff --git a/package.json b/package.json index de0cc39d..24abc04b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "git-graph", "displayName": "Git Graph", - "version": "1.29.0", + "version": "1.30.0", "publisher": "mhutchie", "author": { "name": "Michael Hutchison", @@ -1078,7 +1078,7 @@ "enumDescriptions": [ "Sort repositories alphabetically by the full path of the repository.", "Sort repositories alphabetically by the name of the repository.", - "Sort repositories according to the workspace folder order, then alphabetically by the full path of the repository." + "Sort repositories according to the Visual Studio Code Workspace Folder order, then alphabetically by the full path of the repository." ], "default": "Workspace Full Path", "description": "Specifies the order that repositories are sorted in the repository dropdown on the Git Graph View (only visible when more than one repository exists in the current Visual Studio Code Workspace)." diff --git a/src/commands.ts b/src/commands.ts index 87c8def4..7a0f414c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -321,7 +321,7 @@ export class CommandManager extends Disposable { */ private openFile(arg?: vscode.Uri) { const uri = arg || vscode.window.activeTextEditor?.document.uri; - if (typeof uri === 'object' && uri.scheme === DiffDocProvider.scheme) { + if (typeof uri === 'object' && uri && uri.scheme === DiffDocProvider.scheme) { // A Git Graph URI has been provided const request = decodeDiffDocUri(uri); return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 6873ba03..bb9820e6 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -102,6 +102,7 @@ describe('CommandManager', () => { it('Should set git-graph:codiconsSupported to TRUE when vscode.version >= 1.42.0', () => { // Setup commandManager.dispose(); + vscode.mockVscodeVersion('1.42.0'); const spyOnExecuteCommand = jest.spyOn(vscode.commands, 'executeCommand'); const spyOnLog = jest.spyOn(logger, 'log'); vscode.commands.executeCommand.mockResolvedValueOnce(null); diff --git a/tests/diffDocProvider.test.ts b/tests/diffDocProvider.test.ts index f04f90cc..e08454cc 100644 --- a/tests/diffDocProvider.test.ts +++ b/tests/diffDocProvider.test.ts @@ -191,18 +191,7 @@ describe('encodeDiffDocUri', () => { }); describe('decodeDiffDocUri', () => { - it('Should return an null if requested on an empty file URI', () => { - // Run - const value = decodeDiffDocUri(vscode.Uri.file('file').with({ - scheme: 'git-graph', - query: 'bnVsbA==' - })); - - // Assert - expect(value).toBe(null); - }); - - it('Should return the parse DiffDocUriData if requested on a git-graph URI', () => { + it('Should return the parsed DiffDocUriData from the URI', () => { // Run const value = decodeDiffDocUri(vscode.Uri.file('file.txt').with({ scheme: 'git-graph', diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 0c1cd2ed..ab2d31c3 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -322,7 +322,7 @@ describe('doesFileExist', () => { expect(mockedFileSystemModule.access).toHaveBeenNthCalledWith(1, 'file.txt', fs.constants.R_OK, expect.anything()); }); - it('Should return FILE when the file doesn\'t exist', async () => { + it('Should return FALSE when the file doesn\'t exist', async () => { // Setup mockedFileSystemModule.access.mockImplementationOnce((_1: fs.PathLike, _2: number | undefined, callback: (err: NodeJS.ErrnoException | null) => void) => callback(new Error())); diff --git a/web/main.ts b/web/main.ts index 07c73adb..f23b6633 100644 --- a/web/main.ts +++ b/web/main.ts @@ -991,8 +991,9 @@ class GitGraphView { if (remotesWithBranch.length > 0) { inputs.push({ type: DialogInputType.Checkbox, - name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : '') + '' + SVG_ICONS.info + '', - value: false + name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : ''), + value: false, + info: 'This branch is on the remote' + (remotesWithBranch.length > 1 ? 's: ' : ' ') + formatCommaSeparatedList(remotesWithBranch.map((remote) => '"' + remote + '"')) }); } dialog.showForm('Are you sure you want to delete the branch ' + escapeHtml(refName) + '?', inputs, 'Yes, delete', (values) => { @@ -1254,8 +1255,13 @@ class GitGraphView { title: 'Fetch into local branch' + ELLIPSIS, visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, onClick: () => { - dialog.showCheckbox('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', 'Force Fetch' + SVG_ICONS.info + '', this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, 'Yes, fetch', (force) => { - runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: force }, 'Fetching Branch'); + dialog.showForm('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Force Fetch', + value: this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, + info: 'Force the local branch to be reset to this remote branch.' + }], 'Yes, fetch', (values) => { + runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: values[0] }, 'Fetching Branch'); }, target); } }, {