diff --git a/README.md b/README.md index 022a5c4f..8d5b0eb7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ This extension contributes the following commands: * `git-graph.fetch`: Git Graph: Fetch from Remote(s) _(used to open the Git Graph View and immediately run "Fetch from Remote(s)")_ * `git-graph.removeGitRepository`: Git Graph: Remove Git Repository... _(used to remove repositories from Git Graph)_ * `git-graph.resumeWorkspaceCodeReview`: Git Graph: Resume a specific Code Review in Workspace... _(used to open the Git Graph View to a Code Review that is already in progress)_ +* `git-graph.switchRepository`: Git Graph: Switch Repository... _(used to switch the repository open in the Git Graph View)_ * `git-graph.version`: Git Graph: Get Version Information ## Release Notes @@ -171,4 +172,4 @@ Thank you to all of the contributors that help with the development of Git Graph Some of the icons used in Git Graph are from the following sources, please support them for their excellent work! - [GitHub Octicons](https://octicons.github.com/) ([License](https://github.com/primer/octicons/blob/master/LICENSE)) -- [Icons8](https://icons8.com/icon/pack/free-icons/ios11) ([License](https://icons8.com/license)) \ No newline at end of file +- [Icons8](https://icons8.com/icon/pack/free-icons/ios11) ([License](https://icons8.com/license)) diff --git a/package.json b/package.json index 3d024431..7650db35 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,11 @@ "command": "git-graph.removeGitRepository", "title": "Remove Git Repository..." }, + { + "category": "Git Graph", + "command": "git-graph.switchRepository", + "title": "Switch Repository..." + }, { "category": "Git Graph", "command": "git-graph.resumeWorkspaceCodeReview", diff --git a/src/commands.ts b/src/commands.ts index a6474a68..725a8880 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -53,6 +53,7 @@ export class CommandManager extends Disposable { this.registerCommand('git-graph.fetch', () => this.fetch()); this.registerCommand('git-graph.endAllWorkspaceCodeReviews', () => this.endAllWorkspaceCodeReviews()); this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); + this.registerCommand('git-graph.switchRepository', () => this.switchRepository()); this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); this.registerCommand('git-graph.version', () => this.version()); this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); @@ -239,6 +240,40 @@ export class CommandManager extends Disposable { } } + /** + * The method run when the `git-graph.switchRepository` command is invoked. + */ + private switchRepository() { + const repos = this.repoManager.getRepos(); + const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); + + if (repoPaths.length > 1) { + const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ + label: repos[path].name || getRepoName(path), + description: path + })); + + vscode.window.showQuickPick(items, { + placeHolder: 'Select the repository you want to open in Git Graph:', + canPickMany: false + }).then((item) => { + if (item && item.description) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: item.description + }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Switch Repository".'); + }); + } else if (repoPaths.length === 1) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: repoPaths[0] + }); + } else { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, null); + } + } + /** * The method run when the `git-graph.endAllWorkspaceCodeReviews` command is invoked. */ diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 921105ba..8d458e6d 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -73,7 +73,7 @@ describe('CommandManager', () => { it('Should construct a CommandManager, and be disposed', () => { // Assert - expect(commandManager['disposables']).toHaveLength(11); + expect(commandManager['disposables']).toHaveLength(12); expect(commandManager['gitExecutable']).toStrictEqual({ path: '/path/to/git', version: '2.25.0' @@ -805,6 +805,137 @@ describe('CommandManager', () => { }); }); + describe('git-graph.switchRepository', () => { + it('Should display a quick pick to select a repository to open in the Git Graph View', async () => { + // Setup + const repos = { + '/path/to/repo3': mockRepoState({ name: null, workspaceFolderIndex: 2 }), + '/path/to/repo2': mockRepoState({ name: 'Custom Name', workspaceFolderIndex: 1 }), + '/path/to/repo1': mockRepoState({ name: null, workspaceFolderIndex: 0 }) + }; + spyOnGetRepos.mockReturnValueOnce(repos); + vscode.window.showQuickPick.mockResolvedValueOnce({ + label: 'repo3', + description: '/path/to/repo3' + }); + const spyOnGetSortedRepositoryPaths = jest.spyOn(utils, 'getSortedRepositoryPaths'); + + // Run + vscode.commands.executeCommand('git-graph.switchRepository'); + + // Assert + await waitForExpect(() => { + expect(spyOnLog).toHaveBeenCalledWith('Command Invoked: git-graph.switchRepository'); + expect(spyOnGetSortedRepositoryPaths).toHaveBeenCalledWith(repos, RepoDropdownOrder.WorkspaceFullPath); + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [ + { + label: 'repo1', + description: '/path/to/repo1' + }, + { + label: 'Custom Name', + description: '/path/to/repo2' + }, + { + label: 'repo3', + description: '/path/to/repo3' + } + ], + { + placeHolder: 'Select the repository you want to open in Git Graph:', + canPickMany: false + } + ); + expect(spyOnGitGraphViewCreateOrShow).toHaveBeenCalledWith('/path/to/extension', dataSource, extensionState, avatarManager, repoManager, logger, { repo: '/path/to/repo3' }); + }); + }); + + it('Shouldn\'t open the Git Graph View when no item is selected in the quick pick', async () => { + // Setup + spyOnGetRepos.mockReturnValueOnce({ + '/path/to/repo1': mockRepoState({ name: null, workspaceFolderIndex: 0 }), + '/path/to/repo2': mockRepoState({ name: 'Custom Name', workspaceFolderIndex: 0 }) + }); + vscode.window.showQuickPick.mockResolvedValueOnce(null); + + // Run + vscode.commands.executeCommand('git-graph.switchRepository'); + + // Assert + await waitForExpect(() => { + expect(spyOnLog).toHaveBeenCalledWith('Command Invoked: git-graph.switchRepository'); + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [ + { + label: 'repo1', + description: '/path/to/repo1' + }, + { + label: 'Custom Name', + description: '/path/to/repo2' + } + ], + { + placeHolder: 'Select the repository you want to open in Git Graph:', + canPickMany: false + } + ); + expect(spyOnGitGraphViewCreateOrShow).not.toHaveBeenCalled(); + }); + }); + + it('Should display an error message when showQuickPick rejects', async () => { + // Setup + spyOnGetRepos.mockReturnValueOnce({ + '/path/to/repo1': mockRepoState({ name: null, workspaceFolderIndex: 0 }), + '/path/to/repo2': mockRepoState({ name: 'Custom Name', workspaceFolderIndex: 0 }) + }); + vscode.window.showQuickPick.mockRejectedValueOnce(null); + vscode.window.showErrorMessage.mockResolvedValueOnce(null); + + // Run + vscode.commands.executeCommand('git-graph.switchRepository'); + + // Assert + await waitForExpect(() => { + expect(spyOnLog).toHaveBeenCalledWith('Command Invoked: git-graph.switchRepository'); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('An unexpected error occurred while running the command "Switch Repository".'); + expect(spyOnGitGraphViewCreateOrShow).not.toHaveBeenCalled(); + }); + }); + + it('Should open the Git Graph View immediately when there is only one repository', async () => { + // Setup + spyOnGetRepos.mockReturnValueOnce({ + '/path/to/repo1': mockRepoState({ name: null, workspaceFolderIndex: 0 }) + }); + + // Run + vscode.commands.executeCommand('git-graph.switchRepository'); + + // Assert + await waitForExpect(() => { + expect(spyOnLog).toHaveBeenCalledWith('Command Invoked: git-graph.switchRepository'); + expect(spyOnGitGraphViewCreateOrShow).toHaveBeenCalledWith('/path/to/extension', dataSource, extensionState, avatarManager, repoManager, logger, { repo: '/path/to/repo1' }); + }); + }); + + it('Should open the Git Graph View immediately when there are no repositories', async () => { + // Setup + spyOnGetRepos.mockReturnValueOnce({}); + + // Run + vscode.commands.executeCommand('git-graph.switchRepository'); + + // Assert + await waitForExpect(() => { + expect(spyOnLog).toHaveBeenCalledWith('Command Invoked: git-graph.switchRepository'); + expect(spyOnGitGraphViewCreateOrShow).toHaveBeenCalledWith('/path/to/extension', dataSource, extensionState, avatarManager, repoManager, logger, null); + }); + }); + }); + describe('git-graph.endAllWorkspaceCodeReviews', () => { it('Should end all workspace code reviews', () => { // Setup diff --git a/web/dropdown.ts b/web/dropdown.ts index d69407d0..061c2801 100644 --- a/web/dropdown.ts +++ b/web/dropdown.ts @@ -17,7 +17,8 @@ class Dropdown { private lastSelected: number = 0; // Only used when multipleAllowed === false private dropdownVisible: boolean = false; private lastClicked: number = 0; - private doubleClickTimeout: NodeJS.Timer | null = null; + private doubleClickTimeout: number | null = null; + private keyboardFocus: number | null = null; private readonly elem: HTMLElement; private readonly currentValueElem: HTMLDivElement; @@ -86,7 +87,11 @@ class Dropdown { } }, true); document.addEventListener('contextmenu', () => this.close(), true); - this.filterInput.addEventListener('keyup', () => this.filter()); + this.filterInput.addEventListener('keydown', (e) => this.onFilterInputKeyDown(e)); + this.filterInput.addEventListener('keyup', (e) => { + if (isKeyboardNavigationEvent(e)) return; + this.filter(); + }); } /** @@ -208,6 +213,7 @@ class Dropdown { public close() { this.elem.classList.remove('dropdownOpen'); this.dropdownVisible = false; + this.keyboardFocus = null; this.clearDoubleClickTimeout(); } @@ -247,16 +253,82 @@ class Dropdown { * Filter the options displayed in the dropdown list, based on the filter criteria specified by the user. */ private filter() { - let val = this.filterInput.value.toLowerCase(), match, matches = false; + let val = this.filterInput.value.toLowerCase(), match, matches = false, firstMatch: number | null = null; for (let i = 0; i < this.options.length; i++) { match = this.options[i].name.toLowerCase().indexOf(val) > -1; (this.optionsElem.children[i]).style.display = match ? 'block' : 'none'; - if (match) matches = true; + if (match) { + matches = true; + if (firstMatch === null) firstMatch = i; + } } + this.keyboardFocus = firstMatch; + this.renderKeyboardFocus(); this.filterInput.style.display = 'block'; this.noResultsElem.style.display = matches ? 'none' : 'block'; } + /** + * Handle keyboard navigation in the dropdown filter input. + * @param e The keyboard event. + */ + private onFilterInputKeyDown(e: KeyboardEvent) { + if (e.key === 'ArrowDown' || e.keyCode === 40) { + this.moveKeyboardFocus(1); + handledEvent(e); + } else if (e.key === 'ArrowUp' || e.keyCode === 38) { + this.moveKeyboardFocus(-1); + handledEvent(e); + } else if (e.key === 'Enter' || e.keyCode === 13) { + if (this.keyboardFocus !== null) { + this.onOptionClick(this.keyboardFocus); + handledEvent(e); + } + } + } + + /** + * Move the keyboard focus between filtered dropdown options. + * @param direction The direction to move, either 1 for down or -1 for up. + */ + private moveKeyboardFocus(direction: number) { + const visibleOptions = this.getVisibleOptions(); + if (visibleOptions.length === 0) return; + + const currentVisibleOptionIndex = this.keyboardFocus !== null ? visibleOptions.indexOf(this.keyboardFocus) : -1; + const nextVisibleOptionIndex = currentVisibleOptionIndex === -1 + ? direction > 0 ? 0 : visibleOptions.length - 1 + : (currentVisibleOptionIndex + direction + visibleOptions.length) % visibleOptions.length; + this.keyboardFocus = visibleOptions[nextVisibleOptionIndex]; + this.renderKeyboardFocus(); + } + + /** + * Get the option indexes that are visible after filtering. + * @returns The visible option indexes. + */ + private getVisibleOptions() { + let visibleOptions = []; + for (let i = 0; i < this.optionsElem.children.length; i++) { + if ((this.optionsElem.children[i]).style.display !== 'none') { + visibleOptions.push(i); + } + } + return visibleOptions; + } + + /** + * Render the keyboard focus state on dropdown options. + */ + private renderKeyboardFocus() { + for (let i = 0; i < this.optionsElem.children.length; i++) { + alterClass(this.optionsElem.children[i], CLASS_ACTIVE, this.keyboardFocus === i); + } + if (this.keyboardFocus !== null) { + (this.optionsElem.children[this.keyboardFocus]).scrollIntoView({ block: 'nearest' }); + } + } + /** * Get an array of the selected dropdown options. * @param names TRUE => Return the names of the selected options, FALSE => Return the values of the selected options. @@ -359,3 +431,7 @@ class Dropdown { } } } + +function isKeyboardNavigationEvent(e: KeyboardEvent) { + return e.key === 'ArrowDown' || e.keyCode === 40 || e.key === 'ArrowUp' || e.keyCode === 38 || e.key === 'Enter' || e.keyCode === 13; +} diff --git a/web/findWidget.ts b/web/findWidget.ts index ff0e0974..e2d08856 100644 --- a/web/findWidget.ts +++ b/web/findWidget.ts @@ -38,7 +38,7 @@ class FindWidget { document.body.appendChild(this.widgetElem); this.inputElem = document.getElementById('findInput')!; - let keyupTimeout: NodeJS.Timer | null = null; + let keyupTimeout: number | null = null; this.inputElem.addEventListener('keyup', (e) => { if ((e.keyCode ? e.keyCode === 13 : e.key === 'Enter') && this.text !== '') { if (e.shiftKey) { diff --git a/web/graph.ts b/web/graph.ts index 415dfd2e..9aec0212 100644 --- a/web/graph.ts +++ b/web/graph.ts @@ -358,7 +358,7 @@ class Graph { private tooltipId: number = -1; private tooltipElem: HTMLElement | null = null; - private tooltipTimeout: NodeJS.Timer | null = null; + private tooltipTimeout: number | null = null; private tooltipVertex: HTMLElement | null = null; constructor(id: string, viewElem: HTMLElement, config: GG.GraphConfig, muteConfig: GG.MuteCommitsConfig) { diff --git a/web/main.ts b/web/main.ts index 29c23c82..28ec9b46 100644 --- a/web/main.ts +++ b/web/main.ts @@ -1996,7 +1996,7 @@ class GitGraphView { } private observeViewScroll() { - let active = this.viewElem.scrollTop > 0, timeout: NodeJS.Timer | null = null; + let active = this.viewElem.scrollTop > 0, timeout: number | null = null; this.scrollShadowElem.className = active ? CLASS_ACTIVE : ''; this.viewElem.addEventListener('scroll', () => { const scrollTop = this.viewElem.scrollTop; diff --git a/web/styles/dropdown.css b/web/styles/dropdown.css index cabf69ae..980da870 100644 --- a/web/styles/dropdown.css +++ b/web/styles/dropdown.css @@ -79,10 +79,12 @@ .dropdownOption.selected{ background-color:rgba(128,128,128,0.15); } +.dropdownOption.active, .dropdownOption:hover{ background-color:var(--vscode-menu-selectionBackground, var(--vscode-menu-background)); color:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); } +body.vscode-high-contrast .dropdownOption.active, body.vscode-high-contrast .dropdownOption:hover{ outline:1px dotted var(--vscode-menu-selectionBorder, transparent); outline-offset:-2px; @@ -115,6 +117,7 @@ body.vscode-high-contrast .dropdownOption:hover{ opacity:0.7; } +.dropdownOption.active svg, .dropdownOption:hover svg{ fill:var(--vscode-menu-selectionForeground, var(--vscode-menu-foreground)); opacity:0.85; diff --git a/web/tsconfig.json b/web/tsconfig.json index f0981199..a51d4689 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "lib": [ "es6", + "es2016.array.include", "dom" ], "module": "none", @@ -13,6 +14,7 @@ "outDir": "../media", "removeComments": true, "strict": true, - "target": "es5" + "target": "es5", + "types": [] } -} \ No newline at end of file +} diff --git a/web/utils.ts b/web/utils.ts index a63b64c8..ef2ce412 100644 --- a/web/utils.ts +++ b/web/utils.ts @@ -470,7 +470,7 @@ function observeElemScroll(id: string, initialScrollTop: number, onScroll: (scro const elem = document.getElementById(id); if (elem === null) return; - let timeout: NodeJS.Timer | null = null; + let timeout: number | null = null; elem.scroll(0, initialScrollTop); elem.addEventListener('scroll', () => { const elem = document.getElementById(id);