Переглянути джерело

Adds search result paging support

Adds keyboard shortcuts for next/prev search
Adds timeout for webview completion events
main
Eric Amodio 2 роки тому
джерело
коміт
ed2e07dcce
13 змінених файлів з 303 додано та 180 видалено
  1. +2
    -2
      src/commands/git/search.ts
  2. +30
    -15
      src/env/node/git/localGitProvider.ts
  3. +3
    -2
      src/git/search.ts
  4. +5
    -2
      src/plus/github/githubGitProvider.ts
  5. +111
    -63
      src/plus/webviews/graph/graphWebview.ts
  6. +2
    -0
      src/plus/webviews/graph/protocol.ts
  7. +3
    -3
      src/views/nodes/searchResultsNode.ts
  8. +82
    -47
      src/webviews/apps/plus/graph/GraphWrapper.tsx
  9. +13
    -20
      src/webviews/apps/plus/graph/graph.tsx
  10. +28
    -9
      src/webviews/apps/shared/appBase.ts
  11. +2
    -0
      src/webviews/apps/shared/components/search/react.tsx
  12. +16
    -0
      src/webviews/apps/shared/components/search/search-field.ts
  13. +6
    -17
      src/webviews/apps/shared/components/search/search-nav.ts

+ 2
- 2
src/commands/git/search.ts Переглянути файл

@ -6,7 +6,7 @@ import type { GitCommit } from '../../git/models/commit';
import type { GitLog } from '../../git/models/log';
import type { Repository } from '../../git/models/repository';
import type { SearchOperators, SearchPattern } from '../../git/search';
import { getKeyForSearchPattern, parseSearchOperations, searchOperators } from '../../git/search';
import { getSearchPatternComparisonKey, parseSearchOperations, searchOperators } from '../../git/search';
import type { QuickPickItemOfT } from '../../quickpicks/items/common';
import { ActionQuickPickItem } from '../../quickpicks/items/common';
import { pluralize } from '../../system/string';
@ -166,7 +166,7 @@ export class SearchGitCommand extends QuickCommand {
matchCase: state.matchCase,
matchRegex: state.matchRegex,
};
const searchKey = getKeyForSearchPattern(search);
const searchKey = getSearchPatternComparisonKey(search);
if (context.resultsPromise == null || context.resultsKey !== searchKey) {
context.resultsPromise = state.repo.searchForCommits(search);

+ 30
- 15
src/env/node/git/localGitProvider.ts Переглянути файл

@ -107,7 +107,7 @@ import type { RemoteProviders } from '../../../git/remotes/remoteProviders';
import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../git/remotes/remoteProviders';
import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider';
import type { GitSearch, SearchPattern } from '../../../git/search';
import { parseSearchOperations } from '../../../git/search';
import { getSearchPatternComparisonKey, parseSearchOperations } from '../../../git/search';
import { Logger } from '../../../logger';
import type { LogScope } from '../../../logger';
import {
@ -2645,12 +2645,14 @@ export class LocalGitProvider implements GitProvider, Disposable {
): Promise<GitSearch> {
search = { matchAll: false, matchCase: false, matchRegex: true, ...search };
const comparisonKey = getSearchPatternComparisonKey(search);
try {
const { args: searchArgs, files, commits } = this.getArgsFromSearchPattern(search);
if (commits?.length) {
if (commits?.size) {
return {
repoPath: repoPath,
pattern: search,
comparisonKey: comparisonKey,
results: commits,
};
}
@ -2664,21 +2666,24 @@ export class LocalGitProvider implements GitProvider, Disposable {
`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
'--use-mailmap',
];
if (limit) {
args.push(`-n${limit + 1}`);
}
if (options?.ordering) {
args.push(`--${options.ordering}-order`);
}
const results = new Set<string>();
let total = 0;
let iterations = 0;
async function searchForCommitsCore(
this: LocalGitProvider,
limit: number,
cursor?: { sha: string; skip: number },
): Promise<GitSearch> {
iterations++;
if (options?.cancellation?.isCancellationRequested) {
// TODO@eamodio: Should we throw an error here?
return { repoPath: repoPath, pattern: search, results: [] };
return { repoPath: repoPath, pattern: search, comparisonKey: comparisonKey, results: results };
}
const data = await this.git.log2(
@ -2686,6 +2691,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
{ cancellation: options?.cancellation },
...args,
...(cursor?.skip ? [`--skip=${cursor.skip}`] : []),
...(limit ? [`-n${limit + 1}`] : []),
...searchArgs,
'--',
...files,
@ -2693,26 +2699,34 @@ export class LocalGitProvider implements GitProvider, Disposable {
if (options?.cancellation?.isCancellationRequested) {
// TODO@eamodio: Should we throw an error here?
return { repoPath: repoPath, pattern: search, results: [] };
return { repoPath: repoPath, pattern: search, comparisonKey: comparisonKey, results: results };
}
const results = [...refParser.parse(data)];
let count = 0;
let last: string | undefined;
for (const r of refParser.parse(data)) {
results.add(r);
count++;
last = r;
}
const last = results[results.length - 1];
total += count;
cursor =
last != null
? {
sha: last,
skip: results.length,
skip: total - iterations,
}
: undefined;
return {
repoPath: repoPath,
pattern: search,
comparisonKey: comparisonKey,
results: results,
paging:
limit !== 0 && results.length > limit
limit !== 0 && count > limit
? {
limit: limit,
startingCursor: cursor?.sha,
@ -2730,7 +2744,8 @@ export class LocalGitProvider implements GitProvider, Disposable {
return {
repoPath: repoPath,
pattern: search,
results: [],
comparisonKey: comparisonKey,
results: new Set<string>(),
};
}
}
@ -2758,7 +2773,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
ordering: configuration.get('advanced.commitOrdering'),
...options,
limit: limit,
useShow: Boolean(commits?.length),
useShow: Boolean(commits?.size),
});
const log = GitLogParser.parse(
this.container,
@ -2790,7 +2805,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
private getArgsFromSearchPattern(search: SearchPattern): {
args: string[];
files: string[];
commits?: string[] | undefined;
commits?: Set<string> | undefined;
} {
const operations = parseSearchOperations(search.pattern);
@ -2806,7 +2821,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
for (const value of values) {
searchArgs.add(value.replace(doubleQuoteRegex, ''));
}
commits = [...searchArgs.values()];
commits = searchArgs;
} else {
searchArgs.add('--all');
searchArgs.add('--full-history');

+ 3
- 2
src/git/search.ts Переглянути файл

@ -38,7 +38,8 @@ export interface SearchPattern {
export interface GitSearch {
repoPath: string;
pattern: SearchPattern;
results: string[];
comparisonKey: string;
results: Set<string>;
readonly paging?: {
readonly limit: number | undefined;
@ -49,7 +50,7 @@ export interface GitSearch {
more?(limit: number): Promise<GitSearch>;
}
export function getKeyForSearchPattern(search: SearchPattern) {
export function getSearchPatternComparisonKey(search: SearchPattern) {
return `${search.pattern}|${search.matchAll ? 'A' : ''}${search.matchCase ? 'C' : ''}${
search.matchRegex ? 'R' : ''
}`;

+ 5
- 2
src/plus/github/githubGitProvider.ts Переглянути файл

@ -74,7 +74,7 @@ import type { RemoteProviders } from '../../git/remotes/remoteProviders';
import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders';
import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider';
import type { GitSearch, SearchPattern } from '../../git/search';
import { parseSearchOperations } from '../../git/search';
import { getSearchPatternComparisonKey, parseSearchOperations } from '../../git/search';
import type { LogScope } from '../../logger';
import { Logger } from '../../logger';
import { gate } from '../../system/decorators/gate';
@ -1588,10 +1588,13 @@ export class GitHubGitProvider implements GitProvider, Disposable {
_options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' },
): Promise<GitSearch> {
search = { matchAll: false, matchCase: false, matchRegex: true, ...search };
const comparisonKey = getSearchPatternComparisonKey(search);
return {
repoPath: repoPath,
pattern: search,
results: [],
comparisonKey: comparisonKey,
results: new Set<string>(),
};
// try {

+ 111
- 63
src/plus/webviews/graph/graphWebview.ts Переглянути файл

@ -3,7 +3,6 @@ import { CancellationTokenSource, EventEmitter, MarkdownString, StatusBarAlignme
import { getAvatarUri } from '../../../avatars';
import { parseCommandContext } from '../../../commands/base';
import { GitActions } from '../../../commands/gitCommands.actions';
import type { GraphColumnConfig } from '../../../configuration';
import { configuration } from '../../../configuration';
import { Commands, ContextKeys } from '../../../constants';
import type { Container } from '../../../container';
@ -14,11 +13,14 @@ import { GitGraphRowType } from '../../../git/models/graph';
import type { GitGraph } from '../../../git/models/graph';
import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository';
import type { GitSearch } from '../../../git/search';
import { getSearchPatternComparisonKey } from '../../../git/search';
import { registerCommand } from '../../../system/command';
import { gate } from '../../../system/decorators/gate';
import { debug } from '../../../system/decorators/log';
import type { Deferrable } from '../../../system/function';
import { debounce } from '../../../system/function';
import { first } from '../../../system/iterable';
import { updateRecordValue } from '../../../system/object';
import { isDarkTheme, isLightTheme } from '../../../system/utils';
import { RepositoryFolderNode } from '../../../views/nodes/viewNode';
@ -30,10 +32,15 @@ import { ensurePlusFeaturesEnabled } from '../../subscription/utils';
import type {
DismissBannerParams,
EnsureCommitParams,
GetMissingAvatarsParams,
GetMoreCommitsParams,
GraphComponentConfig,
GraphRepository,
SearchCommitsParams,
State,
UpdateColumnParams,
UpdateSelectedRepositoryParams,
UpdateSelectionParams,
} from './protocol';
import {
DidChangeAvatarsNotificationType,
@ -98,6 +105,8 @@ export class GraphWebview extends WebviewBase {
private _etagRepository?: number;
private _graph?: GitGraph;
private _pendingNotifyCommits: boolean = false;
private _search: GitSearch | undefined;
private _searchCancellation: CancellationTokenSource | undefined;
private _selectedSha?: string;
private _selectedRows: { [sha: string]: true } = {};
private _repositoryEventsDisposable: Disposable | undefined;
@ -140,7 +149,7 @@ export class GraphWebview extends WebviewBase {
}
this.setSelectedRows(args.sha);
void this.onGetMoreCommits(args.sha);
void this.onGetMoreCommits({ sha: args.sha });
}
}),
);
@ -199,28 +208,28 @@ export class GraphWebview extends WebviewBase {
protected override onMessageReceived(e: IpcMessage) {
switch (e.method) {
case DismissBannerCommandType.method:
onIpc(DismissBannerCommandType, e, params => this.dismissBanner(params.key));
onIpc(DismissBannerCommandType, e, params => this.dismissBanner(params));
break;
case EnsureCommitCommandType.method:
onIpc(EnsureCommitCommandType, e, params => this.onEnsureCommit(params, e.completionId));
break;
case GetMissingAvatarsCommandType.method:
onIpc(GetMissingAvatarsCommandType, e, params => this.onGetMissingAvatars(params.emails));
onIpc(GetMissingAvatarsCommandType, e, params => this.onGetMissingAvatars(params));
break;
case GetMoreCommitsCommandType.method:
onIpc(GetMoreCommitsCommandType, e, params => this.onGetMoreCommits(params.sha, e.id));
onIpc(GetMoreCommitsCommandType, e, params => this.onGetMoreCommits(params));
break;
case SearchCommitsCommandType.method:
onIpc(SearchCommitsCommandType, e, params => this.onSearchCommits(params, e.id));
onIpc(SearchCommitsCommandType, e, params => this.onSearchCommits(params, e.completionId));
break;
case UpdateColumnCommandType.method:
onIpc(UpdateColumnCommandType, e, params => this.onColumnUpdated(params.name, params.config));
onIpc(UpdateColumnCommandType, e, params => this.onColumnUpdated(params));
break;
case UpdateSelectedRepositoryCommandType.method:
onIpc(UpdateSelectedRepositoryCommandType, e, params => this.onRepositorySelectionChanged(params.path));
onIpc(UpdateSelectedRepositoryCommandType, e, params => this.onRepositorySelectionChanged(params));
break;
case UpdateSelectionCommandType.method:
onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(params.selection));
onIpc(UpdateSelectionCommandType, e, debounce(this.onSelectionChanged.bind(this), 100));
break;
}
}
@ -335,21 +344,21 @@ export class GraphWebview extends WebviewBase {
this.updateState();
}
private dismissBanner(key: DismissBannerParams['key']) {
if (key === 'preview') {
private dismissBanner(e: DismissBannerParams) {
if (e.key === 'preview') {
this.previewBanner = false;
} else if (key === 'trial') {
} else if (e.key === 'trial') {
this.trialBanner = false;
}
let banners = this.container.storage.getWorkspace('graph:banners:dismissed');
banners = updateRecordValue(banners, key, true);
banners = updateRecordValue(banners, e.key, true);
void this.container.storage.storeWorkspace('graph:banners:dismissed', banners);
}
private onColumnUpdated(name: string, config: GraphColumnConfig) {
private onColumnUpdated(e: UpdateColumnParams) {
let columns = this.container.storage.getWorkspace('graph:columns');
columns = updateRecordValue(columns, name, config);
columns = updateRecordValue(columns, e.name, e.config);
void this.container.storage.storeWorkspace('graph:columns', columns);
void this.notifyDidChangeGraphConfiguration();
@ -357,18 +366,18 @@ export class GraphWebview extends WebviewBase {
@debug()
private async onEnsureCommit(e: EnsureCommitParams, completionId?: string) {
if (this._graph?.more == null) return;
if (this._graph?.more == null || this._repository?.etag !== this._etagRepository) {
this.updateState(true);
let selected: boolean | undefined;
if (!this._graph.ids.has(e.id)) {
const { defaultItemLimit, pageItemLimit } = configuration.get('graph');
const newGraph = await this._graph.more(pageItemLimit ?? defaultItemLimit, e.id);
if (newGraph != null) {
this.setGraph(newGraph);
} else {
debugger;
if (completionId != null) {
void this.notify(DidEnsureCommitNotificationType, {}, completionId);
}
return;
}
let selected: boolean | undefined;
if (!this._graph.ids.has(e.id)) {
await this.updateGraphWithMoreCommits(this._graph, e.id);
if (e.select && this._graph.ids.has(e.id)) {
selected = true;
this.setSelectedRows(e.id);
@ -382,7 +391,7 @@ export class GraphWebview extends WebviewBase {
void this.notify(DidEnsureCommitNotificationType, { id: e.id, selected: selected }, completionId);
}
private async onGetMissingAvatars(emails: { [email: string]: string }) {
private async onGetMissingAvatars(e: GetMissingAvatarsParams) {
if (this._graph == null) return;
const repoPath = this._graph.repoPath;
@ -394,7 +403,7 @@ export class GraphWebview extends WebviewBase {
const promises: Promise<void>[] = [];
for (const [email, sha] of Object.entries(emails)) {
for (const [email, sha] of Object.entries(e.emails)) {
if (this._graph.avatars.has(email)) continue;
promises.push(getAvatar.call(this, email, sha));
@ -408,64 +417,88 @@ export class GraphWebview extends WebviewBase {
@gate()
@debug()
private async onGetMoreCommits(sha?: string, completionId?: string) {
private async onGetMoreCommits(e: GetMoreCommitsParams) {
if (this._graph?.more == null || this._repository?.etag !== this._etagRepository) {
this.updateState(true);
return;
}
const { defaultItemLimit, pageItemLimit } = configuration.get('graph');
const newGraph = await this._graph.more(pageItemLimit ?? defaultItemLimit, sha);
if (newGraph != null) {
this.setGraph(newGraph);
} else {
debugger;
}
void this.notifyDidChangeCommits(completionId);
await this.updateGraphWithMoreCommits(this._graph, e.sha);
void this.notifyDidChangeCommits();
}
private _searchCancellation: CancellationTokenSource | undefined;
@debug()
private async onSearchCommits(e: SearchCommitsParams, completionId?: string) {
if (this._repository == null) return;
let search: GitSearch | undefined = this._search;
if (search?.more != null && e.more && search.comparisonKey === getSearchPatternComparisonKey(e.search)) {
const limit = typeof e.more !== 'boolean' ? e.more.limit : undefined;
search = await search.more(limit ?? configuration.get('graph.searchItemLimit') ?? 100);
if (search != null) {
this._search = search;
void this.notify(
DidSearchCommitsNotificationType,
{
results: {
ids: [...search.results.values()],
paging: {
startingCursor: search.paging?.startingCursor,
more: search.paging?.more ?? false,
},
},
selectedRows: this._selectedRows,
},
completionId,
);
}
if (this._repository.etag !== this._etagRepository) {
this.updateState(true);
return;
}
if (this._searchCancellation != null) {
this._searchCancellation.cancel();
this._searchCancellation.dispose();
}
if (search == null || search.comparisonKey !== getSearchPatternComparisonKey(e.search)) {
if (this._repository == null) return;
const cancellation = new CancellationTokenSource();
this._searchCancellation = cancellation;
if (this._repository.etag !== this._etagRepository) {
this.updateState(true);
}
const search = await this._repository.searchForCommitsSimple(e.search, {
limit: configuration.get('graph.searchItemLimit') ?? 100,
ordering: configuration.get('graph.commitOrdering'),
cancellation: cancellation.token,
});
if (this._searchCancellation != null) {
this._searchCancellation.cancel();
this._searchCancellation.dispose();
}
if (cancellation.token.isCancellationRequested) {
if (completionId != null) {
void this.notify(DidSearchCommitsNotificationType, { results: undefined }, completionId);
const cancellation = new CancellationTokenSource();
this._searchCancellation = cancellation;
search = await this._repository.searchForCommitsSimple(e.search, {
limit: configuration.get('graph.searchItemLimit') ?? 100,
ordering: configuration.get('graph.commitOrdering'),
cancellation: cancellation.token,
});
if (cancellation.token.isCancellationRequested) {
if (completionId != null) {
void this.notify(DidSearchCommitsNotificationType, { results: undefined }, completionId);
}
return;
}
return;
this._search = search;
} else {
search = this._search!;
}
if (search.results.length > 0) {
this.setSelectedRows(search.results[0]);
if (search.results.size > 0) {
this.setSelectedRows(first(search.results));
}
void this.notify(
DidSearchCommitsNotificationType,
{
results: {
ids: search.results,
ids: [...search.results.values()],
paging: {
startingCursor: search.paging?.startingCursor,
more: search.paging?.more ?? false,
@ -477,12 +510,12 @@ export class GraphWebview extends WebviewBase {
);
}
private onRepositorySelectionChanged(path: string) {
this.repository = this.container.git.getRepository(path);
private onRepositorySelectionChanged(e: UpdateSelectedRepositoryParams) {
this.repository = this.container.git.getRepository(e.path);
}
private async onSelectionChanged(selection: { id: string; type: GitGraphRowType }[]) {
const item = selection[0];
private async onSelectionChanged(e: UpdateSelectionParams) {
const item = e.selection[0];
this.setSelectedRows(item?.id);
let commits: GitCommit[] | undefined;
@ -723,6 +756,21 @@ export class GraphWebview extends WebviewBase {
private setGraph(graph: GitGraph | undefined) {
this._graph = graph;
if (graph == null) {
this._search = undefined;
this._searchCancellation?.dispose();
this._searchCancellation = undefined;
}
}
private async updateGraphWithMoreCommits(graph: GitGraph, sha?: string) {
const { defaultItemLimit, pageItemLimit } = configuration.get('graph');
const updatedGraph = await graph.more?.(pageItemLimit ?? defaultItemLimit, sha);
if (updatedGraph != null) {
this.setGraph(updatedGraph);
} else {
debugger;
}
}
private setSelectedRows(sha: string | undefined) {

+ 2
- 0
src/plus/webviews/graph/protocol.ts Переглянути файл

@ -97,6 +97,8 @@ export const GetMoreCommitsCommandType = new IpcCommandType
export interface SearchCommitsParams {
search: SearchPattern;
more?: boolean | { limit?: number };
}
export const SearchCommitsCommandType = new IpcCommandType<SearchCommitsParams>('graph/searchCommits');

+ 3
- 3
src/views/nodes/searchResultsNode.ts Переглянути файл

@ -4,7 +4,7 @@ import { executeGitCommand } from '../../commands/gitCommands.actions';
import { GitUri } from '../../git/gitUri';
import type { GitLog } from '../../git/models/log';
import type { SearchPattern } from '../../git/search';
import { getKeyForSearchPattern } from '../../git/search';
import { getSearchPatternComparisonKey } from '../../git/search';
import { gate } from '../../system/decorators/gate';
import { debug, log } from '../../system/decorators/log';
import { md5, pluralize } from '../../system/string';
@ -28,12 +28,12 @@ export class SearchResultsNode extends ViewNode implements
static key = ':search-results';
static getId(repoPath: string, search: SearchPattern | undefined, instanceId: number): string {
return `${RepositoryNode.getId(repoPath)}${this.key}(${
search == null ? '?' : getKeyForSearchPattern(search)
search == null ? '?' : getSearchPatternComparisonKey(search)
}):${instanceId}`;
}
static getPinnableId(repoPath: string, search: SearchPattern) {
return md5(`${repoPath}|${getKeyForSearchPattern(search)}`);
return md5(`${repoPath}|${getSearchPatternComparisonKey(search)}`);
}
private _instanceId: number;

+ 82
- 47
src/webviews/apps/plus/graph/GraphWrapper.tsx Переглянути файл

@ -17,6 +17,7 @@ import type { GitGraphRowType } from '../../../../git/models/graph';
import type { SearchPattern } from '../../../../git/search';
import type {
DidEnsureCommitParams,
DidSearchCommitsParams,
DismissBannerParams,
GraphComponentConfig,
GraphRepository,
@ -37,10 +38,14 @@ export interface GraphWrapperProps extends State {
onColumnChange?: (name: string, settings: GraphColumnConfig) => void;
onMissingAvatars?: (emails: { [email: string]: string }) => void;
onMoreCommits?: (id?: string) => void;
onSearchCommits?: (search: SearchPattern) => void; //Promise<DidSearchCommitsParams>;
onSearchCommits?: (search: SearchPattern) => void;
onSearchCommitsPromise?: (
search: SearchPattern,
options?: { more?: boolean | { limit?: number } },
) => Promise<DidSearchCommitsParams>;
onDismissBanner?: (key: DismissBannerParams['key']) => void;
onSelectionChange?: (selection: { id: string; type: GitGraphRowType }[]) => void;
onEnsureCommit?: (id: string, select: boolean) => Promise<DidEnsureCommitParams>;
onEnsureCommitPromise?: (id: string, select: boolean) => Promise<DidEnsureCommitParams>;
}
const getStyleProps = (
@ -200,9 +205,11 @@ export function GraphWrapper({
paging,
onSelectRepository,
onColumnChange,
onEnsureCommitPromise,
onMissingAvatars,
onMoreCommits,
onSearchCommits,
onSearchCommitsPromise,
onSelectionChange,
nonce,
mixedColumnColors,
@ -210,7 +217,6 @@ export function GraphWrapper({
searchResults,
trialBanner = true,
onDismissBanner,
onEnsureCommit,
}: GraphWrapperProps) {
const [graphRows, setGraphRows] = useState(rows);
const [graphAvatars, setAvatars] = useState(avatars);
@ -242,72 +248,99 @@ export function GraphWrapper({
// column setting UI
const [columnSettingsExpanded, setColumnSettingsExpanded] = useState(false);
// search state
const [searchValue, setSearchValue] = useState('');
const [search, setSearch] = useState<SearchPattern | undefined>(undefined);
const [searchResultKey, setSearchResultKey] = useState<string | undefined>(undefined);
const [searchIds, setSearchIds] = useState(searchResults?.ids);
const [hasMoreSearchIds, setHasMoreSearchIds] = useState(searchResults?.paging?.more ?? false);
const [searchResultIds, setSearchResultIds] = useState(searchResults?.ids);
const [hasMoreSearchResults, setHasMoreSearchResults] = useState(searchResults?.paging?.more ?? false);
useEffect(() => {
if (graphRows.length === 0) {
setSearchIds(undefined);
setSearchResultIds(undefined);
}
}, [graphRows]);
useEffect(() => {
if (searchIds == null) {
if (searchResultIds == null) {
setSearchResultKey(undefined);
return;
}
if (searchResultKey == null || (searchResultKey != null && searchIds.includes(searchResultKey))) {
setSearchResultKey(searchIds[0]);
if (searchResultKey == null || (searchResultKey != null && !searchResultIds.includes(searchResultKey))) {
setSearchResultKey(searchResultIds[0]);
}
}, [searchIds]);
}, [searchResultIds]);
const searchHighlights = useMemo(() => getSearchHighlights(searchIds), [searchIds]);
const searchHighlights = useMemo(() => getSearchHighlights(searchResultIds), [searchResultIds]);
const searchPosition: number = useMemo(() => {
if (searchResultKey == null || searchIds == null) {
return 0;
}
const idx = searchIds.indexOf(searchResultKey);
if (idx < 1) {
return 1;
}
if (searchResultKey == null || searchResultIds == null) return 0;
return idx + 1;
}, [searchResultKey, searchIds]);
const idx = searchResultIds.indexOf(searchResultKey);
return idx < 1 ? 1 : idx + 1;
}, [searchResultKey, searchResultIds]);
const handleSearchNavigation = (next = true) => {
if (searchResultKey == null || searchIds == null) return;
const handleSearchNavigation = async (next = true) => {
if (searchResultKey == null || searchResultIds == null) return;
const rowIndex = searchIds.indexOf(searchResultKey);
let resultIds = searchResultIds;
let rowIndex = resultIds.indexOf(searchResultKey);
if (rowIndex === -1) return;
let nextSha: string | undefined;
if (next && rowIndex < searchIds.length - 1) {
nextSha = searchIds[rowIndex + 1];
} else if (!next && rowIndex > 0) {
nextSha = searchIds[rowIndex - 1];
if (next) {
if (rowIndex < resultIds.length - 1) {
rowIndex++;
} else if (hasMoreSearchResults) {
const results = await onSearchCommitsPromise?.(search!, { more: true });
if (results?.results != null) {
if (resultIds.length < results.results.ids.length) {
resultIds = results.results.ids;
rowIndex++;
} else {
rowIndex = 0;
}
} else {
rowIndex = 0;
}
} else {
rowIndex = 0;
}
} else if (rowIndex > 0) {
rowIndex--;
} else {
if (hasMoreSearchResults) {
const results = await onSearchCommitsPromise?.(search!, { more: { limit: 0 } });
if (results?.results != null) {
if (resultIds.length < results.results.ids.length) {
resultIds = results.results.ids;
}
}
}
rowIndex = resultIds.length - 1;
}
const nextSha = resultIds[rowIndex];
if (nextSha == null) return;
if (onEnsureCommit != null) {
if (onEnsureCommitPromise != null) {
let timeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
timeout = undefined;
setIsLoading(true);
}, 250);
onEnsureCommit(nextSha, true).finally(() => {
if (timeout == null) {
setIsLoading(false);
} else {
clearTimeout(timeout);
}
const e = await onEnsureCommitPromise(nextSha, true);
if (timeout == null) {
setIsLoading(false);
} else {
clearTimeout(timeout);
}
if (e?.id === nextSha) {
setSearchResultKey(nextSha);
setSelectedRows({ [nextSha!]: true });
});
setSelectedRows({ [nextSha]: true });
} else {
debugger;
}
} else {
setSearchResultKey(nextSha);
setSelectedRows({ [nextSha]: true });
@ -316,11 +349,11 @@ export function GraphWrapper({
const handleSearchInput = (e: CustomEvent<SearchPattern>) => {
const detail = e.detail;
setSearchValue(detail.pattern);
setSearch(detail);
if (detail.pattern.length < 3) {
setSearchResultKey(undefined);
setSearchIds(undefined);
setSearchResultIds(undefined);
return;
}
onSearchCommits?.(detail);
@ -360,8 +393,8 @@ export function GraphWrapper({
setSubscriptionSnapshot(state.subscription);
setIsPrivateRepo(state.selectedRepositoryVisibility === RepositoryVisibility.Private);
setIsLoading(state.loading);
setSearchIds(state.searchResults?.ids);
setHasMoreSearchIds(state.searchResults?.paging?.more ?? false);
setSearchResultIds(state.searchResults?.ids);
setHasMoreSearchResults(state.searchResults?.paging?.more ?? false);
}
useEffect(() => subscriber?.(transformData), []);
@ -587,15 +620,17 @@ export function GraphWrapper({
<header className="titlebar graph-app__header">
<div className="titlebar__group">
<SearchField
value={searchValue}
value={search?.pattern}
onChange={e => handleSearchInput(e as CustomEvent<SearchPattern>)}
onPrevious={() => handleSearchNavigation(false)}
onNext={() => handleSearchNavigation(true)}
/>
<SearchNav
aria-label="Graph search navigation"
step={searchPosition}
total={searchIds?.length ?? 0}
valid={Boolean(searchValue && searchValue.length > 2)}
more={hasMoreSearchIds}
total={searchResultIds?.length ?? 0}
valid={Boolean(search?.pattern && search.pattern.length > 2)}
more={hasMoreSearchResults}
onPrevious={() => handleSearchNavigation(false)}
onNext={() => handleSearchNavigation(true)}
/>

+ 13
- 20
src/webviews/apps/plus/graph/graph.tsx Переглянути файл

@ -78,12 +78,13 @@ export class GraphApp extends App {
onMissingAvatars={(...params) => this.onGetMissingAvatars(...params)}
onMoreCommits={(...params) => this.onGetMoreCommits(...params)}
onSearchCommits={(...params) => this.onSearchCommits(...params)}
onSearchCommitsPromise={(...params) => this.onSearchCommitsPromise(...params)}
onSelectionChange={debounce(
(selection: { id: string; type: GitGraphRowType }[]) => this.onSelectionChanged(selection),
250,
)}
onDismissBanner={key => this.onDismissBanner(key)}
onEnsureCommit={this.onEnsureCommit.bind(this)}
onEnsureCommitPromise={this.onEnsureCommitPromise.bind(this)}
{...this.state}
/>,
$root,
@ -294,31 +295,23 @@ export class GraphApp extends App {
this.sendCommand(GetMissingAvatarsCommandType, { emails: emails });
}
private onGetMoreCommits(sha?: string, wait?: boolean) {
if (wait) {
return this.sendCommandWithCompletion(
GetMoreCommitsCommandType,
{ sha: sha },
DidChangeCommitsNotificationType,
);
}
private onGetMoreCommits(sha?: string) {
return this.sendCommand(GetMoreCommitsCommandType, { sha: sha });
}
private onSearchCommits(search: SearchPattern, wait?: boolean) {
if (wait) {
return this.sendCommandWithCompletion(
SearchCommitsCommandType,
{ search: search },
DidSearchCommitsNotificationType,
);
}
private onSearchCommits(search: SearchPattern) {
return this.sendCommand(SearchCommitsCommandType, { search: search });
}
private onEnsureCommit(id: string, select: boolean) {
private onSearchCommitsPromise(search: SearchPattern, options?: { more?: boolean | { limit?: number } }) {
return this.sendCommandWithCompletion(
SearchCommitsCommandType,
{ search: search, more: options?.more },
DidSearchCommitsNotificationType,
);
}
private onEnsureCommitPromise(id: string, select: boolean) {
return this.sendCommandWithCompletion(
EnsureCommitCommandType,
{ id: id, select: select },

+ 28
- 9
src/webviews/apps/shared/appBase.ts Переглянути файл

@ -103,15 +103,34 @@ export abstract class App {
const id = nextIpcId();
this.log(`${this.appName}.sendCommandWithCompletion(${id}): name=${command.method}`);
const promise = new Promise<IpcMessageParams<TCompletion>>(resolve => {
const disposable = DOM.on(window, 'message', (e: MessageEvent<IpcMessage>) => {
onIpc(completion, e.data, params => {
if (e.data.completionId === id) {
disposable.dispose();
resolve(params);
}
});
});
const promise = new Promise<IpcMessageParams<TCompletion>>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const disposables = [
DOM.on(window, 'message', (e: MessageEvent<IpcMessage>) => {
onIpc(completion, e.data, params => {
if (e.data.completionId === id) {
disposables.forEach(d => d.dispose());
queueMicrotask(() => resolve(params));
}
});
}),
{
dispose: function () {
if (timeout != null) {
clearTimeout(timeout);
timeout = undefined;
}
},
},
];
timeout = setTimeout(() => {
timeout = undefined;
disposables.forEach(d => d.dispose());
debugger;
reject(new Error(`Timed out waiting for completion of ${completion.method}`));
}, 60000);
});
this.postMessage({ id: id, method: command.method, params: params, completionId: id });

+ 2
- 0
src/webviews/apps/shared/components/search/react.tsx Переглянути файл

@ -8,6 +8,8 @@ const { wrap } = provideReactWrapper(React);
export const SearchField = wrap(fieldComponent, {
events: {
onChange: 'change',
onPrevious: 'previous',
onNext: 'next',
},
});

+ 16
- 0
src/webviews/apps/shared/components/search/search-field.ts Переглянути файл

@ -14,6 +14,7 @@ const template = html`
placeholder="${x => x.placeholder}"
value="${x => x.value}"
@input="${(x, c) => x.handleInput(c.event)}"
@keyup="${(x, c) => x.handleShortcutKeys(c.event as KeyboardEvent)}"
/>
<div class="controls">
<button
@ -158,14 +159,29 @@ export class SearchField extends FASTElement {
this.value = value;
this.emitSearch();
}
handleShortcutKeys(e: KeyboardEvent) {
if (e.key !== 'Enter' && e.key !== 'F3') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
if (e.shiftKey) {
this.$emit('previous');
} else {
this.$emit('next');
}
}
handleAll(_e: Event) {
this.all = !this.all;
this.emitSearch();
}
handleCase(_e: Event) {
this.case = !this.case;
this.emitSearch();
}
handleRegex(_e: Event) {
this.regex = !this.regex;
if (!this.regex) {

+ 6
- 17
src/webviews/apps/shared/components/search/search-nav.ts Переглянути файл

@ -20,16 +20,12 @@ const template = html`