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 1a34348c..c3c971b3 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -464,7 +464,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': @@ -589,7 +589,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 3e0d70db..81233fcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -972,6 +972,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 8f0c06f4..01339b25 100644 --- a/web/main.ts +++ b/web/main.ts @@ -2883,8 +2883,11 @@ class GitGraphView { }; const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + this.cdvChangeFileReviewedState(file.newFilePath, fileElem, true, 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) => {