From ed2e07dccec6db52ea75c9e585eb1731edd85a61 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Fri, 23 Sep 2022 02:57:26 -0400 Subject: [PATCH] Adds search result paging support Adds keyboard shortcuts for next/prev search Adds timeout for webview completion events --- src/commands/git/search.ts | 4 +- src/env/node/git/localGitProvider.ts | 45 ++++-- src/git/search.ts | 5 +- src/plus/github/githubGitProvider.ts | 7 +- src/plus/webviews/graph/graphWebview.ts | 174 +++++++++++++-------- src/plus/webviews/graph/protocol.ts | 2 + src/views/nodes/searchResultsNode.ts | 6 +- src/webviews/apps/plus/graph/GraphWrapper.tsx | 129 +++++++++------ src/webviews/apps/plus/graph/graph.tsx | 33 ++-- src/webviews/apps/shared/appBase.ts | 37 +++-- .../apps/shared/components/search/react.tsx | 2 + .../apps/shared/components/search/search-field.ts | 16 ++ .../apps/shared/components/search/search-nav.ts | 23 +-- 13 files changed, 303 insertions(+), 180 deletions(-) diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts index 6773188..bf0d372 100644 --- a/src/commands/git/search.ts +++ b/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); diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index e281c81..5d1f92e 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/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 { 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(); + let total = 0; + let iterations = 0; + async function searchForCommitsCore( this: LocalGitProvider, limit: number, cursor?: { sha: string; skip: number }, ): Promise { + 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(), }; } } @@ -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 | 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'); diff --git a/src/git/search.ts b/src/git/search.ts index 8b98fa4..3da1a61 100644 --- a/src/git/search.ts +++ b/src/git/search.ts @@ -38,7 +38,8 @@ export interface SearchPattern { export interface GitSearch { repoPath: string; pattern: SearchPattern; - results: string[]; + comparisonKey: string; + results: Set; readonly paging?: { readonly limit: number | undefined; @@ -49,7 +50,7 @@ export interface GitSearch { more?(limit: number): Promise; } -export function getKeyForSearchPattern(search: SearchPattern) { +export function getSearchPatternComparisonKey(search: SearchPattern) { return `${search.pattern}|${search.matchAll ? 'A' : ''}${search.matchCase ? 'C' : ''}${ search.matchRegex ? 'R' : '' }`; diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts index c22c522..6d26705 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/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 { search = { matchAll: false, matchCase: false, matchRegex: true, ...search }; + + const comparisonKey = getSearchPatternComparisonKey(search); return { repoPath: repoPath, pattern: search, - results: [], + comparisonKey: comparisonKey, + results: new Set(), }; // try { diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index c118d85..0d05422 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/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[] = []; - 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) { diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index 487867b..2880de4 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -97,6 +97,8 @@ export const GetMoreCommitsCommandType = new IpcCommandType('graph/searchCommits'); diff --git a/src/views/nodes/searchResultsNode.ts b/src/views/nodes/searchResultsNode.ts index fdd1c95..dea2344 100644 --- a/src/views/nodes/searchResultsNode.ts +++ b/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; diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index cb4894b..cfaf17a 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/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; + onSearchCommits?: (search: SearchPattern) => void; + onSearchCommitsPromise?: ( + search: SearchPattern, + options?: { more?: boolean | { limit?: number } }, + ) => Promise; onDismissBanner?: (key: DismissBannerParams['key']) => void; onSelectionChange?: (selection: { id: string; type: GitGraphRowType }[]) => void; - onEnsureCommit?: (id: string, select: boolean) => Promise; + onEnsureCommitPromise?: (id: string, select: boolean) => Promise; } 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(undefined); const [searchResultKey, setSearchResultKey] = useState(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 | 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) => { 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({
handleSearchInput(e as CustomEvent)} + onPrevious={() => handleSearchNavigation(false)} + onNext={() => handleSearchNavigation(true)} /> 2)} - more={hasMoreSearchIds} + total={searchResultIds?.length ?? 0} + valid={Boolean(search?.pattern && search.pattern.length > 2)} + more={hasMoreSearchResults} onPrevious={() => handleSearchNavigation(false)} onNext={() => handleSearchNavigation(true)} /> diff --git a/src/webviews/apps/plus/graph/graph.tsx b/src/webviews/apps/plus/graph/graph.tsx index ed76a52..b4218cd 100644 --- a/src/webviews/apps/plus/graph/graph.tsx +++ b/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 }, diff --git a/src/webviews/apps/shared/appBase.ts b/src/webviews/apps/shared/appBase.ts index ef143b4..8e90f3a 100644 --- a/src/webviews/apps/shared/appBase.ts +++ b/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>(resolve => { - const disposable = DOM.on(window, 'message', (e: MessageEvent) => { - onIpc(completion, e.data, params => { - if (e.data.completionId === id) { - disposable.dispose(); - resolve(params); - } - }); - }); + const promise = new Promise>((resolve, reject) => { + let timeout: ReturnType | undefined; + + const disposables = [ + DOM.on(window, 'message', (e: MessageEvent) => { + 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 }); diff --git a/src/webviews/apps/shared/components/search/react.tsx b/src/webviews/apps/shared/components/search/react.tsx index d182dae..63606b4 100644 --- a/src/webviews/apps/shared/components/search/react.tsx +++ b/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', }, }); diff --git a/src/webviews/apps/shared/components/search/search-field.ts b/src/webviews/apps/shared/components/search/search-field.ts index cd1451a..6e84ef1 100644 --- a/src/webviews/apps/shared/components/search/search-field.ts +++ b/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)}" />