diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bc96ab..57cfda6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Adds all-new iconography to better match VS Code's new visual style — thanks to John Letey ([@johnletey](https://github.com/johnletey)) and Jon Beaumont-Pike ([@jonbp](https://github.com/jonbp)) for their help!
- Adds an all-new Welcome experience with a simple quick setup of common GitLens features — can be accessed via the _Welcome_ (`gitlens.showWelcomePage`) command
- Adds a new and improved interactive Settings editor experience — can be accessed via the _Open Settings_ (`gitlens.showSettingsPage`) command
+- Adds an all-new commit search experience, via _Git Commands_ (`gitlens.gitCommands`) or _Search Commits_ (`gitlens.showCommitSearch`)
+ - Adds ability to match on more than one search pattern — closes [#410](https://github.com/eamodio/vscode-gitlens/issues/410)
+ - Adds case-\[in\]sensitive matching support — defaults to the new `gitlens.gitCommands.search.matchCase` setting
+ - Adds support for regular expression matching — defaults to the new `gitlens.gitCommands.search.matchRegex` setting
+ - Adds ability to match on all or any patterns when searching commit messages — defaults to the new `gitlens.gitCommands.search.matchAll` setting
- Adds ability to sort branches and tags in quick pick menus and views — closes [#745](https://github.com/eamodio/vscode-gitlens/issues/745)
- Adds a `gitlens.sortBranchesBy` setting to specify how branches are sorted in quick pick menus and views
- Adds a `gitlens.sortTagsBy` setting to specify how tags are sorted in quick pick menus and views
@@ -23,11 +28,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Changed
-- Improves the _Git Commands_ (`gitlens.gitCommands`) experience
+- Dramatically improves the _Git Commands_ (`gitlens.gitCommands`) experience
- Adds a confirmation toggle (look for the checkmark icon in the upper right) to some of the Git commands
- Saves to the new `gitCommands.skipConfirmations` setting to specify which (and when) Git commands will skip the confirmation step
- Adds a new _reset_ Git command to reset current HEAD to a specified commit
- Adds a new _revert_ Git command to revert specific commits
+ - Adds a new _search_ Git command to search for specific commits
- Adds a new _stash_ Git command with sub-commands for _apply_, _drop_, _pop_, and _push_
- Adds a new _Fetch All & Prune_ option to the _fetch_ Git command
- Adds the last fetched on date to the confirmation step of the _fetch_ Git command (when a single repo is selected)
diff --git a/README.md b/README.md
index 2671d36..5574c3d 100644
--- a/README.md
+++ b/README.md
@@ -835,6 +835,10 @@ See also [View Settings](#view-settings- 'Jump to the View settings')
| Name | Description |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `gitlens.gitCommands.closeOnFocusOut` | Specifies whether to dismiss the Git Commands menu when focus is lost (if not, press `ESC` to dismiss) |
+| `gitlens.gitCommands.search.matchAll` | Specifies whether to match all or any commit message search patterns |
+| `gitlens.gitCommands.search.matchCase` | Specifies whether to match commit search patterns with or without regard to casing |
+| `gitlens.gitCommands.search.matchRegex` | Specifies whether to match commit search patterns using regular expressions |
+| `gitlens.gitCommands.search.showInView` | Specifies whether to show the results of a commit search in the _Search Commits_ view or directly within the quick pick menu |
| `gitlens.gitCommands.skipConfirmations` | Specifies which (and when) Git commands will skip the confirmation step, using the format: `git-command-name:(menu|command)` |
### Date & Time Settings [#](#date--time-settings- 'Date & Time Settings')
diff --git a/images/dark/icon-eye-selected.svg b/images/dark/icon-eye-selected.svg
new file mode 100644
index 0000000..cb1a5ed
--- /dev/null
+++ b/images/dark/icon-eye-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/dark/icon-eye.svg b/images/dark/icon-eye.svg
new file mode 100644
index 0000000..84c4d32
--- /dev/null
+++ b/images/dark/icon-eye.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/dark/icon-match-all-selected.svg b/images/dark/icon-match-all-selected.svg
new file mode 100644
index 0000000..0bffb6f
--- /dev/null
+++ b/images/dark/icon-match-all-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/dark/icon-match-all.svg b/images/dark/icon-match-all.svg
new file mode 100644
index 0000000..ca1f799
--- /dev/null
+++ b/images/dark/icon-match-all.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/dark/icon-match-case-selected.svg b/images/dark/icon-match-case-selected.svg
new file mode 100644
index 0000000..6cb0cb0
--- /dev/null
+++ b/images/dark/icon-match-case-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/dark/icon-match-case.svg b/images/dark/icon-match-case.svg
new file mode 100644
index 0000000..7650e85
--- /dev/null
+++ b/images/dark/icon-match-case.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/dark/icon-match-regex-selected.svg b/images/dark/icon-match-regex-selected.svg
new file mode 100644
index 0000000..1d08d1c
--- /dev/null
+++ b/images/dark/icon-match-regex-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/dark/icon-match-regex.svg b/images/dark/icon-match-regex.svg
new file mode 100644
index 0000000..f8cd318
--- /dev/null
+++ b/images/dark/icon-match-regex.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/light/icon-eye-selected.svg b/images/light/icon-eye-selected.svg
new file mode 100644
index 0000000..f72b624
--- /dev/null
+++ b/images/light/icon-eye-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/light/icon-eye.svg b/images/light/icon-eye.svg
new file mode 100644
index 0000000..1280078
--- /dev/null
+++ b/images/light/icon-eye.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/light/icon-match-all-selected.svg b/images/light/icon-match-all-selected.svg
new file mode 100644
index 0000000..7ec0aa7
--- /dev/null
+++ b/images/light/icon-match-all-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/light/icon-match-all.svg b/images/light/icon-match-all.svg
new file mode 100644
index 0000000..8c52136
--- /dev/null
+++ b/images/light/icon-match-all.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/light/icon-match-case-selected.svg b/images/light/icon-match-case-selected.svg
new file mode 100644
index 0000000..f055a42
--- /dev/null
+++ b/images/light/icon-match-case-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/light/icon-match-case.svg b/images/light/icon-match-case.svg
new file mode 100644
index 0000000..b34e49a
--- /dev/null
+++ b/images/light/icon-match-case.svg
@@ -0,0 +1,3 @@
+
diff --git a/images/light/icon-match-regex-selected.svg b/images/light/icon-match-regex-selected.svg
new file mode 100644
index 0000000..506d614
--- /dev/null
+++ b/images/light/icon-match-regex-selected.svg
@@ -0,0 +1,4 @@
+
diff --git a/images/light/icon-match-regex.svg b/images/light/icon-match-regex.svg
new file mode 100644
index 0000000..0e75ac7
--- /dev/null
+++ b/images/light/icon-match-regex.svg
@@ -0,0 +1,3 @@
+
diff --git a/package.json b/package.json
index 8d27b5e..cfcc18a 100644
--- a/package.json
+++ b/package.json
@@ -487,6 +487,30 @@
"markdownDescription": "Specifies whether to dismiss the Git Commands menu when focus is lost (if not, press `ESC` to dismiss)",
"scope": "window"
},
+ "gitlens.gitCommands.search.matchAll": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "Specifies whether to match all or any commit message search patterns",
+ "scope": "window"
+ },
+ "gitlens.gitCommands.search.matchCase": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "Specifies whether to match commit search patterns with or without regard to casing",
+ "scope": "window"
+ },
+ "gitlens.gitCommands.search.matchRegex": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to match commit search patterns using regular expressions",
+ "scope": "window"
+ },
+ "gitlens.gitCommands.search.showInView": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to show the results of a commit search in the _Search Commits_ view or directly within the quick pick menu",
+ "scope": "window"
+ },
"gitlens.gitCommands.skipConfirmations": {
"type": "array",
"default": [
diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts
new file mode 100644
index 0000000..b1dd058
--- /dev/null
+++ b/src/commands/git/search.ts
@@ -0,0 +1,497 @@
+'use strict';
+/* eslint-disable no-loop-func */
+import { QuickInputButton } from 'vscode';
+import { Container } from '../../container';
+import { GitLogCommit, GitService, Repository } from '../../git/gitService';
+import { GlyphChars } from '../../constants';
+import { QuickCommandBase, StepAsyncGenerator, StepSelection, StepState } from '../quickCommand';
+import { RepositoryQuickPickItem } from '../../quickpicks';
+import { Iterables, Mutable, Strings } from '../../system';
+import { Logger } from '../../logger';
+import {
+ CommitQuickPickItem,
+ Directive,
+ DirectiveQuickPickItem,
+ QuickPickItemOfT
+} from '../../quickpicks/gitQuickPicks';
+
+interface State {
+ repo: Repository;
+ search: string;
+ matchAll: boolean;
+ matchCase: boolean;
+ matchRegex: boolean;
+ showInView: boolean;
+}
+
+export interface SearchGitCommandArgs {
+ readonly command: 'search';
+ state?: Partial;
+
+ confirm?: boolean;
+ prefillOnly?: boolean;
+}
+
+const searchOperators = new Set(['', 'author:', 'change:', 'commit:', 'file:']);
+const searchOperatorToTitleMap = new Map([
+ ['', 'Search by Message'],
+ ['author:', 'Search by Author or Committer'],
+ ['change:', 'Search by Changes'],
+ ['commit:', 'Search by Commit ID'],
+ ['file:', 'Search by File']
+]);
+
+export class SearchGitCommand extends QuickCommandBase {
+ constructor(args?: SearchGitCommandArgs) {
+ super('search', 'search', 'Search', {
+ description: 'aka grep, searches for commits'
+ });
+
+ if (args == null || args.state === undefined) return;
+
+ let counter = 0;
+ if (args.state.repo !== undefined) {
+ counter++;
+ }
+
+ if (args.state.search !== undefined && !args.prefillOnly) {
+ counter++;
+ }
+
+ this._initialState = {
+ counter: counter,
+ confirm: args.confirm,
+ ...args.state
+ };
+ }
+
+ get canConfirm(): boolean {
+ return false;
+ }
+
+ isMatch(name: string) {
+ return super.isMatch(name) || name === 'grep';
+ }
+
+ protected async *steps(): StepAsyncGenerator {
+ const state: StepState = this._initialState === undefined ? { counter: 0 } : this._initialState;
+ let oneRepo = false;
+ let pickedCommit: GitLogCommit | undefined;
+
+ const cfg = Container.config.gitCommands.search;
+ if (state.matchAll === undefined) {
+ state.matchAll = cfg.matchAll;
+ }
+ if (state.matchCase === undefined) {
+ state.matchCase = cfg.matchCase;
+ }
+ if (state.matchRegex === undefined) {
+ state.matchRegex = cfg.matchRegex;
+ }
+ if (state.showInView === undefined) {
+ state.showInView = cfg.showInView;
+ }
+
+ while (true) {
+ try {
+ if (state.repo === undefined || state.counter < 1) {
+ const repos = [...(await Container.git.getOrderedRepositories())];
+
+ if (repos.length === 1) {
+ oneRepo = true;
+ state.counter++;
+ state.repo = repos[0];
+ } else {
+ const active = state.repo ? state.repo : await Container.git.getActiveRepository();
+
+ const step = this.createPickStep({
+ multiselect: true,
+ title: this.title,
+ placeholder: 'Choose repositories',
+ items: await Promise.all(
+ repos.map(r =>
+ RepositoryQuickPickItem.create(r, r.id === (active && active.id), {
+ branch: true,
+ fetched: true,
+ status: true
+ })
+ )
+ )
+ });
+ const selection: StepSelection = yield step;
+
+ if (!this.canPickStepMoveNext(step, state, selection)) {
+ break;
+ }
+
+ state.repo = selection[0].item;
+ }
+ }
+
+ if (state.search === undefined || state.counter < 2) {
+ const items: QuickPickItemOfT[] = [
+ {
+ label: `${this.title} by Message`,
+ description: `pattern ${GlyphChars.Dash} use quotes to search for phrases`,
+ item: ''
+ },
+ {
+ label: `${this.title} by Author or Committer`,
+ description: 'author: pattern',
+ item: 'author:'
+ },
+ {
+ label: `${this.title} by Commit ID`,
+ description: 'commit: sha',
+ item: 'commit:'
+ },
+ {
+ label: `${this.title} by Files`,
+ description: 'file: glob',
+ item: 'file:'
+ },
+ {
+ label: `${this.title} by Changes`,
+ description: 'change: pattern',
+ item: 'change:'
+ }
+ ];
+ const titleSuffix = `${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.formattedName}`;
+
+ const matchCaseButton: Mutable = {
+ iconPath: state.matchCase
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-case-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-case-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath('images/dark/icon-match-case.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-match-case.svg') as any
+ },
+ tooltip: 'Match Case'
+ };
+
+ const matchAllButton: Mutable = {
+ iconPath: state.matchAll
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-all-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-all-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath('images/dark/icon-match-all.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-match-all.svg') as any
+ },
+ tooltip: 'Match All'
+ };
+
+ const matchRegexButton: Mutable = {
+ iconPath: state.matchRegex
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-regex-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-regex-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath('images/dark/icon-match-regex.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-match-regex.svg') as any
+ },
+ tooltip: 'Match using Regular Expressions'
+ };
+
+ const showInViewButton: Mutable = {
+ iconPath: state.showInView
+ ? {
+ dark: Container.context.asAbsolutePath('images/dark/icon-eye-selected.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-eye-selected.svg') as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath('images/dark/icon-eye.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-eye.svg') as any
+ },
+ tooltip: 'Show Results in the Search Commits View'
+ };
+
+ const step = this.createPickStep>({
+ title: `${this.title}${titleSuffix}`,
+ placeholder: 'e.g. "Updates dependencies" author:eamodio',
+ matchOnDescription: true,
+ matchOnDetail: true,
+ additionalButtons: [matchCaseButton, matchAllButton, matchRegexButton, showInViewButton],
+ items: items,
+ value: state.search,
+ onDidAccept: (quickpick): boolean => {
+ const pick = quickpick.selectedItems[0];
+ if (!searchOperators.has(pick.item)) return true;
+
+ const value = quickpick.value.trim();
+ if (value.length === 0 || searchOperators.has(value)) {
+ quickpick.value = pick.item;
+ } else {
+ quickpick.value = `${value} ${pick.item}`;
+ }
+
+ void step.onDidChangeValue!(quickpick);
+
+ return false;
+ },
+ onDidClickButton: (quickpick, button) => {
+ if (button === matchCaseButton) {
+ state.matchCase = !state.matchCase;
+ matchCaseButton.iconPath = state.matchCase
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-case-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-case-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-case.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-case.svg'
+ ) as any
+ };
+
+ return;
+ }
+
+ if (button === matchAllButton) {
+ state.matchAll = !state.matchAll;
+ matchAllButton.iconPath = state.matchAll
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-all-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-all-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-all.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-all.svg'
+ ) as any
+ };
+
+ return;
+ }
+
+ if (button === matchRegexButton) {
+ state.matchRegex = !state.matchRegex;
+ matchRegexButton.iconPath = state.matchRegex
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-regex-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-regex-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-match-regex.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-match-regex.svg'
+ ) as any
+ };
+
+ return;
+ }
+
+ if (button === showInViewButton) {
+ state.showInView = !state.showInView;
+ showInViewButton.iconPath = state.showInView
+ ? {
+ dark: Container.context.asAbsolutePath(
+ 'images/dark/icon-eye-selected.svg'
+ ) as any,
+ light: Container.context.asAbsolutePath(
+ 'images/light/icon-eye-selected.svg'
+ ) as any
+ }
+ : {
+ dark: Container.context.asAbsolutePath('images/dark/icon-eye.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-eye.svg') as any
+ };
+ }
+ },
+ onDidChangeValue: (quickpick): boolean => {
+ const operations = GitService.parseSearchOperations(quickpick.value.trim());
+
+ quickpick.title =
+ operations.size === 0 || operations.size > 1
+ ? `${this.title}${titleSuffix}`
+ : `${searchOperatorToTitleMap.get(operations.keys().next().value)!}${titleSuffix}`;
+
+ if (quickpick.value.length === 0) {
+ quickpick.items = items;
+ } else {
+ quickpick.items = [
+ {
+ label: 'Search for commits matching',
+ description: quickpick.value,
+ item: quickpick.value
+ }
+ ];
+ }
+
+ return true;
+ }
+ });
+ const selection: StepSelection = yield step;
+
+ if (!this.canPickStepMoveNext(step, state, selection)) {
+ if (oneRepo) {
+ break;
+ }
+
+ continue;
+ }
+
+ state.search = selection[0].item.trim();
+ }
+
+ const resultsPromise = Container.git.getLogForSearch(state.repo.path, {
+ pattern: state.search,
+ matchAll: state.matchAll,
+ matchCase: state.matchCase,
+ matchRegex: state.matchRegex
+ });
+
+ if (state.showInView) {
+ void Container.searchView.search(
+ state.repo.path,
+ {
+ pattern: state.search,
+ matchAll: state.matchAll,
+ matchCase: state.matchCase,
+ matchRegex: state.matchRegex
+ },
+ {
+ label: { label: `commits matching: ${state.search}` }
+ },
+ resultsPromise
+ );
+
+ break;
+ }
+
+ const results = await resultsPromise;
+
+ const openInViewButton: QuickInputButton = {
+ iconPath: {
+ dark: Container.context.asAbsolutePath('images/dark/icon-link.svg') as any,
+ light: Container.context.asAbsolutePath('images/light/icon-link.svg') as any
+ },
+ tooltip: 'Open Results in the Search Commits View'
+ };
+
+ const step = this.createPickStep({
+ title: `${this.title}${Strings.pad(GlyphChars.Dot, 2, 2)}${state.repo.formattedName}`,
+ placeholder:
+ results === undefined
+ ? `No results for commits matching: ${state.search}`
+ : `${Strings.pluralize('result', results.count, {
+ number: results.truncated ? `${results.count}+` : undefined
+ })} for commits matching: ${state.search}`,
+ matchOnDescription: true,
+ matchOnDetail: true,
+ items:
+ results === undefined
+ ? [
+ DirectiveQuickPickItem.create(Directive.Back, true),
+ DirectiveQuickPickItem.create(Directive.Cancel)
+ ]
+ : [
+ ...Iterables.map(results.commits.values(), commit =>
+ CommitQuickPickItem.create(
+ commit,
+ commit.ref === (pickedCommit && pickedCommit.ref),
+ { compact: true, icon: true }
+ )
+ )
+ ],
+ additionalButtons: [openInViewButton],
+ onDidClickButton: (quickpick, button) => {
+ if (button !== openInViewButton) return;
+
+ void Container.searchView.search(
+ state.repo!.path,
+ {
+ pattern: state.search!,
+ matchAll: state.matchAll,
+ matchCase: state.matchCase,
+ matchRegex: state.matchRegex
+ },
+ {
+ label: { label: `commits matching: ${state.search}` }
+ },
+ results
+ );
+ }
+ });
+ const selection: StepSelection = yield step;
+
+ if (!this.canPickStepMoveNext(step, state, selection)) {
+ continue;
+ }
+
+ state.counter--;
+ pickedCommit = selection[0].item;
+
+ void Container.searchView.search(
+ pickedCommit.repoPath,
+ { pattern: `commit:${pickedCommit.sha}` },
+ {
+ label: { label: `commits matching: commit:${pickedCommit.shortSha}` }
+ }
+ );
+
+ // const gitCommandArgs: GitCommandsCommandArgs = {
+ // command: 'search',
+ // state: { ...state }
+ // };
+
+ // const commandArgs: ShowQuickCommitDetailsCommandArgs = {
+ // sha: commit.sha,
+ // commit: commit,
+ // goBackCommand: new CommandQuickPickItem(
+ // {
+ // label: 'Back',
+ // description: ''
+ // },
+ // Commands.GitCommands,
+ // [gitCommandArgs]
+ // )
+ // };
+
+ // void commands.executeCommand(Commands.ShowQuickCommitDetails, commit.toGitUri(), commandArgs);
+
+ // break;
+ } catch (ex) {
+ Logger.error(ex, this.title);
+
+ throw ex;
+ }
+ }
+
+ return undefined;
+ }
+}
diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts
index 58beee2..553424a 100644
--- a/src/commands/gitCommands.ts
+++ b/src/commands/gitCommands.ts
@@ -19,6 +19,7 @@ import { PushGitCommand, PushGitCommandArgs } from './git/push';
import { RebaseGitCommand, RebaseGitCommandArgs } from './git/rebase';
import { ResetGitCommand, ResetGitCommandArgs } from './git/reset';
import { RevertGitCommand, RevertGitCommandArgs } from './git/revert';
+import { SearchGitCommand, SearchGitCommandArgs } from './git/search';
import { StashGitCommand, StashGitCommandArgs } from './git/stash';
import { SwitchGitCommand, SwitchGitCommandArgs } from './git/switch';
import { Container } from '../container';
@@ -35,59 +36,10 @@ export type GitCommandsCommandArgs =
| RebaseGitCommandArgs
| ResetGitCommandArgs
| RevertGitCommandArgs
+ | SearchGitCommandArgs
| StashGitCommandArgs
| SwitchGitCommandArgs;
-class PickCommandStep implements QuickPickStep {
- readonly buttons = [];
- readonly items: QuickCommandBase[];
- readonly matchOnDescription = true;
- readonly placeholder = 'Choose a git command';
- readonly title = 'GitLens';
-
- constructor(args?: GitCommandsCommandArgs) {
- this.items = [
- new CherryPickGitCommand(args && args.command === 'cherry-pick' ? args : undefined),
- new MergeGitCommand(args && args.command === 'merge' ? args : undefined),
- new FetchGitCommand(args && args.command === 'fetch' ? args : undefined),
- new PullGitCommand(args && args.command === 'pull' ? args : undefined),
- new PushGitCommand(args && args.command === 'push' ? args : undefined),
- new RebaseGitCommand(args && args.command === 'rebase' ? args : undefined),
- new ResetGitCommand(args && args.command === 'reset' ? args : undefined),
- new RevertGitCommand(args && args.command === 'revert' ? args : undefined),
- new StashGitCommand(args && args.command === 'stash' ? args : undefined),
- new SwitchGitCommand(args && args.command === 'switch' ? args : undefined)
- ];
- }
-
- private _active: QuickCommandBase | undefined;
- get command(): QuickCommandBase | undefined {
- return this._active;
- }
-
- find(commandName: string, fuzzy: boolean = false) {
- if (fuzzy) {
- const cmd = commandName.toLowerCase();
- return this.items.find(c => c.isMatch(cmd));
- }
-
- return this.items.find(c => c.key === commandName);
- }
-
- setCommand(value: QuickCommandBase | undefined, reason: 'menu' | 'command'): void {
- if (this._active !== undefined) {
- this._active.picked = false;
- }
-
- this._active = value;
-
- if (this._active !== undefined) {
- this._active.picked = true;
- this._active.pickedVia = reason;
- }
- }
-}
-
@command()
export class GitCommandsCommand extends Command {
private readonly GitQuickInputButtons = class {
@@ -187,8 +139,7 @@ export class GitCommandsCommand extends Command {
return;
}
- const step = commandsStep.command && commandsStep.command.value;
- if (step !== undefined && isQuickInputStep(step) && step.onDidClickButton !== undefined) {
+ if (step.onDidClickButton !== undefined) {
step.onDidClickButton(input, e);
input.buttons = this.getButtons(step, commandsStep.command);
}
@@ -266,13 +217,17 @@ export class GitCommandsCommand extends Command {
return;
}
- const step = commandsStep.command && commandsStep.command.value;
- if (step !== undefined && isQuickPickStep(step) && step.onDidClickButton !== undefined) {
+ if (step.onDidClickButton !== undefined) {
step.onDidClickButton(quickpick, e);
quickpick.buttons = this.getButtons(step, commandsStep.command);
}
}),
quickpick.onDidChangeValue(async e => {
+ if (step.onDidChangeValue !== undefined) {
+ const cancel = await step.onDidChangeValue(quickpick);
+ if (cancel) return;
+ }
+
if (!overrideItems) {
if (quickpick.canSelectMany && e === ' ') {
quickpick.value = '';
@@ -294,9 +249,6 @@ export class GitCommandsCommand extends Command {
commandsStep.setCommand(command, this._pickedVia);
} else {
- const step = commandsStep.command.value;
- if (step === undefined || !isQuickPickStep(step)) return;
-
const cmd = quickpick.value.trim().toLowerCase();
const item = step.items.find(
i => i.label.replace(sanitizeLabel, '').toLowerCase() === cmd
@@ -319,10 +271,7 @@ export class GitCommandsCommand extends Command {
e.trim().length !== 0 &&
(overrideItems || quickpick.activeItems.length === 0)
) {
- const step = commandsStep.command.value;
- if (step === undefined || !isQuickPickStep(step) || step.onValidateValue === undefined) {
- return;
- }
+ if (step.onValidateValue === undefined) return;
overrideItems = await step.onValidateValue(quickpick, e.trim(), step.items);
} else {
@@ -362,10 +311,7 @@ export class GitCommandsCommand extends Command {
const value = quickpick.value.trim();
if (value.length === 0) return;
- const step = commandsStep.command && commandsStep.command.value;
- if (step === undefined || !isQuickPickStep(step) || step.onDidAccept === undefined) {
- return;
- }
+ if (step.onDidAccept === undefined) return;
quickpick.busy = true;
@@ -406,6 +352,20 @@ export class GitCommandsCommand extends Command {
commandsStep.setCommand(command, this._pickedVia);
}
+ if (!quickpick.canSelectMany) {
+ if (step.onDidAccept !== undefined) {
+ quickpick.busy = true;
+
+ const next = await step.onDidAccept(quickpick);
+
+ quickpick.busy = false;
+
+ if (!next) {
+ return;
+ }
+ }
+ }
+
resolve(await this.nextStep(quickpick, commandsStep.command!, items as QuickPickItem[]));
})
);
@@ -439,6 +399,11 @@ export class GitCommandsCommand extends Command {
}
quickpick.show();
+
+ // Call the step's change directly, because the quickpick doesn't seem to properly
+ if (step.value !== undefined && step.onDidChangeValue !== undefined) {
+ step.onDidChangeValue(quickpick);
+ }
});
} finally {
quickpick.dispose();
@@ -508,3 +473,54 @@ export class GitCommandsCommand extends Command {
input.buttons = this.getButtons(command.value, command);
}
}
+
+class PickCommandStep implements QuickPickStep {
+ readonly buttons = [];
+ readonly items: QuickCommandBase[];
+ readonly matchOnDescription = true;
+ readonly placeholder = 'Choose a git command';
+ readonly title = 'GitLens';
+
+ constructor(args?: GitCommandsCommandArgs) {
+ this.items = [
+ new CherryPickGitCommand(args && args.command === 'cherry-pick' ? args : undefined),
+ new MergeGitCommand(args && args.command === 'merge' ? args : undefined),
+ new FetchGitCommand(args && args.command === 'fetch' ? args : undefined),
+ new PullGitCommand(args && args.command === 'pull' ? args : undefined),
+ new PushGitCommand(args && args.command === 'push' ? args : undefined),
+ new RebaseGitCommand(args && args.command === 'rebase' ? args : undefined),
+ new ResetGitCommand(args && args.command === 'reset' ? args : undefined),
+ new RevertGitCommand(args && args.command === 'revert' ? args : undefined),
+ new SearchGitCommand(args && args.command === 'search' ? args : undefined),
+ new StashGitCommand(args && args.command === 'stash' ? args : undefined),
+ new SwitchGitCommand(args && args.command === 'switch' ? args : undefined)
+ ];
+ }
+
+ private _active: QuickCommandBase | undefined;
+ get command(): QuickCommandBase | undefined {
+ return this._active;
+ }
+
+ find(commandName: string, fuzzy: boolean = false) {
+ if (fuzzy) {
+ const cmd = commandName.toLowerCase();
+ return this.items.find(c => c.isMatch(cmd));
+ }
+
+ return this.items.find(c => c.key === commandName);
+ }
+
+ setCommand(value: QuickCommandBase | undefined, reason: 'menu' | 'command'): void {
+ if (this._active !== undefined) {
+ this._active.picked = false;
+ }
+
+ this._active = value;
+
+ if (this._active !== undefined) {
+ this._active.picked = true;
+ this._active.pickedVia = reason;
+ }
+ }
+}
diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts
index bde2a31..103382b 100644
--- a/src/commands/quickCommand.ts
+++ b/src/commands/quickCommand.ts
@@ -38,9 +38,10 @@ export interface QuickPickStep {
title?: string;
value?: string;
- onDidAccept?(quickpick: QuickPick): Promise;
+ onDidAccept?(quickpick: QuickPick): boolean | Promise;
+ onDidChangeValue?(quickpick: QuickPick): boolean | Promise;
onDidClickButton?(quickpick: QuickPick, button: QuickInputButton): void;
- onValidateValue?(quickpick: QuickPick, value: string, items: T[]): Promise;
+ onValidateValue?(quickpick: QuickPick, value: string, items: T[]): boolean | Promise;
validate?(selection: T[]): boolean;
}
diff --git a/src/commands/searchCommits.ts b/src/commands/searchCommits.ts
index ff6e915..2db2a65 100644
--- a/src/commands/searchCommits.ts
+++ b/src/commands/searchCommits.ts
@@ -1,53 +1,26 @@
'use strict';
-import { commands, InputBoxOptions, TextEditor, Uri, window } from 'vscode';
-import { GlyphChars } from '../constants';
-import { Container } from '../container';
-import { GitRepoSearchBy, GitService } from '../git/gitService';
-import { Logger } from '../logger';
-import { Messages } from '../messages';
-import { CommandQuickPickItem, CommitsQuickPick, ShowCommitSearchResultsInViewQuickPickItem } from '../quickpicks';
-import { Iterables } from '../system';
+import { commands } from 'vscode';
import { SearchResultsCommitsNode } from '../views/nodes';
-import {
- ActiveEditorCachedCommand,
- command,
- CommandContext,
- Commands,
- getCommandUri,
- getRepoPathOrPrompt,
- isCommandViewContextWithRepo
-} from './common';
-import { ShowQuickCommitDetailsCommandArgs } from './showQuickCommitDetails';
-
-const searchByRegex = /^([@~:#])/;
-const symbolToSearchByMap = new Map([
- ['@', GitRepoSearchBy.Author],
- ['~', GitRepoSearchBy.Changes],
- [':', GitRepoSearchBy.Files],
- ['#', GitRepoSearchBy.Sha]
-]);
-
-const searchByToSymbolMap = new Map([
- [GitRepoSearchBy.Author, '@'],
- [GitRepoSearchBy.Changes, '~'],
- [GitRepoSearchBy.Files, ':'],
- [GitRepoSearchBy.Sha, '#']
-]);
+import { Container } from '../container';
+import { Command, command, CommandContext, Commands, isCommandViewContextWithRepo } from './common';
+import { GitCommandsCommandArgs } from '../commands';
export interface SearchCommitsCommandArgs {
- search?: string;
- searchBy?: GitRepoSearchBy;
- prefillOnly?: boolean;
+ search?: {
+ pattern?: string;
+ matchAll?: boolean;
+ matchCase?: boolean;
+ matchRegex?: boolean;
+ };
repoPath?: string;
- showInView?: boolean;
- goBackCommand?: CommandQuickPickItem;
+ prefillOnly?: boolean;
+
+ showInView?: boolean;
}
@command()
-export class SearchCommitsCommand extends ActiveEditorCachedCommand {
- private _lastSearch: string | undefined;
-
+export class SearchCommitsCommand extends Command {
constructor() {
super([Commands.SearchCommits, Commands.SearchCommitsInView]);
}
@@ -58,191 +31,40 @@ export class SearchCommitsCommand extends ActiveEditorCachedCommand {
args.showInView = true;
if (context.node instanceof SearchResultsCommitsNode) {
+ args.repoPath = context.node.repoPath;
args.search = context.node.search;
- args.searchBy = context.node.searchBy;
args.prefillOnly = true;
}
if (isCommandViewContextWithRepo(context)) {
args.repoPath = context.node.repo.path;
- return this.execute(context.editor, context.node.uri, args);
}
} else if (context.command === Commands.SearchCommitsInView) {
args = { ...args };
args.showInView = true;
- } else {
- // TODO: Add a user setting (default to view?)
}
- return this.execute(context.editor, context.uri, args);
+ return this.execute(args);
}
- async execute(editor?: TextEditor, uri?: Uri, args: SearchCommitsCommandArgs = {}) {
- uri = getCommandUri(uri, editor);
-
- const repoPath =
- args.repoPath ||
- (await getRepoPathOrPrompt(
- `Search for commits in which repository${GlyphChars.Ellipsis}`,
- args.goBackCommand
- ));
- if (!repoPath) return undefined;
-
- args = { ...args };
- const originalArgs = { ...args };
-
- if (args.prefillOnly && args.search && args.searchBy) {
- args.search = `${searchByToSymbolMap.get(args.searchBy) || ''}${args.search}`;
- args.searchBy = undefined;
- }
-
- if (!args.search || args.searchBy == null) {
- let selection: [number, number] | undefined;
- if (!args.search) {
- if (args.searchBy != null) {
- args.search = searchByToSymbolMap.get(args.searchBy);
- selection = [1, 1];
- } else {
- args.search = this._lastSearch;
- }
- }
-
- if (args.showInView) {
- await Container.searchView.show();
- }
-
- const repo = await Container.git.getRepository(repoPath);
-
- const opts: InputBoxOptions = {
- value: args.search,
- prompt: 'Please enter a search string',
- placeHolder: `Search${
- repo === undefined ? '' : ` ${repo.formattedName}`
- } for commits by message, author (@), files (:), commit id (#), or changes (~)`,
- valueSelection: selection
- };
- args.search = await window.showInputBox(opts);
- if (args.search === undefined) {
- return args.goBackCommand === undefined ? undefined : args.goBackCommand.execute();
- }
-
- this._lastSearch = originalArgs.search = args.search;
-
- const match = searchByRegex.exec(args.search);
- if (match && match[1]) {
- args.searchBy = symbolToSearchByMap.get(match[1]);
- args.search = args.search.substring(args.search[1] === ' ' ? 2 : 1);
- } else if (GitService.isShaLike(args.search)) {
- args.searchBy = GitRepoSearchBy.Sha;
- } else {
- args.searchBy = GitRepoSearchBy.Message;
- }
- }
-
- if (args.searchBy === undefined) {
- args.searchBy = GitRepoSearchBy.Message;
- }
-
- let searchLabel: string | undefined = undefined;
- switch (args.searchBy) {
- case GitRepoSearchBy.Author:
- searchLabel = `commits with an author matching '${args.search}'`;
- break;
-
- case GitRepoSearchBy.Changes:
- searchLabel = `commits with changes matching '${args.search}'`;
- break;
-
- case GitRepoSearchBy.Files:
- searchLabel = `commits with files matching '${args.search}'`;
- break;
-
- case GitRepoSearchBy.Message:
- searchLabel = args.search ? `commits with a message matching '${args.search}'` : 'all commits';
- break;
-
- case GitRepoSearchBy.Sha:
- searchLabel = `commits with an id matching '${args.search}'`;
- break;
+ async execute(args: SearchCommitsCommandArgs = {}) {
+ let repo;
+ if (args.repoPath !== undefined) {
+ repo = await Container.git.getRepository(args.repoPath);
}
- if (args.showInView) {
- void Container.searchView.search(repoPath, args.search, args.searchBy, {
- label: { label: searchLabel! }
- });
-
- return undefined;
- }
-
- const progressCancellation = CommitsQuickPick.showProgress(searchLabel!);
- try {
- const log = await Container.git.getLogForSearch(repoPath, args.search, args.searchBy);
-
- if (progressCancellation.token.isCancellationRequested) return undefined;
-
- let goBackCommand: CommandQuickPickItem | undefined =
- args.goBackCommand ||
- new CommandQuickPickItem(
- {
- label: `go back ${GlyphChars.ArrowBack}`,
- description: 'to commit search'
- },
- Commands.SearchCommits,
- [uri, originalArgs]
- );
-
- let commit;
- if (args.searchBy !== GitRepoSearchBy.Sha || log === undefined || log.count !== 1) {
- const pick = await CommitsQuickPick.show(log, searchLabel!, progressCancellation, {
- goBackCommand: goBackCommand,
- showAllCommand:
- log !== undefined && log.truncated
- ? new CommandQuickPickItem(
- {
- label: '$(sync) Show All Commits',
- description: 'this may take a while'
- },
- Commands.SearchCommits,
- [uri, { ...args, maxCount: 0, goBackCommand: goBackCommand }]
- )
- : undefined,
- showInViewCommand:
- log !== undefined
- ? new ShowCommitSearchResultsInViewQuickPickItem(args.search, args.searchBy, log, {
- label: searchLabel!
- })
- : undefined
- });
- if (pick === undefined) return undefined;
-
- if (pick instanceof CommandQuickPickItem) return pick.execute();
-
- commit = pick.item;
- goBackCommand = undefined;
- } else {
- commit = Iterables.first(log.commits.values());
+ const gitCommandArgs: GitCommandsCommandArgs = {
+ command: 'search',
+ prefillOnly: args.prefillOnly,
+ state: {
+ repo: repo,
+ search: args.search && args.search.pattern,
+ matchAll: args.search && args.search.matchAll,
+ matchCase: args.search && args.search.matchCase,
+ matchRegex: args.search && args.search.matchRegex,
+ showInView: args.showInView
}
-
- const commandArgs: ShowQuickCommitDetailsCommandArgs = {
- sha: commit.sha,
- commit: commit,
- goBackCommand:
- goBackCommand ||
- new CommandQuickPickItem(
- {
- label: `go back ${GlyphChars.ArrowBack}`,
- description: `to search for ${searchLabel}`
- },
- Commands.SearchCommits,
- [uri, args]
- )
- };
- return commands.executeCommand(Commands.ShowQuickCommitDetails, commit.toGitUri(), commandArgs);
- } catch (ex) {
- Logger.error(ex, 'ShowCommitSearchCommand');
- return Messages.showGenericErrorMessage('Unable to find commits');
- } finally {
- progressCancellation.cancel();
- }
+ };
+ return commands.executeCommand(Commands.GitCommands, gitCommandArgs);
}
}
diff --git a/src/commands/showQuickCommitDetails.ts b/src/commands/showQuickCommitDetails.ts
index 379c068..b83c71d 100644
--- a/src/commands/showQuickCommitDetails.ts
+++ b/src/commands/showQuickCommitDetails.ts
@@ -2,7 +2,7 @@
import { commands, TextEditor, Uri } from 'vscode';
import { GlyphChars } from '../constants';
import { Container } from '../container';
-import { GitCommit, GitLog, GitLogCommit, GitRepoSearchBy, GitUri } from '../git/gitService';
+import { GitCommit, GitLog, GitLogCommit, GitUri } from '../git/gitService';
import { Logger } from '../logger';
import { Messages } from '../messages';
import { CommandQuickPickItem, CommitQuickPick, CommitWithFileStatusQuickPickItem } from '../quickpicks';
@@ -120,9 +120,13 @@ export class ShowQuickCommitDetailsCommand extends ActiveEditorCachedCommand {
}
if (args.showInView) {
- void (await Container.searchView.search(repoPath!, args.commit.sha, GitRepoSearchBy.Sha, {
- label: { label: `commits with an id matching '${args.commit.shortSha}'` }
- }));
+ void (await Container.searchView.search(
+ repoPath!,
+ { pattern: `commit: ${args.commit.sha}` },
+ {
+ label: { label: `commits matching: commit:${args.commit.shortSha}` }
+ }
+ ));
return undefined;
}
diff --git a/src/config.ts b/src/config.ts
index 3eb40ae..dbaa613 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -34,6 +34,12 @@ export interface Config {
defaultGravatarsStyle: GravatarDefaultStyle;
gitCommands: {
closeOnFocusOut: boolean;
+ search: {
+ matchAll: boolean;
+ matchCase: boolean;
+ matchRegex: boolean;
+ showInView: boolean;
+ };
skipConfirmations: string[];
};
heatmap: {
diff --git a/src/git/gitService.ts b/src/git/gitService.ts
index d33d5d1..a7c0cea 100644
--- a/src/git/gitService.ts
+++ b/src/git/gitService.ts
@@ -86,14 +86,9 @@ const RepoSearchWarnings = {
const userConfigRegex = /^user\.(name|email) (.*)$/gm;
const mappedAuthorRegex = /(.+)\s<(.+)>/;
-
-export enum GitRepoSearchBy {
- Author = 'author',
- Changes = 'changes',
- Files = 'files',
- Message = 'message',
- Sha = 'sha'
-}
+const searchMessageOperationRegex = /(?=(.*?)\s?(?:(?:author:|commit:|file:|change:)|$))/;
+const searchMessageValuesRegex = /(?:^|\b)\s?(?:\s?"([^"]*)(?:"|$)|([^"\s]*))/g;
+const searchOperationRegex = /((?:author|commit|file|change):)\s?(?=(.*?)\s?(?:(?:author:|commit:|file:|change:)|$))/g;
const emptyPromise: Promise = Promise.resolve(undefined);
const reflogCommands = ['merge', 'pull'];
@@ -1429,68 +1424,86 @@ export class GitService implements Disposable {
@log()
async getLogForSearch(
repoPath: string,
- search: string,
- searchBy: GitRepoSearchBy,
+ search: {
+ pattern: string;
+ matchAll?: boolean;
+ matchCase?: boolean;
+ matchRegex?: boolean;
+ },
options: { maxCount?: number } = {}
): Promise {
- let maxCount = options.maxCount == null ? Container.config.advanced.maxSearchItems || 0 : options.maxCount;
- const similarityThreshold = Container.config.advanced.similarityThreshold;
-
- let searchArgs: string[] | undefined = undefined;
- switch (searchBy) {
- case GitRepoSearchBy.Author:
- searchArgs = [
- '-m',
- `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
- '--all',
- '--full-history',
- '-E',
- '-i',
- `--author=${search}`
- ];
- break;
- case GitRepoSearchBy.Changes:
- searchArgs = [
- `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
- '--all',
- '--full-history',
- '-E',
- '-i',
- `-G${search}`
- ];
- break;
- case GitRepoSearchBy.Files:
- searchArgs = [
- `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
- '--all',
- '--full-history',
- '-E',
- '-i',
- '--',
- `${search}`
- ];
- break;
- case GitRepoSearchBy.Message:
- searchArgs = [
- '-m',
- `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
- '--all',
- '--full-history',
- '-E',
- '-i'
- ];
- if (search) {
- searchArgs.push(`--grep=${search}`);
- }
- break;
- case GitRepoSearchBy.Sha:
- searchArgs = ['-m', `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, search];
- maxCount = 1;
- break;
- }
+ search = { matchAll: false, matchCase: false, matchRegex: true, ...search };
try {
- const data = await Git.log__search(repoPath, searchArgs, { maxCount: maxCount });
+ let maxCount = options.maxCount == null ? Container.config.advanced.maxSearchItems || 0 : options.maxCount;
+ const similarityThreshold = Container.config.advanced.similarityThreshold;
+
+ const operations = GitService.parseSearchOperations(search.pattern);
+
+ const searchArgs = new Set();
+ const files: string[] = [];
+
+ let op;
+ let values = operations.get('commit:');
+ if (values !== undefined) {
+ searchArgs.add('-m');
+ searchArgs.add(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
+ searchArgs.add(values[0]);
+ maxCount = 1;
+ } else {
+ searchArgs.add(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
+ searchArgs.add('--all');
+ searchArgs.add('--full-history');
+ searchArgs.add(search.matchRegex ? '--extended-regexp' : '--fixed-strings');
+ if (!search.matchCase) {
+ searchArgs.add('--regexp-ignore-case');
+ }
+
+ for ([op, values] of operations.entries()) {
+ switch (op) {
+ case 'author:':
+ searchArgs.add('-m');
+ for (const value of values) {
+ searchArgs.add(`--author=${value}`);
+ searchArgs.add(`--committer=${value}`);
+ }
+
+ break;
+
+ case 'change:':
+ for (const value of values) {
+ searchArgs.add(`-G=${value}`);
+ }
+
+ break;
+
+ case 'file:':
+ for (const value of values) {
+ files.push(value);
+ }
+
+ break;
+
+ case '':
+ searchArgs.add('-m');
+ if (search.matchAll) {
+ searchArgs.add('--all-match');
+ }
+ for (const value of values) {
+ searchArgs.add(`--grep=${value}`);
+ }
+
+ break;
+ }
+ }
+ }
+
+ const args = [...searchArgs.values(), '--'];
+ if (files.length !== 0) {
+ args.push(...files);
+ }
+
+ const data = await Git.log__search(repoPath, args, { maxCount: maxCount });
const log = GitLogParser.parse(
data,
GitCommitType.Log,
@@ -1504,9 +1517,8 @@ export class GitService implements Disposable {
);
if (log !== undefined) {
- const opts = { ...options };
log.query = (maxCount: number | undefined) =>
- this.getLogForSearch(repoPath, search, searchBy, { ...opts, maxCount: maxCount });
+ this.getLogForSearch(repoPath, search, { maxCount: maxCount });
}
return log;
@@ -2808,4 +2820,56 @@ export class GitService implements Disposable {
return Git.shortenSha(ref, options);
}
+
+ static parseSearchOperations(search: string): Map {
+ const operations = new Map();
+
+ let op;
+ let value;
+
+ let match = searchMessageOperationRegex.exec(search);
+ if (match != null && match[1] !== '') {
+ const messageSearch = match[1];
+
+ let quoted;
+ let unquoted;
+ do {
+ match = searchMessageValuesRegex.exec(messageSearch);
+ if (match == null) break;
+
+ [, quoted, unquoted] = match;
+ if (!quoted && !unquoted) {
+ searchMessageValuesRegex.lastIndex = 0;
+ break;
+ }
+
+ let values = operations.get('');
+ if (values === undefined) {
+ values = [quoted || unquoted];
+ operations.set('', values);
+ } else {
+ values.push(quoted || unquoted);
+ }
+ } while (match != null);
+ }
+
+ do {
+ match = searchOperationRegex.exec(search);
+ if (match == null) break;
+
+ [, op, value] = match;
+
+ if (op !== undefined) {
+ let values = operations.get(op);
+ if (values === undefined) {
+ values = [value];
+ operations.set(op, values);
+ } else {
+ values.push(value);
+ }
+ }
+ } while (match != null);
+
+ return operations;
+ }
}
diff --git a/src/quickpicks/commonQuickPicks.ts b/src/quickpicks/commonQuickPicks.ts
index e80d957..a0d8df3 100644
--- a/src/quickpicks/commonQuickPicks.ts
+++ b/src/quickpicks/commonQuickPicks.ts
@@ -3,7 +3,7 @@ import { CancellationTokenSource, commands, QuickPickItem, window } from 'vscode
import { Commands } from '../commands';
import { configuration } from '../configuration';
import { Container } from '../container';
-import { GitLog, GitLogCommit, GitRepoSearchBy, GitUri } from '../git/gitService';
+import { GitLog, GitLogCommit, GitUri } from '../git/gitService';
import { KeyMapping, Keys } from '../keyboard';
import { ReferencesQuickPick, ReferencesQuickPickItem } from './referencesQuickPick';
@@ -109,16 +109,19 @@ export class ShowCommitInViewQuickPickItem extends CommandQuickPickItem {
}
async execute(): Promise<{} | undefined> {
- return void (await Container.searchView.search(this.commit.repoPath, this.commit.sha, GitRepoSearchBy.Sha, {
- label: { label: `commits with an id matching '${this.commit.shortSha}'` }
- }));
+ return void (await Container.searchView.search(
+ this.commit.repoPath,
+ { pattern: `commit:${this.commit.sha}` },
+ {
+ label: { label: `commits matching: commit:${this.commit.shortSha}` }
+ }
+ ));
}
}
export class ShowCommitSearchResultsInViewQuickPickItem extends CommandQuickPickItem {
constructor(
- public readonly search: string,
- public readonly searchBy: GitRepoSearchBy,
+ public readonly search: { pattern: string; matchAll?: boolean; matchCase?: boolean; matchRegex?: boolean },
public readonly results: GitLog,
public readonly resultsLabel: string | { label: string; resultsType?: { singular: string; plural: string } },
item: QuickPickItem = {
@@ -130,7 +133,7 @@ export class ShowCommitSearchResultsInViewQuickPickItem extends CommandQuickPick
}
execute(): Promise<{} | undefined> {
- Container.searchView.showSearchResults(this.results.repoPath, this.search, this.searchBy, this.results, {
+ Container.searchView.showSearchResults(this.results.repoPath, this.search, this.results, {
label: this.resultsLabel
});
return Promise.resolve(undefined);
diff --git a/src/system.ts b/src/system.ts
index b535929..9c81131 100644
--- a/src/system.ts
+++ b/src/system.ts
@@ -1,5 +1,8 @@
'use strict';
+export type PartialDeep = T extends object ? { [K in keyof T]?: PartialDeep } : T;
+export type PickPartialDeep = Omit, K> & { [P in K]?: Partial };
+
export type Mutable = { -readonly [P in keyof T]-?: T[P] };
export type PickMutable = Omit & { -readonly [P in K]: T[P] };
diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts
index 98e7ba8..06110bd 100644
--- a/src/views/nodes/commitNode.ts
+++ b/src/views/nodes/commitNode.ts
@@ -18,7 +18,8 @@ export class CommitNode extends ViewRefNode {
parent: ViewNode,
public readonly commit: GitLogCommit,
public readonly branch?: GitBranch,
- private readonly getBranchAndTagTips?: (sha: string) => string | undefined
+ private readonly getBranchAndTagTips?: (sha: string) => string | undefined,
+ private readonly _options: { expand?: boolean } = {}
) {
super(commit.toGitUri(), view, parent);
}
@@ -62,7 +63,10 @@ export class CommitNode extends ViewRefNode {
truncateMessageAtNewLine: true
});
- const item = new TreeItem(label, TreeItemCollapsibleState.Collapsed);
+ const item = new TreeItem(
+ label,
+ this._options.expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed
+ );
item.contextValue = ResourceType.Commit;
if (this.branch !== undefined && this.branch.current) {
item.contextValue += '+current';
diff --git a/src/views/nodes/helpers.ts b/src/views/nodes/helpers.ts
index 2b5e703..8411cca 100644
--- a/src/views/nodes/helpers.ts
+++ b/src/views/nodes/helpers.ts
@@ -14,9 +14,10 @@ const markers: [number, string][] = [
export function* insertDateMarkers(
iterable: Iterable,
parent: ViewNode,
- skip?: number
+ skip?: number,
+ { show }: { show: boolean } = { show: true }
): Iterable {
- if (!parent.view.config.showRelativeDateMarkers) {
+ if (!parent.view.config.showRelativeDateMarkers || !show) {
return yield* iterable;
}
diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts
index 5cdd9ba..b845540 100644
--- a/src/views/nodes/resultsCommitsNode.ts
+++ b/src/views/nodes/resultsCommitsNode.ts
@@ -44,14 +44,18 @@ export class ResultsCommitsNode extends ViewNode implements Pagea
const { log } = await this.getCommitsQueryResults();
if (log === undefined) return [];
+ const options = { expand: this._options.expand && log.count === 1 };
+
const getBranchAndTagTips = await Container.git.getBranchesAndTagsTipsFn(this.uri.repoPath);
const children = [
...insertDateMarkers(
Iterables.map(
log.commits.values(),
- c => new CommitNode(this.view, this, c, undefined, getBranchAndTagTips)
+ c => new CommitNode(this.view, this, c, undefined, getBranchAndTagTips, options)
),
- this
+ this,
+ undefined,
+ { show: log.count > 1 }
)
];
@@ -78,7 +82,7 @@ export class ResultsCommitsNode extends ViewNode implements Pagea
state =
log == null || log.count === 0
? TreeItemCollapsibleState.None
- : this._options.expand
+ : this._options.expand || log.count === 1
? TreeItemCollapsibleState.Expanded
: TreeItemCollapsibleState.Collapsed;
} catch (ex) {
diff --git a/src/views/nodes/searchNode.ts b/src/views/nodes/searchNode.ts
index e7e4998..14e5efd 100644
--- a/src/views/nodes/searchNode.ts
+++ b/src/views/nodes/searchNode.ts
@@ -2,7 +2,6 @@
import { TreeItem, TreeItemCollapsibleState } from 'vscode';
import { SearchCommitsCommandArgs } from '../../commands';
import { GlyphChars } from '../../constants';
-import { GitRepoSearchBy } from '../../git/gitService';
import { debug, gate, Iterables, log, Promises } from '../../system';
import { View } from '../viewBase';
import { CommandMessageNode, MessageNode } from './common';
@@ -22,9 +21,12 @@ export class SearchNode extends ViewNode {
command: 'gitlens.showCommitSearch'
};
- const getCommandArgs = (searchBy: GitRepoSearchBy): SearchCommitsCommandArgs => {
+ const getCommandArgs = (
+ search: '' | 'author:' | 'change:' | 'commit:' | 'file:'
+ ): SearchCommitsCommandArgs => {
return {
- searchBy: searchBy
+ search: { pattern: search },
+ prefillOnly: true
};
};
@@ -34,55 +36,55 @@ export class SearchNode extends ViewNode {
this,
{
...command,
- arguments: [this, getCommandArgs(GitRepoSearchBy.Message)]
+ arguments: [this, getCommandArgs('')]
},
- 'Search commits by message',
- 'message-pattern',
- 'Click to search commits by message'
+ 'Search for commits with messages',
+ 'pattern',
+ `Click to search for commits with matching messages ${GlyphChars.Dash} use quotes to search for phrases`
),
new CommandMessageNode(
this.view,
this,
{
...command,
- arguments: [this, getCommandArgs(GitRepoSearchBy.Author)]
+ arguments: [this, getCommandArgs('author:')]
},
- `${GlyphChars.Space.repeat(4)} or, by author`,
- '@ author-pattern',
- 'Click to search commits by author'
+ `${GlyphChars.Space.repeat(4)} or, authors or committers`,
+ 'author: pattern',
+ 'Click to search for commits with matching authors or committers'
),
new CommandMessageNode(
this.view,
this,
{
...command,
- arguments: [this, getCommandArgs(GitRepoSearchBy.Sha)]
+ arguments: [this, getCommandArgs('commit:')]
},
- `${GlyphChars.Space.repeat(4)} or, by commit id`,
- '# sha',
- 'Click to search commits by commit id'
+ `${GlyphChars.Space.repeat(4)} or, commit id`,
+ 'commit: sha',
+ 'Click to search for commits with matching commit id'
),
new CommandMessageNode(
this.view,
this,
{
...command,
- arguments: [this, getCommandArgs(GitRepoSearchBy.Files)]
+ arguments: [this, getCommandArgs('file:')]
},
- `${GlyphChars.Space.repeat(4)} or, by files`,
- ': file-path/glob',
- 'Click to search commits by files'
+ `${GlyphChars.Space.repeat(4)} or, files`,
+ 'file: glob',
+ 'Click to search for commits with matching files'
),
new CommandMessageNode(
this.view,
this,
{
...command,
- arguments: [this, getCommandArgs(GitRepoSearchBy.Changes)]
+ arguments: [this, getCommandArgs('change:')]
},
- `${GlyphChars.Space.repeat(4)} or, by changes`,
- '~ pattern',
- 'Click to search commits by changes'
+ `${GlyphChars.Space.repeat(4)} or, changes`,
+ 'change: pattern',
+ 'Click to search for commits with matching changes'
)
];
}
diff --git a/src/views/nodes/searchResultsCommitsNode.ts b/src/views/nodes/searchResultsCommitsNode.ts
index 11a4e9a..e068675 100644
--- a/src/views/nodes/searchResultsCommitsNode.ts
+++ b/src/views/nodes/searchResultsCommitsNode.ts
@@ -2,7 +2,6 @@
import { TreeItem, TreeItemCollapsibleState } from 'vscode';
import { SearchCommitsCommandArgs } from '../../commands';
import { Commands } from '../../commands/common';
-import { GitRepoSearchBy } from '../../git/gitService';
import { ViewWithFiles } from '../viewBase';
import { CommitsQueryResults, ResultsCommitsNode } from './resultsCommitsNode';
import { ResourceType, ViewNode } from './viewNode';
@@ -16,8 +15,12 @@ export class SearchResultsCommitsNode extends ResultsCommitsNode {
view: ViewWithFiles,
parent: ViewNode,
repoPath: string,
- public readonly search: string,
- public readonly searchBy: GitRepoSearchBy,
+ public readonly search: {
+ pattern: string;
+ matchAll?: boolean;
+ matchCase?: boolean;
+ matchRegex?: boolean;
+ },
label: string,
commitsQuery: (maxCount: number | undefined) => Promise
) {
@@ -30,7 +33,11 @@ export class SearchResultsCommitsNode extends ResultsCommitsNode {
}
get id(): string {
- return `gitlens:repository(${this.repoPath}):search(${this.searchBy}:${this.search}):commits|${this._instanceId}`;
+ return `gitlens:repository(${this.repoPath}):search(${this.search && this.search.pattern}|${
+ this.search && this.search.matchAll ? 'A' : ''
+ }${this.search && this.search.matchCase ? 'C' : ''}${
+ this.search && this.search.matchRegex ? 'R' : ''
+ }):commits|${this._instanceId}`;
}
get type(): ResourceType {
@@ -43,8 +50,8 @@ export class SearchResultsCommitsNode extends ResultsCommitsNode {
if (item.collapsibleState === TreeItemCollapsibleState.None) {
const args: SearchCommitsCommandArgs = {
search: this.search,
- searchBy: this.searchBy,
- prefillOnly: true
+ prefillOnly: true,
+ showInView: true
};
item.command = {
title: 'Search Commits',
diff --git a/src/views/searchView.ts b/src/views/searchView.ts
index 3e6abd5..2744e35 100644
--- a/src/views/searchView.ts
+++ b/src/views/searchView.ts
@@ -3,7 +3,7 @@ import { commands, ConfigurationChangeEvent } from 'vscode';
import { configuration, SearchViewConfig, ViewFilesLayout, ViewsConfig } from '../configuration';
import { CommandContext, setCommandContext, WorkspaceState } from '../constants';
import { Container } from '../container';
-import { GitLog, GitRepoSearchBy } from '../git/gitService';
+import { GitLog } from '../git/gitService';
import { Functions, Strings } from '../system';
import { nodeSupportsConditionalDismissal, SearchNode, SearchResultsCommitsNode, ViewNode } from './nodes';
import { ViewBase } from './viewBase';
@@ -105,25 +105,31 @@ export class SearchView extends ViewBase {
async search(
repoPath: string,
- search: string,
- searchBy: GitRepoSearchBy,
- options: {
- maxCount?: number;
+ search: {
+ pattern: string;
+ matchAll?: boolean;
+ matchCase?: boolean;
+ matchRegex?: boolean;
+ },
+ {
+ label,
+ ...options
+ }: {
label:
| string
| {
label: string;
resultsType?: { singular: string; plural: string };
};
- }
+ maxCount?: number;
+ },
+ results?: Promise | GitLog
) {
await this.show();
const searchQueryFn = this.getSearchQueryFn(
- Container.git.getLogForSearch(repoPath, search, searchBy, {
- maxCount: options.maxCount
- }),
- options
+ results || Container.git.getLogForSearch(repoPath, search, options),
+ { label: label }
);
return this.addResults(
@@ -132,8 +138,7 @@ export class SearchView extends ViewBase {
this._root!,
repoPath,
search,
- searchBy,
- `results for ${typeof options.label === 'string' ? options.label : options.label.label}`,
+ `${typeof label === 'string' ? label : label.label}`,
searchQueryFn
)
);
@@ -141,27 +146,33 @@ export class SearchView extends ViewBase {
showSearchResults(
repoPath: string,
- search: string,
- searchBy: GitRepoSearchBy,
+ search: {
+ pattern: string;
+ matchAll?: boolean;
+ matchCase?: boolean;
+ matchRegex?: boolean;
+ },
results: GitLog,
- options: {
+ {
+ label,
+ ...options
+ }: {
label:
| string
| {
label: string;
resultsType?: { singular: string; plural: string };
};
+ maxCount?: number;
}
) {
- const label = this.getSearchLabel(options.label, results);
- const searchQueryFn = Functions.cachedOnce(this.getSearchQueryFn(results, options), {
+ label = this.getSearchLabel(label, results);
+ const searchQueryFn = Functions.cachedOnce(this.getSearchQueryFn(results, { label: label, ...options }), {
label: label,
log: results
});
- return this.addResults(
- new SearchResultsCommitsNode(this, this._root!, repoPath, search, searchBy, label, searchQueryFn)
- );
+ return this.addResults(new SearchResultsCommitsNode(this, this._root!, repoPath, search, label, searchQueryFn));
}
private addResults(results: ViewNode) {