diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts index 3c742fd..6a86cec 100644 --- a/src/commands/git/search.ts +++ b/src/commands/git/search.ts @@ -351,7 +351,12 @@ export class SearchGitCommand extends QuickCommand { // Simulate an extra step if we have a value state.counter = value ? 3 : 2; - const operations = parseSearchQuery(value); + const operations = parseSearchQuery({ + query: value, + matchCase: state.matchCase, + matchAll: state.matchAll, + matchRegex: state.matchRegex, + }); quickpick.title = appendReposToTitle( operations.size === 0 || operations.size > 1 diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index e54b2cb..2564e61 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -27,7 +27,7 @@ const emptyObj = Object.freeze({}); const gitBranchDefaultConfigs = Object.freeze(['-c', 'color.branch=false']); const gitDiffDefaultConfigs = Object.freeze(['-c', 'color.diff=false']); -const gitLogDefaultConfigs = Object.freeze(['-c', 'log.showSignature=false']); +export const gitLogDefaultConfigs = Object.freeze(['-c', 'log.showSignature=false']); export const gitLogDefaultConfigsWithFiles = Object.freeze([ '-c', 'log.showSignature=false', @@ -858,7 +858,13 @@ export class Git { log2( repoPath: string, - options?: { cancellation?: CancellationToken; configs?: readonly string[]; ref?: string; stdin?: string }, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + ref?: string; + errors?: GitErrorHandling; + stdin?: string; + }, ...args: string[] ) { return this.git( @@ -866,6 +872,7 @@ export class Git { cwd: repoPath, cancellation: options?.cancellation, configs: options?.configs ?? gitLogDefaultConfigs, + errors: options?.errors, stdin: options?.stdin, }, 'log', @@ -1195,11 +1202,13 @@ export class Git { } return this.git( - { cwd: repoPath, configs: gitLogDefaultConfigs }, + { cwd: repoPath, configs: ['-C', repoPath, ...gitLogDefaultConfigs] }, 'log', '--name-status', `--format=${GitLogParser.defaultFormat}`, '--use-mailmap', + '--full-history', + '-m', ...(options?.limit ? [`-n${options.limit + 1}`] : emptyArray), ...(options?.skip ? [`--skip=${options.skip}`] : emptyArray), ...(options?.ordering ? [`--${options.ordering}-order`] : emptyArray), diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 2ccbd6d..38e1b42 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -18,7 +18,9 @@ import { CoreGitConfiguration, GlyphChars, Schemes } from '../../../constants'; import type { Container } from '../../../container'; import { emojify } from '../../../emojis'; import { Features } from '../../../features'; +import { GitErrorHandling } from '../../../git/commandOptions'; import { + GitSearchError, StashApplyError, StashApplyErrorReason, WorktreeCreateError, @@ -108,7 +110,7 @@ import type { RemoteProvider } from '../../../git/remotes/remoteProvider'; import type { RemoteProviders } from '../../../git/remotes/remoteProviders'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../git/remotes/remoteProviders'; import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider'; -import type { GitSearch, SearchQuery } from '../../../git/search'; +import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../../git/search'; import { getGitArgsFromSearchQuery, getSearchQueryComparisonKey } from '../../../git/search'; import { Logger } from '../../../logger'; import type { LogScope } from '../../../logger'; @@ -146,11 +148,12 @@ import { serializeWebviewItemContext } from '../../../system/webview'; import type { CachedBlame, CachedDiff, CachedLog, TrackedDocument } from '../../../trackers/gitDocumentTracker'; import { GitDocumentState } from '../../../trackers/gitDocumentTracker'; import type { Git } from './git'; -import { GitErrors, gitLogDefaultConfigsWithFiles, maxGitCliLength } from './git'; +import { GitErrors, gitLogDefaultConfigs, gitLogDefaultConfigsWithFiles, maxGitCliLength } from './git'; import type { GitLocation } from './locator'; import { findGitPath, InvalidGitConfigError, UnableToFindGitError } from './locator'; -import { fsExists, RunError } from './shell'; +import { CancelledRunError, fsExists, RunError } from './shell'; +const emptyArray = Object.freeze([]) as unknown as any[]; const emptyPromise: Promise = Promise.resolve(undefined); const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); const slash = 47; @@ -1660,7 +1663,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const avatars = new Map(); const ids = new Set(); const reachableFromHEAD = new Set(); - const skipStashParents = new Set(); + const skippedIds = new Set(); let total = 0; let iterations = 0; @@ -1764,7 +1767,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (ids.has(commit.sha)) continue; total++; - if (skipStashParents.has(commit.sha)) continue; + if (skippedIds.has(commit.sha)) continue; ids.add(commit.sha); @@ -1876,9 +1879,9 @@ export class LocalGitProvider implements GitProvider, Disposable { // Remove the second & third parent, if exists, from each stash commit as it is a Git implementation for the index and untracked files if (stashCommit != null && parents.length > 1) { // Skip the "index commit" (e.g. contains staged files) of the stash - skipStashParents.add(parents[1]); + skippedIds.add(parents[1]); // Skip the "untracked commit" (e.g. contains untracked files) of the stash - skipStashParents.add(parents[2]); + skippedIds.add(parents[2]); parents.splice(1, 2); } @@ -1959,8 +1962,9 @@ export class LocalGitProvider implements GitProvider, Disposable { repoPath: repoPath, avatars: avatars, ids: ids, + skippedIds: skippedIds, rows: rows, - sha: sha, + id: sha, paging: { limit: limit === 0 ? count : limit, @@ -4225,7 +4229,11 @@ export class LocalGitProvider implements GitProvider, Disposable { async searchCommits( repoPath: string, search: SearchQuery, - options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' }, + options?: { + cancellation?: CancellationToken; + limit?: number; + ordering?: 'date' | 'author-date' | 'topo'; + }, ): Promise { search = { matchAll: false, matchCase: false, matchRegex: true, ...search }; @@ -4245,10 +4253,14 @@ export class LocalGitProvider implements GitProvider, Disposable { '--', ); - const results = new Map( + let i = 0; + const results: GitSearchResults = new Map( map(refAndDateParser.parse(data), c => [ c.sha, - Number(options?.ordering === 'author-date' ? c.authorDate : c.committerDate) * 1000, + { + i: i++, + date: Number(options?.ordering === 'author-date' ? c.authorDate : c.committerDate) * 1000, + }, ]), ); @@ -4278,59 +4290,69 @@ export class LocalGitProvider implements GitProvider, Disposable { `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, '--use-mailmap', ]; - if (options?.ordering) { - args.push(`--${options.ordering}-order`); - } - const results = new Map(); + const results: GitSearchResults = new Map(); 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, query: search, comparisonKey: comparisonKey, results: results }; } - const data = await this.git.log2( - repoPath, - { cancellation: options?.cancellation, stdin: stdin }, - ...args, - ...(cursor?.skip ? [`--skip=${cursor.skip}`] : []), - ...(limit ? [`-n${limit + 1}`] : []), - ...searchArgs, - '--', - ...files, - ); + let data; + try { + data = await this.git.log2( + repoPath, + { + cancellation: options?.cancellation, + configs: ['-C', repoPath, ...gitLogDefaultConfigs], + errors: GitErrorHandling.Throw, + stdin: stdin, + }, + ...args, + ...searchArgs, + ...(options?.ordering ? [`--${options.ordering}-order`] : emptyArray), + ...(limit ? [`-n${limit + 1}`] : emptyArray), + ...(cursor?.skip ? [`--skip=${cursor.skip}`] : emptyArray), + '--', + ...files, + ); + } catch (ex) { + if (ex instanceof CancelledRunError || options?.cancellation?.isCancellationRequested) { + return { repoPath: repoPath, query: search, comparisonKey: comparisonKey, results: results }; + } + + throw new GitSearchError(ex); + } if (options?.cancellation?.isCancellationRequested) { - // TODO@eamodio: Should we throw an error here? return { repoPath: repoPath, query: search, comparisonKey: comparisonKey, results: results }; } - let count = 0; - for (const r of refAndDateParser.parse(data)) { - results.set( - r.sha, - Number(options?.ordering === 'author-date' ? r.authorDate : r.committerDate) * 1000, - ); + let count = total; - count++; + for (const r of refAndDateParser.parse(data)) { + if (results.has(r.sha)) { + limit--; + continue; + } + results.set(r.sha, { + i: total++, + date: Number(options?.ordering === 'author-date' ? r.authorDate : r.committerDate) * 1000, + }); } - total += count; + count = total - count; const lastSha = last(results)?.[0]; cursor = lastSha != null ? { sha: lastSha, - skip: total - iterations, + skip: total, } : undefined; @@ -4352,14 +4374,10 @@ export class LocalGitProvider implements GitProvider, Disposable { return searchForCommitsCore.call(this, limit); } catch (ex) { - // TODO@eamodio: Should we throw an error here? - // TODO@eamodio handle error reporting -- just invalid queries? or more detailed? - return { - repoPath: repoPath, - query: search, - comparisonKey: comparisonKey, - results: new Map(), - }; + if (ex instanceof GitSearchError) { + throw ex; + } + throw new GitSearchError(ex); } } diff --git a/src/git/errors.ts b/src/git/errors.ts index 03f9398..ec4919f 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -1,3 +1,11 @@ +export class GitSearchError extends Error { + constructor(public readonly original: Error) { + super(original.message); + + Error.captureStackTrace?.(this, GitSearchError); + } +} + export const enum StashApplyErrorReason { WorkingChanges = 1, } diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 4aaa3f4..5b7fbb1 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -415,7 +415,11 @@ export interface GitProvider extends Disposable { searchCommits( repoPath: string | Uri, search: SearchQuery, - options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' }, + options?: { + cancellation?: CancellationToken; + limit?: number; + ordering?: 'date' | 'author-date' | 'topo'; + }, ): Promise; validateBranchOrTagName(repoPath: string, ref: string): Promise; validateReference(repoPath: string, ref: string): Promise; diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 64f6f18..f5faf65 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -2263,7 +2263,11 @@ export class GitProviderService implements Disposable { searchCommits( repoPath: string | Uri, search: SearchQuery, - options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' }, + options?: { + cancellation?: CancellationToken; + limit?: number; + ordering?: 'date' | 'author-date' | 'topo'; + }, ): Promise { const { provider, path } = this.getProvider(repoPath); return provider.searchCommits(path, search, options); diff --git a/src/git/models/graph.ts b/src/git/models/graph.ts index 000f4f7..c7b365a 100644 --- a/src/git/models/graph.ts +++ b/src/git/models/graph.ts @@ -27,9 +27,11 @@ export interface GitGraph { readonly avatars: Map; /** A set of all "seen" commit ids */ readonly ids: Set; + /** A set of all skipped commit ids -- typically for stash index/untracked commits */ + readonly skippedIds?: Set; /** The rows for the set of commits requested */ readonly rows: GitGraphRow[]; - readonly sha?: string; + readonly id?: string; readonly paging?: { readonly limit: number | undefined; @@ -37,5 +39,5 @@ export interface GitGraph { readonly hasMore: boolean; }; - more?(limit: number, sha?: string): Promise; + more?(limit: number, id?: string): Promise; } diff --git a/src/git/search.ts b/src/git/search.ts index 66597b6..8f8625d 100644 --- a/src/git/search.ts +++ b/src/git/search.ts @@ -43,11 +43,17 @@ export interface StoredSearchQuery { matchRegex?: boolean; } +export interface GitSearchResultData { + date: number; + i: number; +} +export type GitSearchResults = Map; + export interface GitSearch { repoPath: string; query: SearchQuery; comparisonKey: string; - results: Map; + results: GitSearchResults; readonly paging?: { readonly limit: number | undefined; @@ -108,9 +114,9 @@ const normalizeSearchOperatorsMap = new Map([ ]); const searchOperationRegex = - /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?".+?"|\S+\b}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi; + /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?".+?"|\S+}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi; -export function parseSearchQuery(query: string): Map { +export function parseSearchQuery(search: SearchQuery): Map { const operations = new Map(); let op: SearchOperators | undefined; @@ -119,7 +125,7 @@ export function parseSearchQuery(query: string): Map { let match; do { - match = searchOperationRegex.exec(query); + match = searchOperationRegex.exec(search.query); if (match?.groups == null) break; op = normalizeSearchOperatorsMap.get(match.groups.op as SearchOperators); @@ -150,7 +156,7 @@ export function getGitArgsFromSearchQuery(search: SearchQuery): { files: string[]; shas?: Set | undefined; } { - const operations = parseSearchQuery(search.query); + const operations = parseSearchQuery(search); const searchArgs = new Set(); const files: string[] = []; @@ -160,14 +166,12 @@ export function getGitArgsFromSearchQuery(search: SearchQuery): { let op; let values = operations.get('commit:'); if (values != null) { - // searchArgs.add('-m'); for (const value of values) { searchArgs.add(value.replace(doubleQuoteRegex, '')); } shas = searchArgs; } else { searchArgs.add('--all'); - searchArgs.add('--full-history'); searchArgs.add(search.matchRegex ? '--extended-regexp' : '--fixed-strings'); if (search.matchRegex && !search.matchCase) { searchArgs.add('--regexp-ignore-case'); @@ -176,38 +180,58 @@ export function getGitArgsFromSearchQuery(search: SearchQuery): { for ([op, values] of operations.entries()) { switch (op) { case 'message:': - searchArgs.add('-m'); if (search.matchAll) { searchArgs.add('--all-match'); } - for (const value of values) { - searchArgs.add(`--grep=${value.replace(doubleQuoteRegex, search.matchRegex ? '\\b' : '')}`); + for (let value of values) { + if (!value) continue; + value = value.replace(doubleQuoteRegex, search.matchRegex ? '\\b' : ''); + if (!value) continue; + + searchArgs.add(`--grep=${value}`); } break; case 'author:': - searchArgs.add('-m'); - for (const value of values) { - searchArgs.add(`--author=${value.replace(doubleQuoteRegex, search.matchRegex ? '\\b' : '')}`); + for (let value of values) { + if (!value) continue; + value = value.replace(doubleQuoteRegex, search.matchRegex ? '\\b' : ''); + if (!value) continue; + + searchArgs.add(`--author=${value}`); } break; case 'change:': - for (const value of values) { - searchArgs.add( - search.matchRegex - ? `-G${value.replace(doubleQuoteRegex, '')}` - : `-S${value.replace(doubleQuoteRegex, '')}`, - ); + for (let value of values) { + if (!value) continue; + + if (value.startsWith('"')) { + value = value.replace(doubleQuoteRegex, ''); + if (!value) continue; + + searchArgs.add(search.matchRegex ? `-G${value}` : `-S${value}`); + } else { + searchArgs.add(search.matchRegex ? `-G"${value}"` : `-S"${value}"`); + } } break; case 'file:': - for (const value of values) { - files.push(value.replace(doubleQuoteRegex, '')); + for (let value of values) { + if (!value) continue; + + if (value.startsWith('"')) { + value = value.replace(doubleQuoteRegex, ''); + if (!value) continue; + + files.push(value); + } else { + files.push(`${search.matchCase ? '' : ':(icase)'}${value}`); + } } break; diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts index 5d838ec..6941bac 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/github/githubGitProvider.ts @@ -24,6 +24,7 @@ import { OpenVirtualRepositoryErrorReason, } from '../../errors'; import { Features } from '../../features'; +import { GitSearchError } from '../../git/errors'; import type { GitProvider, NextComparisonUrisResult, @@ -73,7 +74,7 @@ import type { RemoteProvider } from '../../git/remotes/remoteProvider'; import type { RemoteProviders } from '../../git/remotes/remoteProviders'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders'; import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; -import type { GitSearch, SearchQuery } from '../../git/search'; +import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../git/search'; import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search'; import type { LogScope } from '../../logger'; import { Logger } from '../../logger'; @@ -1214,7 +1215,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { avatars: avatars, ids: ids, rows: rows, - sha: options?.ref, + id: options?.ref, paging: { limit: log.limit, @@ -2476,7 +2477,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { const scope = getLogScope(); - const operations = parseSearchQuery(search.query); + const operations = parseSearchQuery(search); let op; let values = operations.get('commit:'); @@ -2660,7 +2661,11 @@ export class GitHubGitProvider implements GitProvider, Disposable { async searchCommits( repoPath: string, search: SearchQuery, - options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' }, + options?: { + cancellation?: CancellationToken; + limit?: number; + ordering?: 'date' | 'author-date' | 'topo'; + }, ): Promise { // const scope = getLogScope(); search = { matchAll: false, matchCase: false, matchRegex: true, ...search }; @@ -2668,8 +2673,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { const comparisonKey = getSearchQueryComparisonKey(search); try { - const results = new Map(); - const operations = parseSearchQuery(search.query); + const results: GitSearchResults = new Map(); + const operations = parseSearchQuery(search); let op; let values = operations.get('commit:'); @@ -2677,14 +2682,16 @@ export class GitHubGitProvider implements GitProvider, Disposable { const commitsResults = await Promise.allSettled[]>( values.map(v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))), ); + + let i = 0; for (const commitResult of commitsResults) { const commit = getSettledValue(commitResult); if (commit == null) continue; - results.set( - commit.sha, - Number(options?.ordering === 'author-date' ? commit.author.date : commit.committer.date), - ); + results.set(commit.sha, { + i: i++, + date: Number(options?.ordering === 'author-date' ? commit.author.date : commit.committer.date), + }); } return { @@ -2740,7 +2747,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { cursor?: string, ): Promise { if (options?.cancellation?.isCancellationRequested) { - // TODO@eamodio: Should we throw an error here? return { repoPath: repoPath, query: search, comparisonKey: comparisonKey, results: results }; } @@ -2757,15 +2763,14 @@ export class GitHubGitProvider implements GitProvider, Disposable { }); if (result == null || options?.cancellation?.isCancellationRequested) { - // TODO@eamodio: Should we throw an error if cancelled? return { repoPath: repoPath, query: search, comparisonKey: comparisonKey, results: results }; } for (const commit of result.values) { - results.set( - commit.sha, - Number(options?.ordering === 'author-date' ? commit.authorDate : commit.committerDate), - ); + results.set(commit.sha, { + i: results.size - 1, + date: Number(options?.ordering === 'author-date' ? commit.authorDate : commit.committerDate), + }); } cursor = result.pageInfo?.endCursor ?? undefined; @@ -2787,14 +2792,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { return searchForCommitsCore.call(this, options?.limit); } catch (ex) { - // TODO@eamodio: Should we throw an error here? - // TODO@eamodio handle error reporting -- just invalid queries? or more detailed? - return { - repoPath: repoPath, - query: search, - comparisonKey: comparisonKey, - results: new Map(), - }; + if (ex instanceof GitSearchError) { + throw ex; + } + throw new GitSearchError(ex); } } diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index a2654e2..76ec91e 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -30,6 +30,7 @@ import { Commands, ContextKeys, CoreGitCommands } from '../../../constants'; import type { Container } from '../../../container'; import { getContext, onDidChangeContext, setContext } from '../../../context'; import { PlusFeatures } from '../../../features'; +import { GitSearchError } from '../../../git/errors'; import type { GitCommit } from '../../../git/models/commit'; import { GitGraphRowType } from '../../../git/models/graph'; import type { GitGraph } from '../../../git/models/graph'; @@ -40,9 +41,12 @@ import type { GitTagReference, } from '../../../git/models/reference'; import { GitReference, GitRevision } from '../../../git/models/reference'; -import type { Repository, RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../../git/models/repository'; +import type { + Repository, + RepositoryChangeEvent, + RepositoryFileSystemChangeEvent, +} from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; -import type { GitStatus } from '../../../git/models/status'; import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey } from '../../../git/search'; import { executeActionCommand, executeCommand, executeCoreGitCommand, registerCommand } from '../../../system/command'; @@ -50,8 +54,9 @@ import { gate } from '../../../system/decorators/gate'; import { debug } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; import { debounce, once } from '../../../system/function'; -import { first, last } from '../../../system/iterable'; +import { last } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; +import { getSettledValue } from '../../../system/promise'; import { isDarkTheme, isLightTheme } from '../../../system/utils'; import type { WebviewItemContext } from '../../../system/webview'; import { isWebviewItemContext, serializeWebviewItemContext } from '../../../system/webview'; @@ -68,17 +73,18 @@ import type { SubscriptionChangeEvent } from '../../subscription/subscriptionSer import { arePlusFeaturesEnabled, ensurePlusFeaturesEnabled } from '../../subscription/utils'; import type { DismissBannerParams, - EnsureCommitParams, + EnsureRowParams, GetMissingAvatarsParams, - GetMoreCommitsParams, + GetMoreRowsParams, GraphColumnConfig, GraphColumnName, GraphColumnsSettings, GraphComponentConfig, GraphRepository, - GraphWorkDirStats, - SearchCommitsParams, + GraphSelectedRows, + GraphWorkingTreeStats, SearchOpenInViewParams, + SearchParams, State, UpdateColumnParams, UpdateSelectedRepositoryParams, @@ -87,19 +93,19 @@ import type { import { DidChangeAvatarsNotificationType, DidChangeColumnsNotificationType, - DidChangeCommitsNotificationType, DidChangeGraphConfigurationNotificationType, DidChangeNotificationType, + DidChangeRowsNotificationType, DidChangeSelectionNotificationType, DidChangeSubscriptionNotificationType, - DidChangeWorkDirStatsNotificationType, - DidEnsureCommitNotificationType, - DidSearchCommitsNotificationType, + DidChangeWorkingTreeNotificationType, + DidEnsureRowNotificationType, + DidSearchNotificationType, DismissBannerCommandType, - EnsureCommitCommandType, + EnsureRowCommandType, GetMissingAvatarsCommandType, - GetMoreCommitsCommandType, - SearchCommitsCommandType, + GetMoreRowsCommandType, + SearchCommandType, SearchOpenInViewCommandType, UpdateColumnCommandType, UpdateSelectedRepositoryCommandType, @@ -150,7 +156,9 @@ export class GraphWebview extends WebviewBase { ); } - this.updateState(); + if (this.isReady) { + this.updateState(); + } } private _selection: readonly GitCommit[] | undefined; @@ -164,8 +172,8 @@ export class GraphWebview extends WebviewBase { private _pendingIpcNotifications = new Map Promise)>(); private _search: GitSearch | undefined; private _searchCancellation: CancellationTokenSource | undefined; - private _selectedSha?: string; - private _selectedRows: { [sha: string]: true } = {}; + private _selectedId?: string; + private _selectedRows: GraphSelectedRows | undefined; private _repositoryEventsDisposable: Disposable | undefined; private _statusBarItem: StatusBarItem | undefined; @@ -198,26 +206,26 @@ export class GraphWebview extends WebviewBase { args: ShowInCommitGraphCommandArgs | BranchNode | CommitNode | CommitFileNode | StashNode | TagNode, ) => { this.repository = this.container.git.getRepository(args.ref.repoPath); - let sha = args.ref.ref; - if (!GitRevision.isSha(sha)) { - sha = await this.container.git.resolveReference(args.ref.repoPath, sha, undefined, { + let id = args.ref.ref; + if (!GitRevision.isSha(id)) { + id = await this.container.git.resolveReference(args.ref.repoPath, id, undefined, { force: true, }); } - this.setSelectedRows(sha); + this.setSelectedRows(id); const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; if (this._panel == null) { void this.show({ preserveFocus: preserveFocus }); } else { this._panel.reveal(this._panel.viewColumn ?? ViewColumn.Active, preserveFocus ?? false); - if (this._graph?.ids.has(sha)) { + if (this._graph?.ids.has(id)) { void this.notifyDidChangeSelection(); return; } - this.setSelectedRows(sha); - void this.onGetMoreCommits({ sha: sha }); + this.setSelectedRows(id); + void this.onGetMoreRows({ id: id }, true); } }, ), @@ -259,6 +267,9 @@ export class GraphWebview extends WebviewBase { protected override refresh(force?: boolean): Promise { this.resetRepositoryState(); + if (force) { + this._pendingIpcNotifications.clear(); + } return super.refresh(force); } @@ -332,17 +343,17 @@ export class GraphWebview extends WebviewBase { case DismissBannerCommandType.method: onIpc(DismissBannerCommandType, e, params => this.dismissBanner(params)); break; - case EnsureCommitCommandType.method: - onIpc(EnsureCommitCommandType, e, params => this.onEnsureCommit(params, e.completionId)); + case EnsureRowCommandType.method: + onIpc(EnsureRowCommandType, e, params => this.onEnsureRow(params, e.completionId)); break; case GetMissingAvatarsCommandType.method: onIpc(GetMissingAvatarsCommandType, e, params => this.onGetMissingAvatars(params)); break; - case GetMoreCommitsCommandType.method: - onIpc(GetMoreCommitsCommandType, e, params => this.onGetMoreCommits(params)); + case GetMoreRowsCommandType.method: + onIpc(GetMoreRowsCommandType, e, params => this.onGetMoreRows(params)); break; - case SearchCommitsCommandType.method: - onIpc(SearchCommitsCommandType, e, params => this.onSearchCommits(params, e.completionId)); + case SearchCommandType.method: + onIpc(SearchCommandType, e, params => this.onSearch(params, e.completionId)); break; case SearchOpenInViewCommandType.method: onIpc(SearchOpenInViewCommandType, e, params => this.onSearchOpenInView(params)); @@ -354,7 +365,7 @@ export class GraphWebview extends WebviewBase { onIpc(UpdateSelectedRepositoryCommandType, e, params => this.onRepositorySelectionChanged(params)); break; case UpdateSelectionCommandType.method: - onIpc(UpdateSelectionCommandType, e, debounce(this.onSelectionChanged.bind(this), 100)); + onIpc(UpdateSelectionCommandType, e, this.onSelectionChanged.bind(this)); break; } } @@ -380,7 +391,9 @@ export class GraphWebview extends WebviewBase { return; } - this.sendPendingIpcNotifications(); + if (this.isReady && visible) { + this.sendPendingIpcNotifications(); + } } private onConfigurationChanged(e: ConfigurationChangeEvent) { @@ -433,6 +446,11 @@ export class GraphWebview extends WebviewBase { this.updateState(); } + private onRepositoryFileSystemChanged(e: RepositoryFileSystemChangeEvent) { + if (e.repository?.path !== this.repository?.path) return; + void this.notifyDidChangeWorkingTree(); + } + private onSubscriptionChanged(e: SubscriptionChangeEvent) { if (e.etag === this._etagSubscription) return; @@ -472,30 +490,30 @@ export class GraphWebview extends WebviewBase { } @debug() - private async onEnsureCommit(e: EnsureCommitParams, completionId?: string) { + private async onEnsureRow(e: EnsureRowParams, completionId?: string) { if (this._graph?.more == null || this._repository?.etag !== this._etagRepository) { this.updateState(true); if (completionId != null) { - void this.notify(DidEnsureCommitNotificationType, {}, completionId); + void this.notify(DidEnsureRowNotificationType, {}, 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); + let id: string | undefined; + if (!this._graph.skippedIds?.has(e.id)) { + if (this._graph.ids.has(e.id)) { + id = e.id; + } else { + await this.updateGraphWithMoreRows(this._graph, e.id, this._search); + void this.notifyDidChangeRows(); + if (this._graph.ids.has(e.id)) { + id = e.id; + } } - void this.notifyDidChangeCommits(); - } else if (e.select) { - selected = true; - this.setSelectedRows(e.id); } - void this.notify(DidEnsureCommitNotificationType, { id: e.id, selected: selected }, completionId); + void this.notify(DidEnsureRowNotificationType, { id: id }, completionId); } private async onGetMissingAvatars(e: GetMissingAvatarsParams) { @@ -503,17 +521,17 @@ export class GraphWebview extends WebviewBase { const repoPath = this._graph.repoPath; - async function getAvatar(this: GraphWebview, email: string, sha: string) { - const uri = await getAvatarUri(email, { ref: sha, repoPath: repoPath }); + async function getAvatar(this: GraphWebview, email: string, id: string) { + const uri = await getAvatarUri(email, { ref: id, repoPath: repoPath }); this._graph!.avatars.set(email, uri.toString(true)); } const promises: Promise[] = []; - for (const [email, sha] of Object.entries(e.emails)) { + for (const [email, id] of Object.entries(e.emails)) { if (this._graph.avatars.has(email)) continue; - promises.push(getAvatar.call(this, email, sha)); + promises.push(getAvatar.call(this, email, id)); } if (promises.length) { @@ -524,19 +542,19 @@ export class GraphWebview extends WebviewBase { @gate() @debug() - private async onGetMoreCommits(e: GetMoreCommitsParams) { + private async onGetMoreRows(e: GetMoreRowsParams, sendSelectedRows: boolean = false) { if (this._graph?.more == null || this._repository?.etag !== this._etagRepository) { this.updateState(true); return; } - await this.updateGraphWithMoreCommits(this._graph, e.sha); - void this.notifyDidChangeCommits(); + await this.updateGraphWithMoreRows(this._graph, e.id, this._search); + void this.notifyDidChangeRows(sendSelectedRows); } @debug() - private async onSearchCommits(e: SearchCommitsParams, completionId?: string) { + private async onSearch(e: SearchParams, completionId?: string) { if (e.search == null) { this.resetSearchState(); @@ -553,15 +571,19 @@ export class GraphWebview extends WebviewBase { search = await search.more(e.limit ?? configuration.get('graph.searchItemLimit') ?? 100); if (search != null) { this._search = search; + void (await this.ensureSearchStartsInRange(this._graph!, search)); void this.notify( - DidSearchCommitsNotificationType, + DidSearchNotificationType, { - results: { - ids: Object.fromEntries(search.results), - paging: { hasMore: search.paging?.hasMore ?? false }, - }, - selectedRows: this._selectedRows, + results: + search.results.size > 0 + ? { + ids: Object.fromEntries(search.results), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + } + : undefined, }, completionId, ); @@ -585,15 +607,30 @@ export class GraphWebview extends WebviewBase { const cancellation = new CancellationTokenSource(); this._searchCancellation = cancellation; - search = await this._repository.searchCommits(e.search, { - limit: configuration.get('graph.searchItemLimit') ?? 100, - ordering: configuration.get('graph.commitOrdering'), - cancellation: cancellation.token, - }); + try { + search = await this._repository.searchCommits(e.search, { + limit: configuration.get('graph.searchItemLimit') ?? 100, + ordering: configuration.get('graph.commitOrdering'), + cancellation: cancellation.token, + }); + } catch (ex) { + this._search = undefined; + + void this.notify( + DidSearchNotificationType, + { + results: { + error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', + }, + }, + completionId, + ); + return; + } if (cancellation.token.isCancellationRequested) { if (completionId != null) { - void this.notify(DidSearchCommitsNotificationType, { results: undefined }, completionId); + void this.notify(DidSearchNotificationType, { results: undefined }, completionId); } return; } @@ -603,18 +640,26 @@ export class GraphWebview extends WebviewBase { search = this._search!; } - if (search.results.size > 0) { - this.setSelectedRows(first(search.results)![0]); + const firstResult = await this.ensureSearchStartsInRange(this._graph!, search); + + let sendSelectedRows = false; + if (firstResult != null) { + sendSelectedRows = true; + this.setSelectedRows(firstResult); } void this.notify( - DidSearchCommitsNotificationType, + DidSearchNotificationType, { - results: { - ids: Object.fromEntries(search.results), - paging: { hasMore: search.paging?.hasMore ?? false }, - }, - selectedRows: this._selectedRows, + results: + search.results.size === 0 + ? { count: 0 } + : { + ids: Object.fromEntries(search.results), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + }, + selectedRows: sendSelectedRows ? this._selectedRows : undefined, }, completionId, ); @@ -633,29 +678,34 @@ export class GraphWebview extends WebviewBase { }); } - private onRepositoryFileSystemChanged(e: RepositoryFileSystemChangeEvent) { - if (!(e.repository?.path === this.repository?.path)) return; - this.updateWorkDirStats(); - } - private onRepositorySelectionChanged(e: UpdateSelectedRepositoryParams) { this.repository = this.container.git.getRepository(e.path); } - private async onSelectionChanged(e: UpdateSelectionParams) { + private _fireSelectionChangedDebounced: Deferrable | undefined = undefined; + + private onSelectionChanged(e: UpdateSelectionParams) { const item = e.selection[0]; this.setSelectedRows(item?.id); + if (this._fireSelectionChangedDebounced == null) { + this._fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 250); + } + + void this._fireSelectionChangedDebounced(item?.id, item?.type); + } + + private async fireSelectionChanged(id: string | undefined, type: GitGraphRowType | undefined) { let commits: GitCommit[] | undefined; - if (item?.id != null) { + if (id != null) { let commit; - if (item.type === GitGraphRowType.Stash) { + if (type === GitGraphRowType.Stash) { const stash = await this.repository?.getStash(); - commit = stash?.commits.get(item.id); - } else if (item.type === GitGraphRowType.Working) { - commit = await this.repository?.getCommit('0000000000000000000000000000000000000000'); + commit = stash?.commits.get(id); + } else if (type === GitGraphRowType.Working) { + commit = await this.repository?.getCommit(GitRevision.uncommitted); } else { - commit = await this.repository?.getCommit(item?.id); + commit = await this.repository?.getCommit(id); } if (commit != null) { commits = [commit]; @@ -670,7 +720,7 @@ export class GraphWebview extends WebviewBase { void GitActions.Commit.showDetailsView(commits[0], { pin: true, preserveFocus: true }); } - private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + private _notifyDidChangeStateDebounced: Deferrable | undefined = undefined; @debug() private updateState(immediate: boolean = false) { @@ -685,10 +735,11 @@ export class GraphWebview extends WebviewBase { this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); } - this._notifyDidChangeStateDebounced(); + void this._notifyDidChangeStateDebounced(); } - private _notifyDidChangeAvatarsDebounced: Deferrable<() => void> | undefined = undefined; + private _notifyDidChangeAvatarsDebounced: Deferrable | undefined = + undefined; @debug() private updateAvatars(immediate: boolean = false) { @@ -701,25 +752,7 @@ export class GraphWebview extends WebviewBase { this._notifyDidChangeAvatarsDebounced = debounce(this.notifyDidChangeAvatars.bind(this), 100); } - this._notifyDidChangeAvatarsDebounced(); - } - - private _notifyDidChangeWorkDirStatsDebounced: Deferrable<() => void> | undefined = undefined; - - @debug() - private updateWorkDirStats(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - - if (immediate) { - void this.notifyDidChangeWorkDirStats(); - return; - } - - if (this._notifyDidChangeWorkDirStatsDebounced == null) { - this._notifyDidChangeWorkDirStatsDebounced = debounce(this.notifyDidChangeWorkDirStats.bind(this), 500); - } - - this._notifyDidChangeWorkDirStatsDebounced(); + void this._notifyDidChangeAvatarsDebounced(); } @debug() @@ -759,16 +792,16 @@ export class GraphWebview extends WebviewBase { } @debug() - private async notifyDidChangeCommits(completionId?: string) { + private async notifyDidChangeRows(sendSelectedRows: boolean = false, completionId?: string) { if (this._graph == null) return; const data = this._graph; return this.notify( - DidChangeCommitsNotificationType, + DidChangeRowsNotificationType, { rows: data.rows, avatars: Object.fromEntries(data.avatars), - selectedRows: this._selectedRows, + selectedRows: sendSelectedRows ? this._selectedRows : undefined, paging: { startingCursor: data.paging?.startingCursor, hasMore: data.paging?.hasMore ?? false, @@ -779,11 +812,14 @@ export class GraphWebview extends WebviewBase { } @debug() - private async notifyDidChangeWorkDirStats() { - if (!this.isReady || !this.visible) return false; + private async notifyDidChangeWorkingTree() { + if (!this.isReady || !this.visible) { + this.addPendingIpcNotification(DidChangeWorkingTreeNotificationType); + return false; + } - return this.notify(DidChangeWorkDirStatsNotificationType, { - workDirStats: await this.getWorkDirStats() ?? {added: 0, deleted: 0, modified: 0 }, + return this.notify(DidChangeWorkingTreeNotificationType, { + stats: (await this.getWorkingTreeStats()) ?? { added: 0, deleted: 0, modified: 0 }, }); } @@ -795,7 +831,7 @@ export class GraphWebview extends WebviewBase { } return this.notify(DidChangeSelectionNotificationType, { - selection: this._selectedRows, + selection: this._selectedRows ?? {}, }); } @@ -806,7 +842,7 @@ export class GraphWebview extends WebviewBase { return false; } - const access = await this.getGraphAccess(); + const [access] = await this.getGraphAccess(); return this.notify(DidChangeSubscriptionNotificationType, { subscription: access.subscription.current, allowed: access.allowed !== false, @@ -849,6 +885,7 @@ export class GraphWebview extends WebviewBase { [DidChangeNotificationType, this.notifyDidChangeState], [DidChangeSelectionNotificationType, this.notifyDidChangeSelection], [DidChangeSubscriptionNotificationType, this.notifyDidChangeSubscription], + [DidChangeWorkingTreeNotificationType, this.notifyDidChangeWorkingTree], ]); private addPendingIpcNotification(type: IpcNotificationType, msg?: IpcMessage) { @@ -885,6 +922,26 @@ export class GraphWebview extends WebviewBase { } } + private async ensureSearchStartsInRange(graph: GitGraph, search: GitSearch) { + if (search.results.size === 0) return undefined; + + let firstResult: string | undefined; + for (const id of search.results.keys()) { + if (graph.ids.has(id)) return id; + if (graph.skippedIds?.has(id)) continue; + + firstResult = id; + break; + } + + if (firstResult == null) return undefined; + + await this.updateGraphWithMoreRows(graph, firstResult); + void this.notifyDidChangeRows(); + + return graph.ids.has(firstResult) ? firstResult : undefined; + } + private getColumns(): Record | undefined { return this.container.storage.getWorkspace('graph:columns'); } @@ -933,12 +990,30 @@ export class GraphWebview extends WebviewBase { enableMultiSelection: false, highlightRowsOnRefHover: configuration.get('graph.highlightRowsOnRefHover'), showGhostRefsOnRowHover: configuration.get('graph.showGhostRefsOnRowHover'), - shaLength: configuration.get('advanced.abbreviatedShaLength'), + idLength: configuration.get('advanced.abbreviatedShaLength'), }; return config; } - private async getWorkDirStats(): Promise { + private async getGraphAccess() { + let access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); + this._etagSubscription = this.container.subscription.etag; + + // If we don't have access to GitLens+, but the preview trial hasn't been started, auto-start it + if (access.allowed === false && access.subscription.current.previewTrial == null) { + await this.container.subscription.startPreviewTrial(true); + access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); + } + + let visibility = access?.visibility; + if (visibility == null && this.repository != null) { + visibility = await this.container.git.visibility(this.repository?.path); + } + + return [access, visibility] as const; + } + + private async getWorkingTreeStats(): Promise { if (this.container.git.repositoryCount === 0) return undefined; if (this.repository == null) { @@ -946,7 +1021,7 @@ export class GraphWebview extends WebviewBase { if (this.repository == null) return undefined; } - const status: GitStatus | undefined = await this.container.git.getStatusForRepo(this.repository.path); + const status = await this.container.git.getStatusForRepo(this.repository.path); const workingTreeStatus = status?.getDiffStatus(); return { added: workingTreeStatus?.added ?? 0, @@ -955,18 +1030,6 @@ export class GraphWebview extends WebviewBase { }; } - private async getGraphAccess() { - let access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); - this._etagSubscription = this.container.subscription.etag; - - // If we don't have access to GitLens+, but the preview trial hasn't been started, auto-start it - if (access.allowed === false && access.subscription.current.previewTrial == null) { - await this.container.subscription.startPreviewTrial(true); - access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); - } - return access; - } - private async getState(deferRows?: boolean): Promise { if (this.container.git.repositoryCount === 0) return { allowed: true, repositories: [] }; @@ -993,30 +1056,32 @@ export class GraphWebview extends WebviewBase { // If we have a set of data refresh to the same set const limit = Math.max(defaultItemLimit, this._graph?.ids.size ?? defaultItemLimit); - // Check for GitLens+ access - const access = await this.getGraphAccess(); - const visibility = access.visibility ?? (await this.container.git.visibility(this.repository.path)); - const dataPromise = this.container.git.getCommitsForGraph( this.repository.path, this._panel!.webview.asWebviewUri.bind(this._panel!.webview), - { limit: limit, ref: this._selectedSha ?? 'HEAD' }, + { limit: limit, ref: this._selectedId ?? 'HEAD' }, ); - let data; + // Check for GitLens+ access and working tree stats + const [accessResult, workingStatsResult] = await Promise.allSettled([ + this.getGraphAccess(), + this.getWorkingTreeStats(), + ]); + const [access, visibility] = getSettledValue(accessResult) ?? []; + let data; if (deferRows) { queueMicrotask(async () => { const data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.sha); + this.setSelectedRows(data.id); - void this.notifyDidChangeCommits(); + void this.notifyDidChangeRows(true); }); } else { data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.sha); + this.setSelectedRows(data.id); } const columns = this.getColumns(); @@ -1028,8 +1093,8 @@ export class GraphWebview extends WebviewBase { selectedRepository: this.repository.path, selectedRepositoryVisibility: visibility, selectedRows: this._selectedRows, - subscription: access.subscription.current, - allowed: access.allowed !== false, + subscription: access?.subscription.current, + allowed: (access?.allowed ?? false) !== false, avatars: data != null ? Object.fromEntries(data.avatars) : undefined, loading: deferRows, rows: data?.rows, @@ -1046,7 +1111,7 @@ export class GraphWebview extends WebviewBase { header: this.getColumnHeaderContext(columns), }, nonce: this.cspNonce, - dirStats: await this.getWorkDirStats() ?? {added: 0, deleted: 0, modified: 0 }, + workingTreeStats: getSettledValue(workingStatsResult) ?? { added: 0, deleted: 0, modified: 0 }, }; } @@ -1068,11 +1133,11 @@ export class GraphWebview extends WebviewBase { this._searchCancellation = undefined; } - private setSelectedRows(sha: string | undefined) { - if (this._selectedSha === sha) return; + private setSelectedRows(id: string | undefined) { + if (this._selectedId === id) return; - this._selectedSha = sha; - this._selectedRows = sha != null ? { [sha]: true } : {}; + this._selectedId = id; + this._selectedRows = id != null ? { [id]: true } : undefined; } private setGraph(graph: GitGraph | undefined) { @@ -1082,17 +1147,16 @@ export class GraphWebview extends WebviewBase { } } - private async updateGraphWithMoreCommits(graph: GitGraph, sha?: string) { + private async updateGraphWithMoreRows(graph: GitGraph, id: string | undefined, search?: GitSearch) { const { defaultItemLimit, pageItemLimit } = configuration.get('graph'); - const updatedGraph = await graph.more?.(pageItemLimit ?? defaultItemLimit, sha); + const updatedGraph = await graph.more?.(pageItemLimit ?? defaultItemLimit, id); if (updatedGraph != null) { this.setGraph(updatedGraph); - if (this._search != null) { - const search = this._search; + if (search?.paging?.hasMore) { const lastId = last(search.results)?.[0]; - if (lastId != null && updatedGraph.ids.has(lastId)) { - queueMicrotask(() => void this.onSearchCommits({ search: search.query, more: true })); + if (lastId != null && (updatedGraph.ids.has(lastId) || updatedGraph.skippedIds?.has(lastId))) { + queueMicrotask(() => void this.onSearch({ search: search.query, more: true })); } } } else { diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index e9d9357..0def629 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -1,29 +1,32 @@ import type { + CssVariables, GraphColumnSetting, GraphContexts, GraphRow, GraphZoneType, Remote, - WorkDirStats, + WorkDirStats, } from '@gitkraken/gitkraken-components'; import type { DateStyle } from '../../../config'; import type { RepositoryVisibility } from '../../../git/gitProvider'; import type { GitGraphRowType } from '../../../git/models/graph'; -import type { SearchQuery } from '../../../git/search'; +import type { GitSearchResultData, SearchQuery } from '../../../git/search'; import type { Subscription } from '../../../subscription'; import type { DateTimeFormat } from '../../../system/date'; import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; export type GraphColumnsSettings = Record; +export type GraphSelectedRows = Record; +export type GraphAvatars = Record; export interface State { repositories?: GraphRepository[]; selectedRepository?: string; selectedRepositoryVisibility?: RepositoryVisibility; - selectedRows?: { [id: string]: true }; + selectedRows?: GraphSelectedRows; subscription?: Subscription; allowed: boolean; - avatars?: { [email: string]: string }; + avatars?: GraphAvatars; loading?: boolean; rows?: GraphRow[]; paging?: GraphPaging; @@ -33,14 +36,15 @@ export interface State { nonce?: string; previewBanner?: boolean; trialBanner?: boolean; - dirStats?: WorkDirStats; + workingTreeStats?: GraphWorkingTreeStats; + searchResults?: DidSearchParams['results']; // Props below are computed in the webview (not passed) - mixedColumnColors?: Record; - searchResults?: DidSearchCommitsParams['results']; + activeRow?: string; + theming?: { cssVariables: CssVariables; themeOpacityFactor: number }; } -export type GraphWorkDirStats = WorkDirStats; +export type GraphWorkingTreeStats = WorkDirStats; export interface GraphPaging { startingCursor?: string; @@ -80,7 +84,7 @@ export interface GraphComponentConfig { enableMultiSelection?: boolean; highlightRowsOnRefHover?: boolean; showGhostRefsOnRowHover?: boolean; - shaLength?: number; + idLength?: number; } export interface GraphColumnConfig { @@ -91,7 +95,7 @@ export interface GraphColumnConfig { export type GraphColumnName = GraphZoneType; export interface UpdateStateCallback { - (state: State): void; + (state: State, type?: IpcNotificationType): void; } // Commands @@ -100,28 +104,28 @@ export interface DismissBannerParams { } export const DismissBannerCommandType = new IpcCommandType('graph/dismissBanner'); -export interface EnsureCommitParams { +export interface EnsureRowParams { id: string; select?: boolean; } -export const EnsureCommitCommandType = new IpcCommandType('graph/ensureCommit'); +export const EnsureRowCommandType = new IpcCommandType('graph/rows/ensure'); export interface GetMissingAvatarsParams { - emails: { [email: string]: string }; + emails: GraphAvatars; } -export const GetMissingAvatarsCommandType = new IpcCommandType('graph/getMissingAvatars'); +export const GetMissingAvatarsCommandType = new IpcCommandType('graph/avatars/get'); -export interface GetMoreCommitsParams { - sha?: string; +export interface GetMoreRowsParams { + id?: string; } -export const GetMoreCommitsCommandType = new IpcCommandType('graph/getMoreCommits'); +export const GetMoreRowsCommandType = new IpcCommandType('graph/rows/get'); -export interface SearchCommitsParams { +export interface SearchParams { search?: SearchQuery; limit?: number; more?: boolean; } -export const SearchCommitsCommandType = new IpcCommandType('graph/search'); +export const SearchCommandType = new IpcCommandType('graph/search'); export interface SearchOpenInViewParams { search: SearchQuery; @@ -185,44 +189,47 @@ export const DidChangeColumnsNotificationType = new IpcNotificationType( - 'graph/commits/didChange', -); +export const DidChangeRowsNotificationType = new IpcNotificationType('graph/rows/didChange'); export interface DidChangeSelectionParams { - selection: { [id: string]: true }; + selection: GraphSelectedRows; } export const DidChangeSelectionNotificationType = new IpcNotificationType( 'graph/selection/didChange', true, ); -export interface DidEnsureCommitParams { - id?: string; - selected?: boolean; +export interface DidChangeWorkingTreeParams { + stats: WorkDirStats; } -export const DidEnsureCommitNotificationType = new IpcNotificationType( - 'graph/commits/didEnsureCommit', +export const DidChangeWorkingTreeNotificationType = new IpcNotificationType( + 'graph/workingTree/didChange', + true, ); -export interface DidSearchCommitsParams { - results: { ids: { [sha: string]: number }; paging?: { hasMore: boolean } } | undefined; - selectedRows?: { [id: string]: true }; +export interface DidEnsureRowParams { + id?: string; // `undefined` if the row was not found } -export const DidSearchCommitsNotificationType = new IpcNotificationType( - 'graph/didSearch', - true, -); +export const DidEnsureRowNotificationType = new IpcNotificationType('graph/rows/didEnsure'); -export interface DidChangeWorkDirStatsParams { - workDirStats: WorkDirStats; +export interface GraphSearchResults { + ids?: { [id: string]: GitSearchResultData }; + count: number; + paging?: { hasMore: boolean }; } -export const DidChangeWorkDirStatsNotificationType = new IpcNotificationType( - 'graph/workDirStats/didChange', -); + +export interface GraphSearchResultsError { + error: string; +} + +export interface DidSearchParams { + results: GraphSearchResults | GraphSearchResultsError | undefined; + selectedRows?: GraphSelectedRows; +} +export const DidSearchNotificationType = new IpcNotificationType('graph/didSearch', true); diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index db4ff4c..22b9f7b 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -1,93 +1,72 @@ -import type { OnFormatCommitDateTime } from '@gitkraken/gitkraken-components'; -import GraphContainer, { - type CssVariables, - type GraphColumnSetting, - type GraphPlatform, - type GraphRow, +import GraphContainer from '@gitkraken/gitkraken-components'; +import type { + GraphColumnSetting, + GraphContainerProps, + GraphPlatform, + GraphRow, + OnFormatCommitDateTime, } from '@gitkraken/gitkraken-components'; import type { ReactElement } from 'react'; import React, { createElement, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getPlatform } from '@env/platform'; import { DateStyle } from '../../../../config'; import { RepositoryVisibility } from '../../../../git/gitProvider'; -import type { GitGraphRowType } from '../../../../git/models/graph'; import type { SearchQuery } from '../../../../git/search'; import type { - DidEnsureCommitParams, - DidSearchCommitsParams, + DidEnsureRowParams, + DidSearchParams, DismissBannerParams, GraphColumnConfig, GraphColumnName, GraphComponentConfig, GraphRepository, + GraphSearchResults, + GraphSearchResultsError, State, UpdateStateCallback, } from '../../../../plus/webviews/graph/protocol'; +import { + DidChangeAvatarsNotificationType, + DidChangeColumnsNotificationType, + DidChangeGraphConfigurationNotificationType, + DidChangeRowsNotificationType, + DidChangeSelectionNotificationType, + DidChangeSubscriptionNotificationType, + DidChangeWorkingTreeNotificationType, + DidSearchNotificationType, +} from '../../../../plus/webviews/graph/protocol'; import type { Subscription } from '../../../../subscription'; import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription'; -import { debounce } from '../../../../system/function'; import { pluralize } from '../../../../system/string'; +import type { IpcNotificationType } from '../../../../webviews/protocol'; import { SearchBox } from '../../shared/components/search/react'; +import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; import type { DateTimeFormat } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date'; -export interface GraphWrapperProps extends State { +export interface GraphWrapperProps { nonce?: string; + state: State; subscriber: (callback: UpdateStateCallback) => () => void; onSelectRepository?: (repository: GraphRepository) => void; onColumnChange?: (name: GraphColumnName, settings: GraphColumnConfig) => void; onMissingAvatars?: (emails: { [email: string]: string }) => void; - onMoreCommits?: (id?: string) => void; - onSearchCommits?: (search: SearchQuery | undefined, options?: { limit?: number }) => void; - onSearchCommitsPromise?: ( + onMoreRows?: (id?: string) => void; + onSearch?: (search: SearchQuery | undefined, options?: { limit?: number }) => void; + onSearchPromise?: ( search: SearchQuery, options?: { limit?: number; more?: boolean }, - ) => Promise; + ) => Promise; onSearchOpenInView?: (search: SearchQuery) => void; onDismissBanner?: (key: DismissBannerParams['key']) => void; - onSelectionChange?: (selection: { id: string; type: GitGraphRowType }[]) => void; - onEnsureCommitPromise?: (id: string, select: boolean) => Promise; + onSelectionChange?: (rows: GraphRow[]) => void; + onEnsureRowPromise?: (id: string, select: boolean) => Promise; } -const getStyleProps = ( - mixedColumnColors: CssVariables | undefined, -): { cssVariables: CssVariables; themeOpacityFactor: number } => { - const body = document.body; - const computedStyle = window.getComputedStyle(body); - - return { - cssVariables: { - '--app__bg0': computedStyle.getPropertyValue('--color-background'), - '--panel__bg0': computedStyle.getPropertyValue('--graph-panel-bg'), - '--panel__bg1': computedStyle.getPropertyValue('--graph-panel-bg2'), - '--section-border': computedStyle.getPropertyValue('--graph-panel-bg2'), - '--text-selected': computedStyle.getPropertyValue('--color-foreground'), - '--text-normal': computedStyle.getPropertyValue('--color-foreground--85'), - '--text-secondary': computedStyle.getPropertyValue('--color-foreground--65'), - '--text-disabled': computedStyle.getPropertyValue('--color-foreground--50'), - '--text-accent': computedStyle.getPropertyValue('--color-link-foreground'), - '--text-inverse': computedStyle.getPropertyValue('--vscode-input-background'), - '--text-bright': computedStyle.getPropertyValue('--vscode-input-background'), - ...mixedColumnColors, - }, - themeOpacityFactor: parseInt(computedStyle.getPropertyValue('--graph-theme-opacity-factor')) || 1, - }; -}; - const getGraphDateFormatter = (config?: GraphComponentConfig): OnFormatCommitDateTime => { return (commitDateTime: number) => formatCommitDateTime(commitDateTime, config?.dateStyle, config?.dateFormat); }; -const getSearchHighlights = (searchIds?: [string, number][]): { [id: string]: boolean } | undefined => { - if (!searchIds?.length) return undefined; - - const highlights: { [id: string]: boolean } = {}; - for (const [sha] of searchIds) { - highlights[sha] = true; - } - return highlights; -}; - type DebouncableFn = (...args: any) => void; type DebouncedFn = (...args: any) => void; const debounceFrame = (func: DebouncableFn): DebouncedFn => { @@ -146,274 +125,287 @@ const clientPlatform = getClientPlatform(); // eslint-disable-next-line @typescript-eslint/naming-convention export function GraphWrapper({ subscriber, - repositories = [], - rows = [], - selectedRepository, - selectedRows, - subscription, - selectedRepositoryVisibility, - allowed, - avatars, - columns, - config, - context, - loading, - paging, + nonce, + state, onSelectRepository, onColumnChange, - onEnsureCommitPromise, + onEnsureRowPromise, onMissingAvatars, - onMoreCommits, - onSearchCommits, - onSearchCommitsPromise, + onMoreRows, + onSearch, + onSearchPromise, onSearchOpenInView, onSelectionChange, - nonce, - mixedColumnColors, - previewBanner = true, - searchResults, - trialBanner = true, onDismissBanner, - dirStats = { added: 0, modified: 0, deleted: 0 }, }: GraphWrapperProps) { - const [graphRows, setGraphRows] = useState(rows); - const [graphAvatars, setAvatars] = useState(avatars); - const [reposList, setReposList] = useState(repositories); - const [currentRepository, setCurrentRepository] = useState( - reposList.find(item => item.path === selectedRepository), - ); - const [graphSelectedRows, setSelectedRows] = useState(selectedRows); - const [graphConfig, setGraphConfig] = useState(config); - // const [graphDateFormatter, setGraphDateFormatter] = useState(getGraphDateFormatter(config)); - const [graphColumns, setGraphColumns] = useState(columns); - const [graphContext, setGraphContext] = useState(context); - const [pagingState, setPagingState] = useState(paging); - const [isLoading, setIsLoading] = useState(loading); - const [styleProps, setStyleProps] = useState(getStyleProps(mixedColumnColors)); // TODO: application shouldn't know about the graph component's header const graphHeaderOffset = 24; const [mainWidth, setMainWidth] = useState(); const [mainHeight, setMainHeight] = useState(); const mainRef = useRef(null); + const graphRef = useRef(null); + + const [rows, setRows] = useState(state.rows ?? []); + const [avatars, setAvatars] = useState(state.avatars); + const [repos, setRepos] = useState(state.repositories ?? []); + const [repo, setRepo] = useState( + repos.find(item => item.path === state.selectedRepository), + ); + const [selectedRows, setSelectedRows] = useState(state.selectedRows); + const [activeRow, setActiveRow] = useState(state.activeRow); + const [graphConfig, setGraphConfig] = useState(state.config); + // const [graphDateFormatter, setGraphDateFormatter] = useState(getGraphDateFormatter(config)); + const [columns, setColumns] = useState(state.columns); + const [context, setContext] = useState(state.context); + const [pagingHasMore, setPagingHasMore] = useState(state.paging?.hasMore ?? false); + const [isLoading, setIsLoading] = useState(state.loading); + const [styleProps, setStyleProps] = useState(state.theming); // banner - const [showPreview, setShowPreview] = useState(previewBanner); + const [showPreview, setShowPreview] = useState(state.previewBanner); // account - const [showAccount, setShowAccount] = useState(trialBanner); - const [isAllowed, setIsAllowed] = useState(allowed ?? false); - const [isPrivateRepo, setIsPrivateRepo] = useState(selectedRepositoryVisibility === RepositoryVisibility.Private); - const [subscriptionSnapshot, setSubscriptionSnapshot] = useState(subscription); + const [showAccount, setShowAccount] = useState(state.trialBanner); + const [isAccessAllowed, setIsAccessAllowed] = useState(state.allowed ?? false); + const [isRepoPrivate, setIsRepoPrivate] = useState( + state.selectedRepositoryVisibility === RepositoryVisibility.Private, + ); + const [subscription, setSubscription] = useState(state.subscription); // repo selection UI const [repoExpanded, setRepoExpanded] = useState(false); // search state const [searchQuery, setSearchQuery] = useState(undefined); - const [searchResultKey, setSearchResultKey] = useState(undefined); - const [searchResultIds, setSearchResultIds] = useState( - searchResults != null ? Object.entries(searchResults.ids) : undefined, + const { results, resultsError } = getSearchResultModel(state); + const [searchResults, setSearchResults] = useState(results); + const [searchResultsError, setSearchResultsError] = useState(resultsError); + const [searchResultsHidden, setSearchResultsHidden] = useState(false); + + // working tree state + const [workingTreeStats, setWorkingTreeStats] = useState( + state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }, ); - const [hasMoreSearchResults, setHasMoreSearchResults] = useState(searchResults?.paging?.hasMore ?? false); - const [selectedRow, setSelectedRow] = useState(undefined); - // workdir state - const [workDirStats, setWorkDirStats] = useState(dirStats); - - useEffect(() => { - if (graphRows.length === 0) { - setSearchResultIds(undefined); - } - }, [graphRows]); - useEffect(() => { - if (searchResultIds == null || searchResultIds.length === 0) { - setSearchResultKey(undefined); - return; - } + const ensuredIds = useRef>(new Set()); + const ensuredSkippedIds = useRef>(new Set()); - if ( - searchResultKey == null || - (searchResultKey != null && !searchResultIds.some(id => id[0] === searchResultKey)) - ) { - setSearchResultKey(searchResultIds[0][0]); + function transformData(state: State, type?: IpcNotificationType) { + switch (type) { + case DidChangeAvatarsNotificationType: + setAvatars(state.avatars); + break; + case DidChangeColumnsNotificationType: + setColumns(state.columns); + setContext(state.context); + break; + case DidChangeRowsNotificationType: + setRows(state.rows ?? []); + setSelectedRows(state.selectedRows); + setAvatars(state.avatars); + setPagingHasMore(state.paging?.hasMore ?? false); + setIsLoading(state.loading); + break; + case DidSearchNotificationType: { + const { results, resultsError } = getSearchResultModel(state); + setSearchResultsError(resultsError); + setSearchResults(results); + setSelectedRows(state.selectedRows); + break; + } + case DidChangeGraphConfigurationNotificationType: + setGraphConfig(state.config); + break; + case DidChangeSelectionNotificationType: + setSelectedRows(state.selectedRows); + break; + case DidChangeSubscriptionNotificationType: + setIsAccessAllowed(state.allowed ?? false); + setSubscription(state.subscription); + break; + case DidChangeWorkingTreeNotificationType: + setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); + break; + default: { + setIsAccessAllowed(state.allowed ?? false); + setStyleProps(state.theming); + setColumns(state.columns); + setRows(state.rows ?? []); + setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); + setGraphConfig(state.config); + setSelectedRows(state.selectedRows); + setContext(state.context); + setAvatars(state.avatars ?? {}); + setPagingHasMore(state.paging?.hasMore ?? false); + setRepos(state.repositories ?? []); + setRepo(repos.find(item => item.path === state.selectedRepository)); + setIsRepoPrivate(state.selectedRepositoryVisibility === RepositoryVisibility.Private); + // setGraphDateFormatter(getGraphDateFormatter(config)); + setSubscription(state.subscription); + setShowAccount(state.trialBanner ?? true); + + const { results, resultsError } = getSearchResultModel(state); + setSearchResultsError(resultsError); + setSearchResults(results); + + setIsLoading(state.loading); + break; + } } - }, [searchResultIds]); + } - const searchHighlights = useMemo(() => getSearchHighlights(searchResultIds), [searchResultIds]); + useEffect(() => subscriber?.(transformData), []); + + useLayoutEffect(() => { + if (mainRef.current === null) return; + + const setDimensionsDebounced = debounceFrame((width, height) => { + setMainWidth(Math.floor(width)); + setMainHeight(Math.floor(height) - graphHeaderOffset); + }); + + const resizeObserver = new ResizeObserver(entries => + entries.forEach(e => setDimensionsDebounced(e.contentRect.width, e.contentRect.height)), + ); + resizeObserver.observe(mainRef.current); + + return () => resizeObserver.disconnect(); + }, [mainRef]); const searchPosition: number = useMemo(() => { - if (searchResultKey == null || searchResultIds == null) return 0; + if (searchResults?.ids == null || !searchQuery?.query) return 0; - const idx = searchResultIds.findIndex(id => id[0] === searchResultKey); - return idx < 1 ? 1 : idx + 1; - }, [searchResultKey, searchResultIds]); + const id = getActiveRowInfo(activeRow)?.id; + let searchIndex = id ? searchResults.ids[id]?.i : undefined; + if (searchIndex == null) { + [searchIndex] = getClosestSearchResultIndex(searchResults, searchQuery, activeRow); + } + return searchIndex < 1 ? 1 : searchIndex + 1; + }, [activeRow, searchResults]); - const handleSearchNavigation = async (next = true) => { - if (searchResultKey == null || searchResultIds == null) return; + const handleSearchInput = (e: CustomEvent) => { + const detail = e.detail; + setSearchQuery(detail); - let selected = searchResultKey; - if (selectedRow != null && selectedRow.sha !== searchResultKey) { - selected = selectedRow.sha; + const isValid = detail.query.length >= 3; + if (!isValid) { + setSearchResults(undefined); } + setSearchResultsError(undefined); + setSearchResultsHidden(false); + onSearch?.(isValid ? detail : undefined); + }; + + const handleSearchOpenInView = () => { + if (searchQuery == null) return; - let resultIds = searchResultIds; - const selectedDate = selectedRow != null ? selectedRow.date + (next ? 1 : -1) : undefined; + onSearchOpenInView?.(searchQuery); + }; - // Loop through the search results and: - // try to find the selected sha - // if next=true find the nearest date before the selected date - // if next=false find the nearest date after the selected date - let rowIndex: number | undefined; - let nearestDate: number | undefined; - let nearestIndex: number | undefined; + const handleSearchNavigation = async (e: CustomEvent) => { + if (searchResults == null) return; - let i = -1; - let date: number; - let sha: string; - for ([sha, date] of resultIds) { - i++; + const direction = e.detail?.direction ?? 'next'; - if (sha === selected) { - rowIndex = i; - break; - } + let results = searchResults; + let count = results.count; - if (selectedDate != null) { - if (next) { - if (date < selectedDate && (nearestDate == null || date > nearestDate)) { - nearestDate = date; - nearestIndex = i; - } - } else if (date > selectedDate && (nearestDate == null || date <= nearestDate)) { - nearestDate = date; - nearestIndex = i; - } - } - } + let searchIndex; + let id: string | undefined; - if (rowIndex == null) { - rowIndex = nearestIndex == null ? resultIds.length - 1 : nearestIndex + (next ? -1 : 1); + let next; + if (direction === 'first') { + next = false; + searchIndex = 0; + } else if (direction === 'last') { + next = true; + searchIndex = count - 1; + } else { + next = direction === 'next'; + [searchIndex, id] = getClosestSearchResultIndex(results, searchQuery, activeRow, next); } - if (next) { - if (rowIndex < resultIds.length - 1) { - rowIndex++; - } else if (searchQuery != null && hasMoreSearchResults) { - const results = await onSearchCommitsPromise?.(searchQuery, { more: true }); - if (results?.results != null) { - if (resultIds.length < results.results.ids.length) { - resultIds = Object.entries(results.results.ids); - rowIndex++; + let iterations = 0; + // Avoid infinite loops + while (iterations < 1000) { + iterations++; + + // Indicates a boundary and we need to load more results + if (searchIndex == -1) { + if (next) { + if (searchQuery != null && results?.paging?.hasMore) { + const moreResults = await onSearchPromise?.(searchQuery, { more: true }); + if (moreResults?.results != null && !('error' in moreResults.results)) { + if (count < moreResults.results.count) { + results = moreResults.results; + searchIndex = count; + count = results.count; + } else { + searchIndex = 0; + } + } else { + searchIndex = 0; + } } else { - rowIndex = 0; + searchIndex = 0; } } else { - rowIndex = 0; - } - } else { - rowIndex = 0; - } - } else if (rowIndex > 0) { - rowIndex--; - } else { - if (searchQuery != null && hasMoreSearchResults) { - const results = await onSearchCommitsPromise?.(searchQuery, { limit: 0, more: true }); - if (results?.results != null) { - if (resultIds.length < results.results.ids.length) { - resultIds = Object.entries(results.results.ids); - } + // TODO@eamodio should we load all the results here? Or just go to the end of the loaded results? + // } else if (searchQuery != null && results?.paging?.hasMore) { + // const moreResults = await onSearchCommitsPromise?.(searchQuery, { limit: 0, more: true }); + // if (moreResults?.results != null && !('error' in moreResults.results)) { + // if (count < moreResults.results.count) { + // results = moreResults.results; + // count = results.count; + // } + // } + searchIndex = count - 1; } } - rowIndex = resultIds.length - 1; - } - - const nextSha = resultIds[rowIndex][0]; - if (nextSha == null) return; - - if (onEnsureCommitPromise != null) { - let timeout: ReturnType | undefined = setTimeout(() => { - timeout = undefined; - setIsLoading(true); - }, 250); - - const e = await onEnsureCommitPromise(nextSha, true); - if (timeout == null) { - setIsLoading(false); - } else { - clearTimeout(timeout); + id = id ?? getSearchResultIdByIndex(results, searchIndex); + if (id != null) { + id = await ensureSearchResultRow(id); + if (id != null) break; } - if (e?.id === nextSha) { - setSearchResultKey(nextSha); - setSelectedRows({ [nextSha]: true }); - } else { - debugger; - } - } else { - setSearchResultKey(nextSha); - setSelectedRows({ [nextSha]: true }); - } - }; + setSearchResultsHidden(true); - const handleSearchInput = debounce((e: CustomEvent) => { - const detail = e.detail; - setSearchQuery(detail); - - const isValid = detail.query.length >= 3; - if (!isValid) { - setSearchResultKey(undefined); - setSearchResultIds(undefined); + searchIndex = getNextOrPreviousSearchResultIndex(searchIndex, next, results, searchQuery); } - onSearchCommits?.(isValid ? detail : undefined); - }, 250); - const handleSearchOpenInView = () => { - if (searchQuery == null) return; - - onSearchOpenInView?.(searchQuery); + if (id != null) { + // TODO@eamodio Remove the any once we expose `selectCommits` on the graph component + queueMicrotask(() => void (graphRef.current as any)?.selectCommits([id], false)); + } }; - useLayoutEffect(() => { - if (mainRef.current === null) return; - - const setDimensionsDebounced = debounceFrame((width, height) => { - setMainWidth(Math.floor(width)); - setMainHeight(Math.floor(height) - graphHeaderOffset); - }); + const ensureSearchResultRow = async (id: string): Promise => { + if (onEnsureRowPromise == null) return id; + if (ensuredIds.current.has(id)) return id; + if (ensuredSkippedIds.current.has(id)) return undefined; - const resizeObserver = new ResizeObserver(entries => - entries.forEach(e => setDimensionsDebounced(e.contentRect.width, e.contentRect.height)), - ); - resizeObserver.observe(mainRef.current); + let timeout: ReturnType | undefined = setTimeout(() => { + timeout = undefined; + setIsLoading(true); + }, 500); - return () => resizeObserver.disconnect(); - }, [mainRef]); + const e = await onEnsureRowPromise(id, false); + if (timeout == null) { + setIsLoading(false); + } else { + clearTimeout(timeout); + } - function transformData(state: State) { - setGraphRows(state.rows ?? []); - setAvatars(state.avatars ?? {}); - setReposList(state.repositories ?? []); - setCurrentRepository(reposList.find(item => item.path === state.selectedRepository)); - if (JSON.stringify(graphSelectedRows) !== JSON.stringify(state.selectedRows)) { - setSelectedRows(state.selectedRows); + if (e?.id === id) { + ensuredIds.current.add(id); + return id; } - setGraphConfig(state.config); - // setGraphDateFormatter(getGraphDateFormatter(config)); - setGraphColumns(state.columns); - setGraphContext(state.context); - setPagingState(state.paging); - setStyleProps(getStyleProps(state.mixedColumnColors)); - setIsAllowed(state.allowed ?? false); - setShowAccount(state.trialBanner ?? true); - setSubscriptionSnapshot(state.subscription); - setIsPrivateRepo(state.selectedRepositoryVisibility === RepositoryVisibility.Private); - setIsLoading(state.loading); - setSearchResultIds(state.searchResults != null ? Object.entries(state.searchResults.ids) : undefined); - setHasMoreSearchResults(state.searchResults?.paging?.hasMore ?? false); - setWorkDirStats(state.dirStats ?? { added: 0, modified: 0, deleted: 0 }); - } - useEffect(() => subscriber?.(transformData), []); + if (e != null) { + ensuredSkippedIds.current.add(id); + } + return undefined; + }; const handleSelectRepository = (item: GraphRepository) => { - if (item != null && item !== currentRepository) { + if (item != null && item !== repo) { setIsLoading(true); onSelectRepository?.(item); } @@ -421,7 +413,7 @@ export function GraphWrapper({ }; const handleToggleRepos = () => { - if (currentRepository != null && reposList.length <= 1) return; + if (repo != null && repos.length <= 1) return; setRepoExpanded(!repoExpanded); }; @@ -442,7 +434,7 @@ export function GraphWrapper({ const handleMoreCommits = () => { setIsLoading(true); - onMoreCommits?.(); + onMoreRows?.(); }; const handleOnColumnResized = (columnName: GraphColumnName, columnSettings: GraphColumnSetting) => { @@ -454,9 +446,13 @@ export function GraphWrapper({ } }; - const handleSelectGraphRows = (graphRows: GraphRow[]) => { - setSelectedRow(graphRows[0]); - onSelectionChange?.(graphRows.map(r => ({ id: r.sha, type: r.type as GitGraphRowType }))); + const handleSelectGraphRows = (rows: GraphRow[]) => { + const active = rows[0]; + const activeKey = active != null ? `${active.sha}|${active.date}` : undefined; + // HACK: Ensure the main state is updated since it doesn't come from the extension + state.activeRow = activeKey; + setActiveRow(activeKey); + onSelectionChange?.(rows); }; const handleDismissPreview = () => { @@ -471,15 +467,13 @@ export function GraphWrapper({ const renderTrialDays = () => { if ( - !subscriptionSnapshot || - ![SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes( - subscriptionSnapshot.state, - ) + !subscription || + ![SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(subscription.state) ) { return; } - const days = getSubscriptionTimeRemaining(subscriptionSnapshot, 'days') ?? 0; + const days = getSubscriptionTimeRemaining(subscription, 'days') ?? 0; return ( GitLens+ Trial ({days < 1 ? '< 1 day' : pluralize('day', days)} left) @@ -488,22 +482,18 @@ export function GraphWrapper({ }; const renderAlertContent = () => { - if (subscriptionSnapshot == null || !isPrivateRepo || (isAllowed && !showAccount)) return; + if (subscription == null || !isRepoPrivate || (isAccessAllowed && !showAccount)) return; let icon = 'account'; let modifier = ''; let content; let actions; let days = 0; - if ( - [SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes( - subscriptionSnapshot.state, - ) - ) { - days = getSubscriptionTimeRemaining(subscriptionSnapshot, 'days') ?? 0; + if ([SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(subscription.state)) { + days = getSubscriptionTimeRemaining(subscription, 'days') ?? 0; } - switch (subscriptionSnapshot.state) { + switch (subscription.state) { case SubscriptionState.Free: case SubscriptionState.Paid: return; @@ -590,7 +580,7 @@ export function GraphWrapper({ {content} {actions &&
{actions}
} - {isAllowed && ( + {isAccessAllowed && ( @@ -631,18 +621,20 @@ export function GraphWrapper({ )} {renderAlertContent()} - {isAllowed && ( + {isAccessAllowed && (
2)} - more={hasMoreSearchResults} + more={searchResults?.paging?.hasMore ?? false} value={searchQuery?.query ?? ''} + errorMessage={searchResultsError?.error ?? ''} + resultsHidden={searchResultsHidden} + resultsLoaded={searchResults != null} onChange={e => handleSearchInput(e as CustomEvent)} - onPrevious={() => handleSearchNavigation(false)} - onNext={() => handleSearchNavigation(true)} + onNavigate={e => handleSearchNavigation(e as CustomEvent)} onOpenInView={() => handleSearchOpenInView()} />
@@ -651,40 +643,42 @@ export function GraphWrapper({
- {!isAllowed &&
} - {currentRepository !== undefined ? ( + {!isAccessAllowed &&
} + {repo !== undefined ? ( <> {mainWidth !== undefined && mainHeight !== undefined && ( )} @@ -695,7 +689,7 @@ export function GraphWrapper({ className="column-button" type="button" role="button" - data-vscode-context={graphContext?.header || JSON.stringify({ webviewItem: 'gitlens:graph:columns' })} + data-vscode-context={context?.header || JSON.stringify({ webviewItem: 'gitlens:graph:columns' })} onClick={handleToggleColumnSettings} >
-