Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
- [Icons8](https://icons8.com/icon/pack/free-icons/ios11) ([License](https://icons8.com/license))
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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.
*/
Expand Down
133 changes: 132 additions & 1 deletion tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
84 changes: 80 additions & 4 deletions web/dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
}

/**
Expand Down Expand Up @@ -208,6 +213,7 @@ class Dropdown {
public close() {
this.elem.classList.remove('dropdownOpen');
this.dropdownVisible = false;
this.keyboardFocus = null;
this.clearDoubleClickTimeout();
}

Expand Down Expand Up @@ -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;
(<HTMLElement>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 ((<HTMLElement>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(<HTMLElement>this.optionsElem.children[i], CLASS_ACTIVE, this.keyboardFocus === i);
}
if (this.keyboardFocus !== null) {
(<HTMLElement>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.
Expand Down Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion web/findWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class FindWidget {
document.body.appendChild(this.widgetElem);

this.inputElem = <HTMLInputElement>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) {
Expand Down
2 changes: 1 addition & 1 deletion web/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion web/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions web/styles/dropdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading