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/.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 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 77db7378..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", @@ -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": { @@ -525,6 +532,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, @@ -535,6 +547,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, @@ -703,6 +730,7 @@ "git-graph.keyboardShortcut.find": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -736,6 +764,7 @@ "git-graph.keyboardShortcut.refresh": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -769,6 +798,7 @@ "git-graph.keyboardShortcut.scrollToHead": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -802,6 +832,7 @@ "git-graph.keyboardShortcut.scrollToStash": { "type": "string", "enum": [ + "UNASSIGNED", "CTRL/CMD + A", "CTRL/CMD + B", "CTRL/CMD + C", @@ -1041,13 +1072,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 Visual Studio Code 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": { @@ -1402,6 +1435,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'", @@ -1424,8 +1470,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 abf896a4..7a0f414c 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, getSortedRepositoryPaths, 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 */ @@ -135,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 })); @@ -166,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) => ({ @@ -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 && 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) => { + 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/config.ts b/src/config.ts index 515c21d6..d5dc870b 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: { @@ -192,10 +193,16 @@ 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) }, + general: { + referenceInputSpaceSubstitution: refInputSpaceSubstitution === 'Hyphen' ? '-' : refInputSpaceSubstitution === 'Underscore' ? '_' : null + }, merge: { noCommit: !!this.config.get('dialog.merge.noCommit', false), noFastForward: !!this.config.get('dialog.merge.noFastForward', true), @@ -516,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; } /** @@ -568,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/dataSource.ts b/src/dataSource.ts index e85a8da6..92bd6b6e 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(); } @@ -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. @@ -610,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. @@ -719,7 +733,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'); @@ -884,10 +898,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); } /** @@ -1191,8 +1211,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')); } @@ -1381,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]; @@ -1410,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'); @@ -1651,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); } @@ -1681,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`. */ @@ -1691,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/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/extensionState.ts b/src/extensionState.ts index 3ccfaf2b..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 = { @@ -359,24 +360,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 4844aa1e..3fa92f21 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'; /** @@ -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' }); @@ -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 @@ -259,7 +256,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': @@ -386,7 +383,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': @@ -467,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': @@ -576,12 +573,24 @@ 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', 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, this.dataSource) + }); + break; case 'viewFileAtRevision': this.sendMessage({ command: 'viewFileAtRevision', 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 1198db24..5e863eb5 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,14 @@ export class RepoManager extends Disposable { this.stopWatchingFolder(path); } } - if (changes) this.sendRepos(); + if (this.updateReposWorkspaceFolderIndex()) { + this.extensionState.saveRepos(this.repos); + changes = true; + } + + if (changes) { + this.sendRepos(); + } }), // Monitor changes to the maxDepthOfRepoSearch Extension Setting, and trigger a new search if needed @@ -143,7 +153,13 @@ export class RepoManager extends Disposable { */ private async startupTasks() { this.removeReposNotInWorkspace(); - 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(); @@ -154,16 +170,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 +229,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 +309,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); @@ -364,24 +371,55 @@ 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; }); } + /** + * 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. @@ -400,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); @@ -659,6 +698,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. @@ -764,7 +821,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) { @@ -863,7 +920,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/src/types.ts b/src/types.ts index 82e3c8de..80176974 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; } @@ -278,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 = { @@ -447,10 +448,16 @@ export interface DialogDefaults { readonly deleteBranch: { readonly forceDelete: boolean }; + readonly fetchIntoLocalBranch: { + readonly forceFetch: boolean + }; readonly fetchRemote: { readonly prune: boolean, readonly pruneTags: boolean }; + readonly general: { + readonly referenceInputSpaceSubstitution: string | null + }; readonly merge: { readonly noCommit: boolean, readonly noFastForward: boolean, @@ -509,7 +516,8 @@ export const enum RepoCommitOrdering { export const enum RepoDropdownOrder { FullPath, - Name + Name, + WorkspaceFullPath } export const enum SquashMessageFormat { @@ -639,12 +647,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; @@ -681,6 +683,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'; @@ -858,6 +861,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'; @@ -970,6 +974,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 { @@ -1152,6 +1157,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; @@ -1164,6 +1180,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; @@ -1189,7 +1214,6 @@ export type RequestMessage = | RequestCheckoutCommit | RequestCherrypickCommit | RequestCleanUntrackedFiles - | RequestCodeReviewFileReviewed | RequestCommitDetails | RequestCompareCommits | RequestCopyFilePath @@ -1238,7 +1262,9 @@ export type RequestMessage = | RequestShowErrorDialog | RequestStartCodeReview | RequestTagDetails + | RequestUpdateCodeReview | RequestViewDiff + | RequestViewDiffWithWorkingFile | RequestViewFileAtRevision | RequestViewScm; @@ -1296,7 +1322,9 @@ export type ResponseMessage = | ResponseSetWorkspaceViewState | ResponseStartCodeReview | ResponseTagDetails + | ResponseUpdateCodeReview | ResponseViewDiff + | ResponseViewDiffWithWorkingFile | ResponseViewFileAtRevision | ResponseViewScm; diff --git a/src/utils.ts b/src/utils.ts index bc40a4f5..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.'; @@ -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 */ @@ -199,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 */ @@ -245,10 +282,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); } /** @@ -333,25 +371,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) { - 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 - }).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.'; + } } /** @@ -380,13 +431,41 @@ 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. + * @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 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); +} + /** * Open a Visual Studio Code Editor (readonly) for a file a specific Git revision. * @param repo The repository the file is contained in. @@ -394,7 +473,7 @@ export function viewDiff(repo: string, fromHash: string, toHash: string, oldFile * @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]; @@ -693,17 +772,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..bb9820e6 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, RepoDropdownOrder } 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,82 @@ describe('CommandManager', () => { }); }); + describe('git-graph:codiconsSupported', () => { + 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); + + // 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 +219,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'); @@ -267,22 +346,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( [ { @@ -306,8 +388,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', @@ -344,8 +426,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); @@ -377,8 +459,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); @@ -442,21 +524,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( [ { @@ -466,6 +552,10 @@ describe('CommandManager', () => { { label: 'repo1', description: '/path/to/repo1' + }, + { + label: 'repo3', + description: '/path/to/repo3' } ], { @@ -480,8 +570,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({ @@ -517,8 +607,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({ @@ -554,8 +644,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); @@ -588,8 +678,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); @@ -624,7 +714,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 @@ -678,7 +768,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])); @@ -734,7 +824,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); @@ -761,7 +851,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])); @@ -790,7 +880,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])); @@ -818,7 +908,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); @@ -859,7 +949,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); @@ -915,7 +1005,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); @@ -961,7 +1051,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); @@ -1001,7 +1091,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); @@ -1118,4 +1208,73 @@ 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', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, 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', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, 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', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', dataSource, vscode.ViewColumn.Active); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Unable to Open File: Error Message'); + }); + }); }); + +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 cb118103..18cb70cd 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,8 +835,10 @@ 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.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); @@ -865,10 +868,16 @@ describe('Config', () => { deleteBranch: { forceDelete: true }, + fetchIntoLocalBranch: { + forceFetch: true + }, fetchRemote: { prune: true, pruneTags: true }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: true, noFastForward: true, @@ -906,6 +915,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,8 +940,10 @@ 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.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); @@ -961,10 +973,16 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: false, @@ -1002,6 +1020,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,8 +1045,10 @@ 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.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); @@ -1057,10 +1078,16 @@ describe('Config', () => { deleteBranch: { forceDelete: true }, + fetchIntoLocalBranch: { + forceFetch: true + }, fetchRemote: { prune: true, pruneTags: true }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: true, noFastForward: true, @@ -1098,6 +1125,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,8 +1150,10 @@ 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.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); @@ -1153,10 +1183,16 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: false, @@ -1204,8 +1240,10 @@ 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.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); @@ -1235,10 +1273,16 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: true, @@ -1279,8 +1323,10 @@ 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.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); @@ -1310,10 +1356,16 @@ describe('Config', () => { deleteBranch: { forceDelete: false }, + fetchIntoLocalBranch: { + forceFetch: false + }, fetchRemote: { prune: false, pruneTags: false }, + general: { + referenceInputSpaceSubstitution: null + }, merge: { noCommit: false, noFastForward: true, @@ -1343,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'); @@ -1354,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'); @@ -1366,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'); @@ -1378,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'); @@ -1389,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'); @@ -1402,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'); @@ -1413,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'); @@ -1727,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'); @@ -1774,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'); @@ -1821,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'); @@ -1868,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'); @@ -2645,7 +2791,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); }); @@ -2657,11 +2803,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'); @@ -2669,17 +2827,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/dataSource.test.ts b/tests/dataSource.test.ts index cb5ea4bf..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 @@ -4945,19 +4983,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/tests/diffDocProvider.test.ts b/tests/diffDocProvider.test.ts index 5f036bcd..e08454cc 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,34 +186,24 @@ describe('encodeDiffDocUri', () => { // Assert expect(uri.scheme).toBe('git-graph'); expect(uri.fsPath).toBe('file'); - expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZSIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIiLCJyZXBvIjoiL3JlcG8ifQ=='); + expect(uri.query).toBe('eyJmaWxlUGF0aCI6InBhdGgvdG8vZmlsZSIsImNvbW1pdCI6IjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIzYzRkNWU2ZjFhMmIiLCJyZXBvIjoiL3JlcG8iLCJleGlzdHMiOnRydWV9'); }); }); 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', - 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/extensionState.test.ts b/tests/extensionState.test.ts index 96ad0547..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); @@ -1167,29 +1175,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 +1213,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/tests/mocks/vscode.ts b/tests/mocks/vscode.ts index aad5f4db..7801a02c 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), @@ -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 }; @@ -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/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/tests/repoManager.test.ts b/tests/repoManager.test.ts index 32f8a2cd..1a03cefa 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,66 @@ 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(spyOnSaveRepos).toHaveBeenCalledWith({ + '/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 +321,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([]); @@ -277,21 +374,138 @@ describe('RepoManager', () => { describe('startupTasks', () => { it('Should run startup tasks', async () => { + // Setup + mockRepositoryWithNoSubmodules(); // Exists: /path/to/workspace-folder1/repo1 + mockDirectoryThatsNotRepository(); // Removed: Re/path/to/workspace-folder1/repo2 + 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-folder4', '/path/to/another'] + ); + + // Assert + expect(repoManager.getRepos()).toStrictEqual({ + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/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/**'); + expect(vscode.workspace.createFileSystemWatcher).toHaveBeenCalledWith('/path/to/workspace-folder3/**'); + + // Run + repoManager.dispose(); + }); + + it('Should run startup tasks (calls saveRepos when updateReposWorkspaceFolderIndex makes changes)', async () => { // Setup mockRepositoryWithNoSubmodules(); - mockDirectoryThatsNotRepository(); - mockDirectoryThatsNotRepository(); // 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': mockRepoState(null) + } ); // Assert expect(repoManager.getRepos()).toStrictEqual({ - '/path/to/workspace-folder1/repo1': DEFAULT_REPO_STATE + '/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 @@ -312,10 +526,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 +565,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 +584,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 +596,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 +652,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 +687,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 +698,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 +843,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 +858,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 +1062,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 @@ -833,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': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo2-new': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': 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': DEFAULT_REPO_STATE, - '/path/to/workspace-folder1/repo2-new': DEFAULT_REPO_STATE + '/path/to/workspace-folder1/repo1': mockRepoState(0), + '/path/to/workspace-folder2/repo2': mockRepoState(1) }, numRepos: 2, loadRepo: null @@ -895,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', () => { @@ -926,7 +1229,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 }; // Run @@ -934,11 +1238,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 +1267,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 +1305,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 +1432,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 +1456,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 +1476,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 +1511,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 +1553,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 +1777,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 +1811,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 +1844,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 +1882,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 +1918,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 +1942,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 +1987,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 +2156,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 +2177,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(); @@ -2003,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'); @@ -2034,7 +2340,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 } }); @@ -2052,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'); @@ -2078,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'); @@ -2109,7 +2418,8 @@ describe('RepoManager', () => { showRemoteBranches: true, showRemoteBranchesV2: BooleanOverride.Default, showStashes: BooleanOverride.Default, - showTags: BooleanOverride.Default + showTags: BooleanOverride.Default, + workspaceFolderIndex: 0 } }); @@ -2123,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'); @@ -2142,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'); @@ -2312,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); @@ -2322,12 +2639,22 @@ 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); + 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; } + +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 6eccb299..ab2d31c3 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, 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 { 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'); @@ -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 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())); + + // 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 @@ -550,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 @@ -642,12 +726,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 +739,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.'); @@ -927,7 +1024,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); @@ -944,6 +1041,52 @@ 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 () => { + // 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', null, null, 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); + 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 () => { @@ -956,6 +1099,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 () => { @@ -963,10 +1107,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 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()); + }); + + 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 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()); }); }); @@ -976,13 +1155,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, @@ -996,13 +1175,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, @@ -1016,13 +1195,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, @@ -1036,13 +1215,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, @@ -1056,13 +1235,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, @@ -1076,13 +1255,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, @@ -1096,12 +1275,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({ @@ -1116,12 +1295,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({ @@ -1136,13 +1315,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, @@ -1161,7 +1340,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({ @@ -1181,7 +1360,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({ @@ -1201,8 +1380,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, @@ -1216,10 +1395,10 @@ 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 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 () => { @@ -1239,6 +1418,123 @@ 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()); + }); +}); + +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', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subfolder/modified.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/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({ + preview: true, + 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', 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/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, + 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 () => { + // 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', '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()); }); }); @@ -1248,12 +1544,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 @@ -1266,7 +1562,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.'); @@ -2094,10 +2390,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); @@ -2105,7 +2401,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); @@ -2113,7 +2409,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); @@ -2121,7 +2417,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); @@ -2129,7 +2425,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); @@ -2137,7 +2433,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); @@ -2145,7 +2441,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); @@ -2153,7 +2449,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); @@ -2161,7 +2457,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); @@ -2169,13 +2465,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); @@ -2183,7 +2479,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); @@ -2191,7 +2487,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); @@ -2207,3 +2503,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'); +} 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 9992b386..92d762d5 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. @@ -192,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; @@ -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.' : ''; @@ -411,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 243d29f4..f23b6633 100644 --- a/web/main.ts +++ b/web/main.ts @@ -170,16 +170,18 @@ 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') { - 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; } - alterClass(this.controlsElem, 'singleRepo', repoPaths.length === 1); + alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); this.renderRepoDropdownOptions(newRepo); if (loadViewTo !== null) { @@ -625,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++; } @@ -989,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) => { @@ -1252,8 +1255,13 @@ 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.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); } }, { @@ -1661,7 +1669,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 @@ -2006,19 +2014,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); } @@ -2068,7 +2076,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) { @@ -2081,7 +2089,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; @@ -2138,16 +2146,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); @@ -2175,9 +2183,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(); @@ -2212,9 +2220,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')!; @@ -2251,7 +2259,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); @@ -2704,42 +2712,64 @@ 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); }); } - private cdvFileViewed(filePath: string, fileElem: HTMLElement) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFiles'); + /** + * 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; - 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 (fileWasViewed) { + 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) { - 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(); + if (isReviewed !== null) { + if (isReviewed) { + expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); + } else { + expandedCommit.codeReview.remainingFiles.push(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(); } } + this.saveState(); } @@ -2785,6 +2815,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 +2852,7 @@ class GitGraphView { toHash = expandedCommit.commitHash; } - this.cdvFileViewed(file.newFilePath, fileElem); + this.cdvUpdateFileState(file, fileElem, true, true); sendMessage({ command: 'viewDiff', repo: this.currentRepo, @@ -2823,30 +2864,32 @@ 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) => { 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.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; - this.cdvFileViewed(file.newFilePath, fileElem); - sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: hash, filePath: file.newFilePath }); + const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, null, true); + 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); - sendMessage({ command: 'openFile', repo: this.currentRepo, filePath: file.newFilePath }); + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); }; addListenerToClass('fileTreeFolder', 'click', (e) => { @@ -2884,7 +2927,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) => { @@ -2922,6 +2965,8 @@ 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; + const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); contextMenu.show([ [ @@ -2932,9 +2977,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, @@ -2943,9 +2993,26 @@ class GitGraphView { ], [ { - title: 'Copy File Path to the Clipboard', + title: 'Mark as Reviewed', + visible: codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) + }, + { + title: 'Mark as Not Reviewed', + visible: expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) + } + ], + [ + { + title: 'Copy Absolute File Path to Clipboard', + visible: true, + onClick: () => triggerCopyFilePath(file, true) + }, + { + title: 'Copy Relative File Path to Clipboard', visible: true, - onClick: () => triggerCopyFilePath(file) + onClick: () => triggerCopyFilePath(file, false) } ] ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { @@ -3084,7 +3151,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'); @@ -3234,8 +3301,16 @@ 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 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'); @@ -3377,7 +3452,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 + '' @@ -3400,7 +3475,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') { @@ -3408,22 +3483,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; } } @@ -3603,8 +3680,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[] } = {}; @@ -3645,11 +3722,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); @@ -3669,7 +3746,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]) { @@ -3689,10 +3766,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/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 */ 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. 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 */