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) {