Browse Source

Adds authors & files pickers for search queries

main
Eric Amodio 1 year ago
parent
commit
388b258de7
5 changed files with 198 additions and 48 deletions
  1. +157
    -17
      src/commands/git/search.ts
  2. +5
    -1
      src/commands/gitCommands.ts
  3. +14
    -0
      src/commands/quickCommand.ts
  4. +12
    -20
      src/git/search.ts
  5. +10
    -10
      src/plus/github/githubGitProvider.ts

+ 157
- 17
src/commands/git/search.ts View File

@ -1,28 +1,40 @@
import type { QuickInputButton, QuickPick } from 'vscode';
import { ThemeIcon, window } from 'vscode';
import { GlyphChars } from '../../constants';
import type { Container } from '../../container';
import { showDetailsView } from '../../git/actions/commit';
import type { GitCommit } from '../../git/models/commit';
import type { GitLog } from '../../git/models/log';
import type { Repository } from '../../git/models/repository';
import type { SearchOperators, SearchQuery } from '../../git/search';
import type { NormalizedSearchOperators, SearchOperators, SearchQuery } from '../../git/search';
import { getSearchQueryComparisonKey, parseSearchQuery, searchOperators } from '../../git/search';
import { showContributorsPicker } from '../../quickpicks/contributorsPicker';
import type { QuickPickItemOfT } from '../../quickpicks/items/common';
import { ActionQuickPickItem } from '../../quickpicks/items/common';
import { configuration } from '../../system/configuration';
import { getContext } from '../../system/context';
import { join, map } from '../../system/iterable';
import { pluralize } from '../../system/string';
import { SearchResultsNode } from '../../views/nodes/searchResultsNode';
import type { ViewsWithRepositoryFolders } from '../../views/viewBase';
import { getSteps } from '../gitCommands.utils';
import type {
PartialStepState,
QuickPickStep,
StepGenerator,
StepResult,
StepResultGenerator,
StepSelection,
StepState,
} from '../quickCommand';
import { canPickStepContinue, createPickStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand';
import {
canPickStepContinue,
createPickStep,
endSteps,
freezeStep,
QuickCommand,
StepResultBreak,
} from '../quickCommand';
import {
MatchAllToggleQuickInputButton,
MatchCaseToggleQuickInputButton,
@ -31,7 +43,23 @@ import {
} from '../quickCommand.buttons';
import { appendReposToTitle, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps';
const UseAuthorPickerQuickInputButton: QuickInputButton = {
iconPath: new ThemeIcon('person-add'),
tooltip: 'Pick Authors',
};
const UseFilePickerQuickInputButton: QuickInputButton = {
iconPath: new ThemeIcon('new-file'),
tooltip: 'Pick Files',
};
const UseFolderPickerQuickInputButton: QuickInputButton = {
iconPath: new ThemeIcon('new-folder'),
tooltip: 'Pick Folder',
};
interface Context {
container: Container;
repos: Repository[];
associatedView: ViewsWithRepositoryFolders;
commit: GitCommit | undefined;
@ -105,6 +133,7 @@ export class SearchGitCommand extends QuickCommand {
protected async *steps(state: PartialStepState<State>): StepGenerator {
const context: Context = {
container: this.container,
repos: this.container.git.openRepositories,
associatedView: this.container.searchAndCompareView,
commit: undefined,
@ -281,20 +310,24 @@ export class SearchGitCommand extends QuickCommand {
}
private *pickSearchOperatorStep(state: SearchStepState, context: Context): StepResultGenerator<string> {
const items: QuickPickItemOfT<SearchOperators>[] = [
const items: QuickPickItemOfT<NormalizedSearchOperators>[] = [
{
label: searchOperatorToTitleMap.get('')!,
description: `pattern or message: pattern or =: pattern ${GlyphChars.Dash} use quotes to search for phrases`,
alwaysShow: true,
item: 'message:' as const,
},
{
label: searchOperatorToTitleMap.get('author:')!,
description: 'author: pattern or @: pattern',
buttons: [UseAuthorPickerQuickInputButton],
alwaysShow: true,
item: 'author:' as const,
},
{
label: searchOperatorToTitleMap.get('commit:')!,
description: 'commit: sha or #: sha',
alwaysShow: true,
item: 'commit:' as const,
},
context.hasVirtualFolders
@ -302,6 +335,8 @@ export class SearchGitCommand extends QuickCommand {
: {
label: searchOperatorToTitleMap.get('file:')!,
description: 'file: glob or ?: glob',
buttons: [UseFilePickerQuickInputButton, UseFolderPickerQuickInputButton],
alwaysShow: true,
item: 'file:' as const,
},
context.hasVirtualFolders
@ -309,6 +344,7 @@ export class SearchGitCommand extends QuickCommand {
: {
label: searchOperatorToTitleMap.get('change:')!,
description: 'change: pattern or ~: pattern',
alwaysShow: true,
item: 'change:' as const,
},
].filter(<T>(i?: T): i is T => i != null);
@ -317,7 +353,7 @@ export class SearchGitCommand extends QuickCommand {
const matchAllButton = new MatchAllToggleQuickInputButton(state.matchAll);
const matchRegexButton = new MatchRegexToggleQuickInputButton(state.matchRegex);
const step = createPickStep<QuickPickItemOfT<SearchOperators>>({
const step = createPickStep<QuickPickItemOfT<NormalizedSearchOperators>>({
title: appendReposToTitle(context.title, state, context),
placeholder: 'e.g. "Updates dependencies" author:eamodio',
matchOnDescription: true,
@ -326,19 +362,11 @@ export class SearchGitCommand extends QuickCommand {
items: items,
value: state.query,
selectValueWhenShown: false,
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);
onDidAccept: async quickpick => {
const item = quickpick.selectedItems[0];
if (!searchOperators.has(item.item)) return true;
await updateSearchQuery(item, {}, quickpick, step, state, context);
return false;
},
onDidClickButton: (quickpick, button) => {
@ -353,6 +381,17 @@ export class SearchGitCommand extends QuickCommand {
matchRegexButton.on = state.matchRegex;
}
},
onDidClickItemButton: async function (quickpick, button, item) {
if (button === UseAuthorPickerQuickInputButton) {
await updateSearchQuery(item, { author: true }, quickpick, step, state, context);
} else if (button === UseFilePickerQuickInputButton) {
await updateSearchQuery(item, { file: { type: 'file' } }, quickpick, step, state, context);
} else if (button === UseFolderPickerQuickInputButton) {
await updateSearchQuery(item, { file: { type: 'folder' } }, quickpick, step, state, context);
}
return false;
},
onDidChangeValue: (quickpick): boolean => {
const value = quickpick.value.trim();
// Simulate an extra step if we have a value
@ -384,9 +423,13 @@ export class SearchGitCommand extends QuickCommand {
{
label: 'Search for',
description: quickpick.value,
item: quickpick.value as SearchOperators,
item: quickpick.value as NormalizedSearchOperators,
picked: true,
},
...items,
];
quickpick.activeItems = [quickpick.items[0]];
}
return true;
@ -404,3 +447,100 @@ export class SearchGitCommand extends QuickCommand {
return selection[0].item.trim();
}
}
async function updateSearchQuery(
item: QuickPickItemOfT<NormalizedSearchOperators>,
usePickers: { author?: boolean; file?: { type: 'file' | 'folder' } },
quickpick: QuickPick<any>,
step: QuickPickStep,
state: SearchStepState,
context: Context,
) {
const ops = parseSearchQuery({
query: quickpick.value,
matchCase: state.matchCase,
matchAll: state.matchAll,
});
let append = false;
if (usePickers?.author && item.item === 'author:') {
using frozen = freezeStep(step, quickpick);
const authors = ops.get('author:');
const contributors = await showContributorsPicker(
context.container,
state.repo,
'Search by Author',
'Choose contributors to include commits from',
{
appendReposToTitle: true,
clearButton: true,
multiselect: true,
picked: c =>
authors != null &&
((c.email != null && authors.has(c.email)) ||
(c.name != null && authors.has(c.name)) ||
(c.username != null && authors.has(c.username))),
},
);
frozen[Symbol.dispose]();
if (contributors != null) {
const authors = contributors
.map(c => c.email ?? c.name ?? c.username)
.filter(<T>(c?: T): c is T => c != null);
if (authors.length) {
ops.set('author:', new Set(authors));
} else {
ops.delete('author:');
}
} else {
append = true;
}
} else if (usePickers?.file && item.item === 'file:') {
using frozen = freezeStep(step, quickpick);
let files = ops.get('file:');
const uris = await window.showOpenDialog({
canSelectFiles: usePickers.file.type === 'file',
canSelectFolders: usePickers.file.type === 'folder',
canSelectMany: usePickers.file.type === 'file',
title: 'Search by File',
openLabel: 'Add to Search',
defaultUri: state.repo.folder?.uri,
});
frozen[Symbol.dispose]();
if (uris?.length) {
if (files == null) {
files = new Set();
ops.set('file:', files);
}
for (const uri of uris) {
files.add(context.container.git.getRelativePath(uri, state.repo.uri));
}
} else {
append = true;
}
if (files == null || files.size === 0) {
ops.delete('file:');
}
} else {
const values = ops.get(item.item);
append = !values?.has('');
}
quickpick.value = `${join(
map(ops.entries(), ([op, values]) => `${op}${join(values, ` ${op}`)}`),
' ',
)}${append ? ` ${item.item}` : ''}`;
void step.onDidChangeValue!(quickpick);
}

+ 5
- 1
src/commands/gitCommands.ts View File

@ -522,7 +522,11 @@ export class GitCommandsCommand extends Command {
disposables.push(
scope,
quickpick.onDidHide(() => resolve(undefined)),
quickpick.onDidHide(() => {
if (step.frozen) return;
resolve(undefined);
}),
quickpick.onDidTriggerItemButton(async e => {
if ((await step.onDidClickItemButton?.(quickpick, e.button, e.item)) === true) {
resolve(await this.nextStep(commandsStep.command!, [e.item], quickpick));

+ 14
- 0
src/commands/quickCommand.ts View File

@ -54,6 +54,8 @@ export interface QuickPickStep {
value?: string;
selectValueWhenShown?: boolean;
frozen?: boolean;
onDidAccept?(quickpick: QuickPick<T>): boolean | Promise<boolean>;
onDidChangeValue?(quickpick: QuickPick<T>): boolean | Promise<boolean>;
onDidClickButton?(quickpick: QuickPick<T>, button: QuickInputButton): boolean | void | Promise<boolean | void>;
@ -350,3 +352,15 @@ export function createCustomStep(step: CustomStep): CustomStep {
export function endSteps(state: PartialStepState) {
state.counter = -1;
}
export function freezeStep(step: QuickPickStep, quickpick: QuickPick<any>): Disposable {
quickpick.enabled = false;
step.frozen = true;
return {
[Symbol.dispose]: () => {
step.frozen = false;
quickpick.enabled = true;
quickpick.show();
},
};
}

+ 12
- 20
src/git/search.ts View File

@ -2,18 +2,8 @@ import type { GitRevisionReference } from './models/reference';
import { isSha, shortenRevision } from './models/reference';
import type { GitUser } from './models/user';
export type SearchOperators =
| ''
| '=:'
| 'message:'
| '@:'
| 'author:'
| '#:'
| 'commit:'
| '?:'
| 'file:'
| '~:'
| 'change:';
export type NormalizedSearchOperators = 'message:' | 'author:' | 'commit:' | 'file:' | 'change:';
export type SearchOperators = NormalizedSearchOperators | '' | '=:' | '@:' | '#:' | '?:' | '~:';
export const searchOperators = new Set<string>([
'',
@ -100,7 +90,7 @@ export function createSearchQueryForCommits(refsOrCommits: (string | GitRevision
return refsOrCommits.map(r => `#:${typeof r === 'string' ? shortenRevision(r) : r.name}`).join(' ');
}
const normalizeSearchOperatorsMap = new Map<SearchOperators, SearchOperators>([
const normalizeSearchOperatorsMap = new Map<SearchOperators, NormalizedSearchOperators>([
['', 'message:'],
['=:', 'message:'],
['message:', 'message:'],
@ -117,8 +107,8 @@ const normalizeSearchOperatorsMap = new Map([
const searchOperationRegex =
/(?:(?<op>=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?<value>".+?"|\S+}?))|(?<text>\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi;
export function parseSearchQuery(search: SearchQuery): Map<string, string[]> {
const operations = new Map<string, string[]>();
export function parseSearchQuery(search: SearchQuery): Map<NormalizedSearchOperators, Set<string>> {
const operations = new Map<NormalizedSearchOperators, Set<string>>();
let op: SearchOperators | undefined;
let value: string | undefined;
@ -134,16 +124,18 @@ export function parseSearchQuery(search: SearchQuery): Map {
if (text) {
op = text === '@me' ? 'author:' : isSha(text) ? 'commit:' : 'message:';
value = text;
if (!normalizeSearchOperatorsMap.has(op)) {
value = text;
}
}
if (op && value) {
const values = operations.get(op);
let values = operations.get(op);
if (values == null) {
operations.set(op, [value]);
} else {
values.push(value);
values = new Set();
operations.set(op, values);
}
values.add(value);
}
} while (match != null);

+ 10
- 10
src/plus/github/githubGitProvider.ts View File

@ -75,13 +75,13 @@ import type { GitTreeEntry } from '../../git/models/tree';
import type { GitUser } from '../../git/models/user';
import { isUserMatch } from '../../git/models/user';
import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders';
import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../git/search';
import type { GitSearch, GitSearchResultData, GitSearchResults, SearchOperators, SearchQuery } from '../../git/search';
import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search';
import { configuration } from '../../system/configuration';
import { setContext } from '../../system/context';
import { gate } from '../../system/decorators/gate';
import { debug, log } from '../../system/decorators/log';
import { filterMap, first, last, some } from '../../system/iterable';
import { filterMap, first, last, map, some } from '../../system/iterable';
import { Logger } from '../../system/logger';
import type { LogScope } from '../../system/logger.scope';
import { getLogScope } from '../../system/logger.scope';
@ -2888,8 +2888,8 @@ export class GitHubGitProvider implements GitProvider, Disposable {
const operations = parseSearchQuery(search);
const values = operations.get('commit:');
if (values != null) {
const commit = await this.getCommit(repoPath, values[0]);
if (values?.size) {
const commit = await this.getCommit(repoPath, first(values)!);
if (commit == null) return undefined;
return {
@ -3058,9 +3058,9 @@ export class GitHubGitProvider implements GitProvider, Disposable {
const values = operations.get('commit:');
if (values != null) {
const commitsResults = await Promise.allSettled<Promise<GitCommit | undefined>[]>(
values.map(v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))),
);
const commitsResults = await Promise.allSettled<Promise<GitCommit | undefined>[]>([
...map(values, v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))),
]);
let i = 0;
for (const commitResult of commitsResults) {
@ -3442,7 +3442,7 @@ export class GitHubGitProvider implements GitProvider, Disposable {
private async getQueryArgsFromSearchQuery(
search: SearchQuery,
operations: Map<string, string[]>,
operations: Map<SearchOperators, Set<string>>,
repoPath: string,
) {
const query = [];
@ -3450,12 +3450,12 @@ export class GitHubGitProvider implements GitProvider, Disposable {
for (const [op, values] of operations.entries()) {
switch (op) {
case 'message:':
query.push(...values.map(m => m.replace(/ /g, '+')));
query.push(...map(values, m => m.replace(/ /g, '+')));
break;
case 'author:': {
let currentUser: GitUser | undefined;
if (values.includes('@me')) {
if (values.has('@me')) {
currentUser = await this.getCurrentUser(repoPath);
}

Loading…
Cancel
Save