Browse Source

Overhauls graph rendering performance

- Avoids lots of re-renders and avoids exrta difing
Improves commit search
 - Honors match case for file searches
 - Better parsing of search input for files and changes
 - Honors quotes for verbatim passing to Git
Overhauls graph search
 - Adds order to search results to ensure ordering
 - Adds basic error reporting for invalid search queries
 - Adds shift-click to prev/next buttons to go to first/last
 - Adds warning to search result count when some results are hidden
 - Improves search accuracy and accessibility
 - Improves search keyboarding
 - Tracks search result count based on selection
 - Fixes search paging issues
 - Fixes issues with "hidden" commits e.g. stash related
Fixes `isReady` issues when refreshing/reloading webviews
main
Eric Amodio 2 years ago
parent
commit
45acbf0ec5
21 changed files with 1067 additions and 752 deletions
  1. +6
    -1
      src/commands/git/search.ts
  2. +12
    -3
      src/env/node/git/git.ts
  3. +65
    -47
      src/env/node/git/localGitProvider.ts
  4. +8
    -0
      src/git/errors.ts
  5. +5
    -1
      src/git/gitProvider.ts
  6. +5
    -1
      src/git/gitProviderService.ts
  7. +4
    -2
      src/git/models/graph.ts
  8. +45
    -21
      src/git/search.ts
  9. +25
    -24
      src/plus/github/githubGitProvider.ts
  10. +217
    -153
      src/plus/webviews/graph/graphWebview.ts
  11. +49
    -42
      src/plus/webviews/graph/protocol.ts
  12. +429
    -315
      src/webviews/apps/plus/graph/GraphWrapper.tsx
  13. +1
    -1
      src/webviews/apps/plus/graph/graph.html
  14. +0
    -1
      src/webviews/apps/plus/graph/graph.scss
  15. +111
    -100
      src/webviews/apps/plus/graph/graph.tsx
  16. +5
    -3
      src/webviews/apps/shared/appBase.ts
  17. +1
    -2
      src/webviews/apps/shared/components/search/react.tsx
  18. +57
    -27
      src/webviews/apps/shared/components/search/search-box.ts
  19. +10
    -6
      src/webviews/apps/shared/components/search/search-input.ts
  20. +2
    -2
      src/webviews/protocol.ts
  21. +10
    -0
      src/webviews/webviewBase.ts

+ 6
- 1
src/commands/git/search.ts View File

@ -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

+ 12
- 3
src/env/node/git/git.ts View File

@ -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<string>(
@ -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<string>(
{ 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),

+ 65
- 47
src/env/node/git/localGitProvider.ts View File

@ -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<GitBlame | GitDiff | GitLog | undefined> = Promise.resolve(undefined);
const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
const slash = 47;
@ -1660,7 +1663,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
const avatars = new Map<string, string>();
const ids = new Set<string>();
const reachableFromHEAD = new Set<string>();
const skipStashParents = new Set();
const skippedIds = new Set<string>();
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<GitSearch> {
search = { matchAll: false, matchCase: false, matchRegex: true, ...search };
@ -4245,10 +4253,14 @@ export class LocalGitProvider implements GitProvider, Disposable {
'--',
);
const results = new Map<string, number>(
let i = 0;
const results: GitSearchResults = new Map<string, GitSearchResultData>(
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<string, number>();
const results: GitSearchResults = new Map<string, GitSearchResultData>();
let total = 0;
let iterations = 0;
async function searchForCommitsCore(
this: LocalGitProvider,
limit: number,
cursor?: { sha: string; skip: number },
): Promise<GitSearch> {
iterations++;
if (options?.cancellation?.isCancellationRequested) {
// TODO@eamodio: Should we throw an error here?
return { repoPath: repoPath, 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<string, number>(),
};
if (ex instanceof GitSearchError) {
throw ex;
}
throw new GitSearchError(ex);
}
}

+ 8
- 0
src/git/errors.ts View File

@ -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,
}

+ 5
- 1
src/git/gitProvider.ts View File

@ -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<GitSearch>;
validateBranchOrTagName(repoPath: string, ref: string): Promise<boolean>;
validateReference(repoPath: string, ref: string): Promise<boolean>;

+ 5
- 1
src/git/gitProviderService.ts View File

@ -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<GitSearch> {
const { provider, path } = this.getProvider(repoPath);
return provider.searchCommits(path, search, options);

+ 4
- 2
src/git/models/graph.ts View File

@ -27,9 +27,11 @@ export interface GitGraph {
readonly avatars: Map<string, string>;
/** A set of all "seen" commit ids */
readonly ids: Set<string>;
/** A set of all skipped commit ids -- typically for stash index/untracked commits */
readonly skippedIds?: Set<string>;
/** 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<GitGraph | undefined>;
more?(limit: number, id?: string): Promise<GitGraph | undefined>;
}

+ 45
- 21
src/git/search.ts View File

@ -43,11 +43,17 @@ export interface StoredSearchQuery {
matchRegex?: boolean;
}
export interface GitSearchResultData {
date: number;
i: number;
}
export type GitSearchResults = Map<string, GitSearchResultData>;
export interface GitSearch {
repoPath: string;
query: SearchQuery;
comparisonKey: string;
results: Map<string, number>;
results: GitSearchResults;
readonly paging?: {
readonly limit: number | undefined;
@ -108,9 +114,9 @@ const normalizeSearchOperatorsMap = new Map([
]);
const searchOperationRegex =
/(?:(?<op>=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?<value>".+?"|\S+\b}?))|(?<text>\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi;
/(?:(?<op>=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?<value>".+?"|\S+}?))|(?<text>\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi;
export function parseSearchQuery(query: string): Map<string, string[]> {
export function parseSearchQuery(search: SearchQuery): Map<string, string[]> {
const operations = new Map<string, string[]>();
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<string> | undefined;
} {
const operations = parseSearchQuery(search.query);
const operations = parseSearchQuery(search);
const searchArgs = new Set<string>();
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;

+ 25
- 24
src/plus/github/githubGitProvider.ts View File

@ -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<GitSearch> {
// 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<string, number>();
const operations = parseSearchQuery(search.query);
const results: GitSearchResults = new Map<string, GitSearchResultData>();
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<Promise<GitCommit | undefined>[]>(
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<GitSearch> {
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<string, number>(),
};
if (ex instanceof GitSearchError) {
throw ex;
}
throw new GitSearchError(ex);
}
}

+ 217
- 153
src/plus/webviews/graph/graphWebview.ts View File

@ -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<IpcNotificationType, IpcMessage | (() => Promise<boolean>)>();
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<void> {
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<void>[] = [];
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.idspan>, thisan>._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<GraphWebview['fireSelectionChanged']> | 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<GraphWebview['notifyDidChangeState']> | 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<GraphWebview['notifyDidChangeAvatars']> | 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<any>, 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<GraphColumnName, GraphColumnConfig> | 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<GraphWorkDirStats | undefined> {
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<GraphWorkingTreeStats | undefined> {
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<State> {
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 {

+ 49
- 42
src/plus/webviews/graph/protocol.ts View File

@ -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<GraphColumnName, GraphColumnSetting>;
export type GraphSelectedRows = Record</*id*/ string, true>;
export type GraphAvatars = Record</*email*/ string, /*url*/ string>;
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<string, string>;
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<any>): void;
}
// Commands
@ -100,28 +104,28 @@ export interface DismissBannerParams {
}
export const DismissBannerCommandType = new IpcCommandType<DismissBannerParams>('graph/dismissBanner');
export interface EnsureCommitParams {
export interface EnsureRowParams {
id: string;
select?: boolean;
}
export const EnsureCommitCommandType = new IpcCommandType<EnsureCommitParams>('graph/ensureCommit');
export const EnsureRowCommandType = new IpcCommandType<EnsureRowParams>('graph/rows/ensure');
export interface GetMissingAvatarsParams {
emails: { [email: string]: string };
emails: GraphAvatars;
}
export const GetMissingAvatarsCommandType = new IpcCommandType<GetMissingAvatarsParams>('graph/getMissingAvatars');
export const GetMissingAvatarsCommandType = new IpcCommandType<GetMissingAvatarsParams>('graph/avatars/get');
export interface GetMoreCommitsParams {
sha?: string;
export interface GetMoreRowsParams {
id?: string;
}
export const GetMoreCommitsCommandType = new IpcCommandType<GetMoreCommitsParams>('graph/getMoreCommits');
export const GetMoreRowsCommandType = new IpcCommandType<GetMoreRowsParams>('graph/rows/get');
export interface SearchCommitsParams {
export interface SearchParams {
search?: SearchQuery;
limit?: number;
more?: boolean;
}
export const SearchCommitsCommandType = new IpcCommandType<SearchCommitsParams>('graph/search');
export const SearchCommandType = new IpcCommandType<SearchParams>('graph/search');
export interface SearchOpenInViewParams {
search: SearchQuery;
@ -185,44 +189,47 @@ export const DidChangeColumnsNotificationType = new IpcNotificationType
true,
);
export interface DidChangeCommitsParams {
export interface DidChangeRowsParams {
rows: GraphRow[];
avatars: { [email: string]: string };
selectedRows?: { [id: string]: true };
paging?: GraphPaging;
selectedRows?: GraphSelectedRows;
}
export const DidChangeCommitsNotificationType = new IpcNotificationType<DidChangeCommitsParams>(
'graph/commits/didChange',
);
export const DidChangeRowsNotificationType = new IpcNotificationType<DidChangeRowsParams>('graph/rows/didChange');
export interface DidChangeSelectionParams {
selection: { [id: string]: true };
selection: GraphSelectedRows;
}
export const DidChangeSelectionNotificationType = new IpcNotificationType<DidChangeSelectionParams>(
'graph/selection/didChange',
true,
);
export interface DidEnsureCommitParams {
id?: string;
selected?: boolean;
export interface DidChangeWorkingTreeParams {
stats: WorkDirStats;
}
export const DidEnsureCommitNotificationType = new IpcNotificationType<DidEnsureCommitParams>(
'graph/commits/didEnsureCommit',
export const DidChangeWorkingTreeNotificationType = new IpcNotificationType<DidChangeWorkingTreeParams>(
'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<DidSearchCommitsParams>(
'graph/didSearch',
true,
);
export const DidEnsureRowNotificationType = new IpcNotificationType<DidEnsureRowParams>('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<DidChangeWorkDirStatsParams>(
'graph/workDirStats/didChange',
);
export interface GraphSearchResultsError {
error: string;
}
export interface DidSearchParams {
results: GraphSearchResults | GraphSearchResultsError | undefined;
selectedRows?: GraphSelectedRows;
}
export const DidSearchNotificationType = new IpcNotificationType<DidSearchParams>('graph/didSearch', true);

+ 429
- 315
src/webviews/apps/plus/graph/GraphWrapper.tsx
File diff suppressed because it is too large
View File


+ 1
- 1
src/webviews/apps/plus/graph/graph.html View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
</head>
<body class="graph-app preload" data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
<body class="graph-app" data-vscode-context='{ "preventDefaultContextMenuItems": true }'>
<div id="root" class="graph-app__container">
<p>A repository must be selected.</p>
</div>

+ 0
- 1
src/webviews/apps/plus/graph/graph.scss View File

@ -442,7 +442,6 @@ a {
}
}
.titlebar {
background: var(--vscode-titleBar-inactiveBackground);
color: var(--vscode-titleBar-inactiveForeground);

+ 111
- 100
src/webviews/apps/plus/graph/graph.tsx View File

@ -1,5 +1,5 @@
/*global document window*/
import type { CssVariables } from '@gitkraken/gitkraken-components';
import type { CssVariables, GraphRow } from '@gitkraken/gitkraken-components';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { GitGraphRowType } from '../../../../git/models/graph';
@ -15,26 +15,26 @@ 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 as UpdateRepositorySelectionCommandType,
UpdateSelectionCommandType,
} from '../../../../plus/webviews/graph/protocol';
import { debounce } from '../../../../system/function';
import type { IpcMessage } from '../../../../webviews/protocol';
import type { IpcMessage, IpcNotificationType } from '../../../../webviews/protocol';
import { onIpc } from '../../../../webviews/protocol';
import { App } from '../../shared/appBase';
import { mix, opacity } from '../../shared/colors';
@ -64,33 +64,34 @@ export class GraphApp extends App {
protected override onBind() {
const disposables = super.onBind?.() ?? [];
this.log('GraphApp.onBind paging:', this.state.paging);
this.log(`${this.appName}.onBind`);
const $root = document.getElementById('root');
if ($root != null) {
render(
<GraphWrapper
nonce={this.state.nonce}
state={this.state}
subscriber={(callback: UpdateStateCallback) => this.registerEvents(callback)}
onColumnChange={debounce(
(name: GraphColumnName, settings: GraphColumnConfig) => this.onColumnChanged(name, settings),
onColumnChange={debounce<GraphApp['onColumnChanged']>(
(name, settings) => this.onColumnChanged(name, settings),
250,
)}
onSelectRepository={debounce(
(path: GraphRepository) => this.onRepositorySelectionChanged(path),
onSelectRepository={debounce<GraphApp['onRepositorySelectionChanged']>(
path => this.onRepositorySelectionChanged(path),
250,
)}
onMissingAvatars={(...params) => this.onGetMissingAvatars(...params)}
onMoreCommits={(...params) => this.onGetMoreCommits(...params)}
onSearchCommits={(...params) => this.onSearchCommits(...params)}
onSearchCommitsPromise={(...params) => this.onSearchCommitsPromise(...params)}
onMoreRows={(...params) => this.onGetMoreRows(...params)}
onSearch={debounce<GraphApp['onSearch']>((search, options) => this.onSearch(search, options), 250)}
onSearchPromise={(...params) => this.onSearchPromise(...params)}
onSearchOpenInView={(...params) => this.onSearchOpenInView(...params)}
onSelectionChange={debounce(
(selection: { id: string; type: GitGraphRowTypespan> }[]) => this.onSelectionChanged(selection),
onSelectionChange={debounce<GraphApp['onSelectionChanged']>(
rows => this.onSelectionChanged(rows),
250,
)}
onDismissBanner={key => this.onDismissBanner(key)}
onEnsureCommitPromise={this.onEnsureCommitPromise.bind(this)}
{...this.state}
onEnsureRowPromise={this.onEnsureRowPromise.bind(this)}
/>,
$root,
);
@ -108,56 +109,54 @@ export class GraphApp extends App {
switch (msg.method) {
case DidChangeNotificationType.method:
onIpc(DidChangeNotificationType, msg, params => {
this.setState({ ...this.state, ...params.state });
this.refresh(this.state);
onIpc(DidChangeNotificationType, msg, (params, type) => {
this.setState({ ...this.state, ...params.state }, type);
});
break;
case DidChangeAvatarsNotificationType.method:
onIpc(DidChangeAvatarsNotificationType, msg, params => {
this.setState({ ...this.state, avatars: params.avatars });
this.refresh(this.state);
onIpc(DidChangeAvatarsNotificationType, msg, (params, type) => {
this.state.avatars = params.avatars;
this.setState(this.state, type);
});
break;
case DidChangeColumnsNotificationType.method:
onIpc(DidChangeColumnsNotificationType, msg, params => {
const newState = { ...this.state, columns: params.columns };
onIpc(DidChangeColumnsNotificationType, msg, (params, type) => {
this.state.columns = params.columns;
if (params.context != null) {
if (newState.context == null) {
newState.context = { header: params.context };
if (this.state.context == null) {
this.state.context = { header: params.context };
} else {
newState.context.header = params.context;
this.state.context.header = params.context;
}
} else if (newState.context?.header != null) {
newState.context.header = undefined;
} else if (this.state.context?.header != null) {
this.state.context.header = undefined;
}
this.setState(newState);
this.refresh(this.state);
this.setState(this.state, type);
});
break;
case DidChangeCommitsNotificationType.method:
onIpc(DidChangeCommitsNotificationType, msg, params => {
case DidChangeRowsNotificationType.method:
onIpc(DidChangeRowsNotificationType, msg, (params, type) => {
let rows;
if (params.rows.length && params.paging?.startingCursor != null && this.state.rows != null) {
const previousRows = this.state.rows;
const lastSha = previousRows[previousRows.length - 1]?.sha;
const lastId = previousRows[previousRows.length - 1]?.sha;
let previousRowsLength = previousRows.length;
const newRowsLength = params.rows.length;
this.log(
`${this.appName}.onMessageReceived(${msg.id}:${msg.method}): paging in ${newRowsLength} rows into existing ${previousRowsLength} rows at ${params.paging.startingCursor} (last existing row: ${lastSha})`,
`${this.appName}.onMessageReceived(${msg.id}:${msg.method}): paging in ${newRowsLength} rows into existing ${previousRowsLength} rows at ${params.paging.startingCursor} (last existing row: ${lastId})`,
);
rows = [];
// Preallocate the array to avoid reallocations
rows.length = previousRowsLength + newRowsLength;
if (params.paging.startingCursor !== lastSha) {
if (params.paging.startingCursor !== lastId) {
this.log(
`${this.appName}.onMessageReceived(${msg.id}:${msg.method}): searching for ${params.paging.startingCursor} in existing rows`,
);
@ -202,60 +201,53 @@ export class GraphApp extends App {
}
}
this.setState({
...this.state,
avatars: params.avatars,
paging: params.paging,
selectedRows: params.selectedRows,
rows: rows,
loading: false,
});
this.refresh(this.state);
this.state.avatars = params.avatars;
this.state.rows = rows;
this.state.paging = params.paging;
if (params.selectedRows != null) {
this.state.selectedRows = params.selectedRows;
}
this.state.loading = false;
this.setState(this.state, type);
});
break;
case DidSearchCommitsNotificationType.method:
onIpc(DidSearchCommitsNotificationType, msg, params => {
if (params.results == null && params.selectedRows == null) return;
this.setState({
...this.state,
searchResults: params.results,
selectedRows: params.selectedRows,
});
this.refresh(this.state);
case DidSearchNotificationType.method:
onIpc(DidSearchNotificationType, msg, (params, type) => {
this.state.searchResults = params.results;
if (params.selectedRows != null) {
this.state.selectedRows = params.selectedRows;
}
this.setState(this.state, type);
});
break;
case DidChangeSelectionNotificationType.method:
onIpc(DidChangeSelectionNotificationType, msg, params => {
this.setState({ ...this.state, selectedRows: params.selection });
this.refresh(this.state);
onIpc(DidChangeSelectionNotificationType, msg, (params, type) => {
this.state.selectedRows = params.selection;
this.setState(this.state, type);
});
break;
case DidChangeGraphConfigurationNotificationType.method:
onIpc(DidChangeGraphConfigurationNotificationType, msg, params => {
this.setState({ ...this.state, config: params.config });
this.refresh(this.state);
onIpc(DidChangeGraphConfigurationNotificationType, msg, (params, type) => {
this.state.config = params.config;
this.setState(this.state, type);
});
break;
case DidChangeSubscriptionNotificationType.method:
onIpc(DidChangeSubscriptionNotificationType, msg, params => {
this.setState({
...this.state,
subscription: params.subscription,
allowed: params.allowed,
});
this.refresh(this.state);
onIpc(DidChangeSubscriptionNotificationType, msg, (params, type) => {
this.state.subscription = params.subscription;
this.state.allowed = params.allowed;
this.setState(this.state, type);
});
break;
case DidChangeWorkDirStatsNotificationType.method:
onIpc(DidChangeWorkDirStatsNotificationType, msg, params => {
this.setState({ ...this.state, dirStats: params.workDirStats });
this.refresh(this.state);
case DidChangeWorkingTreeNotificationType.method:
onIpc(DidChangeWorkingTreeNotificationType, msg, (params, type) => {
this.state.workingTreeStats = params.stats;
this.setState(this.state, type);
});
break;
@ -265,18 +257,24 @@ export class GraphApp extends App {
}
protected override onThemeUpdated() {
this.setState({ ...this.state, mixedColumnColors: undefined });
this.refresh(this.state);
this.state.theming = undefined;
this.setState(this.state);
}
protected override setState(state: State) {
if (state.mixedColumnColors == null) {
state.mixedColumnColors = this.getGraphColors();
protected override setState(state: State, type?: IpcNotificationType<any>) {
this.log(`${this.appName}.setState`);
if (state.theming == null) {
state.theming = this.getGraphTheming();
}
super.setState(state);
// Avoid calling the base for now, since we aren't using the vscode state
this.state = state;
// super.setState(state);
this.callback?.(this.state, type);
}
private getGraphColors(): CssVariables {
private getGraphTheming(): { cssVariables: CssVariables; themeOpacityFactor: number } {
// this will be called on theme updated as well as on config updated since it is dependent on the column colors from config changes and the background color from the theme
const computedStyle = window.getComputedStyle(document.body);
const bgColor = computedStyle.getPropertyValue('--color-background');
@ -301,7 +299,23 @@ export class GraphApp extends App {
i++;
}
return mixedGraphColors;
return {
cssVariables: {
'--app__bg0': bgColor,
'--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'),
...mixedGraphColors,
},
themeOpacityFactor: parseInt(computedStyle.getPropertyValue('--graph-theme-opacity-factor')) || 1,
};
}
private onDismissBanner(key: DismissBannerParams['key']) {
@ -325,23 +339,23 @@ export class GraphApp extends App {
this.sendCommand(GetMissingAvatarsCommandType, { emails: emails });
}
private onGetMoreCommits(sha?: string) {
return this.sendCommand(GetMoreCommitsCommandType, { sha: sha });
private onGetMoreRows(sha?: string) {
return this.sendCommand(GetMoreRowsCommandType, { id: sha });
}
private onSearchCommits(search: SearchQuery | undefined, options?: { limit?: number }) {
private onSearch(search: SearchQuery | undefined, options?: { limit?: number }) {
if (search == null) {
this.state.searchResults = undefined;
}
return this.sendCommand(SearchCommitsCommandType, { search: search, limit: options?.limit });
return this.sendCommand(SearchCommandType, { search: search, limit: options?.limit });
}
private async onSearchCommitsPromise(search: SearchQuery, options?: { limit?: number; more?: boolean }) {
private async onSearchPromise(search: SearchQuery, options?: { limit?: number; more?: boolean }) {
try {
return await this.sendCommandWithCompletion(
SearchCommitsCommandType,
SearchCommandType,
{ search: search, limit: options?.limit, more: options?.more },
DidSearchCommitsNotificationType,
DidSearchNotificationType,
);
} catch {
return undefined;
@ -352,19 +366,20 @@ export class GraphApp extends App {
this.sendCommand(SearchOpenInViewCommandType, { search: search });
}
private async onEnsureCommitPromise(id: string, select: boolean) {
private async onEnsureRowPromise(id: string, select: boolean) {
try {
return await this.sendCommandWithCompletion(
EnsureCommitCommandType,
EnsureRowCommandType,
{ id: id, select: select },
DidEnsureCommitNotificationType,
DidEnsureRowNotificationType,
);
} catch {
return undefined;
}
}
private onSelectionChanged(selection: { id: string; type: GitGraphRowType }[]) {
private onSelectionChanged(rows: GraphRow[]) {
const selection = rows.map(r => ({ id: r.sha, type: r.type as GitGraphRowType }));
this.sendCommand(UpdateSelectionCommandType, {
selection: selection,
});
@ -377,10 +392,6 @@ export class GraphApp extends App {
this.callback = undefined;
};
}
private refresh(state: State) {
this.callback?.(state);
}
}
new GraphApp();

+ 5
- 3
src/webviews/apps/shared/appBase.ts View File

@ -55,9 +55,11 @@ export abstract class App {
this.onInitialized?.();
} finally {
setTimeout(() => {
document.body.classList.remove('preload');
}, 500);
if (document.body.classList.contains('preload')) {
setTimeout(() => {
document.body.classList.remove('preload');
}, 500);
}
}
});
}

+ 1
- 2
src/webviews/apps/shared/components/search/react.tsx View File

@ -7,8 +7,7 @@ const { wrap } = provideReactWrapper(React);
export const SearchBox = wrap(searchBoxComponent, {
events: {
onChange: 'change',
onPrevious: 'previous',
onNext: 'next',
onNavigate: 'navigate',
onOpenInView: 'openinview',
},
});

+ 57
- 27
src/webviews/apps/shared/components/search/search-box.ts View File

@ -1,39 +1,59 @@
import { attr, css, customElement, FASTElement, html, observable, volatile, when } from '@microsoft/fast-element';
import { attr, css, customElement, FASTElement, html, observable, ref, volatile, when } from '@microsoft/fast-element';
import { isMac } from '@env/platform';
import { pluralize } from '../../../../../system/string';
import type { Disposable } from '../../dom';
import { DOM } from '../../dom';
import { numberConverter } from '../converters/number-converter';
import '../codicon';
import type { SearchInput } from './search-input';
import './search-input';
export type SearchNavigationDirection = 'first' | 'previous' | 'next' | 'last';
export interface SearchNavigationEventDetail {
direction: SearchNavigationDirection;
}
const template = html<SearchBox>`<template>
<search-input
${ref('searchInput')}
id="search-input"
errorMessage="${x => x.errorMessage}"
:errorMessage="${x => x.errorMessage}"
label="${x => x.label}"
placeholder="${x => x.placeholder}"
matchAll="${x => x.matchAll}"
matchCase="${x => x.matchCase}"
matchRegex="${x => x.matchRegex}"
value="${x => x.value}"
@previous="${(x, c) => x.handlePrevious(c.event)}"
@next="${(x, c) => x.handleNext(c.event)}"
@previous="${(x, c) => {
c.event.stopImmediatePropagation();
x.navigate('previous');
}}"
@next="${(x, c) => {
c.event.stopImmediatePropagation();
x.navigate('next');
}}"
></search-input>
<div class="search-navigation" aria-label="Search navigation">
<span class="count${x => (x.total < 1 && x.valid ? ' error' : '')}">
<span class="count${x => (x.total < 1 && x.valid && x.resultsLoaded ? ' error' : '')}">
${when(x => x.total < 1, html<SearchBox>`${x => x.formattedLabel}`)}
${when(
x => x.total > 0,
html<SearchBox>`<span aria-current="step">${x => x.step}</span> of
${x => x.total}${x => (x.more ? '+' : '')}<span class="sr-only"> ${x => x.formattedLabel}</span>`,
<span
class="${x => (x.resultsHidden ? 'sr-hidden' : '')}"
title="${x =>
x.resultsHidden
? 'Some search results are hidden or unable to be shown on the Commit Graph'
: ''}"
>${x => x.total}${x => (x.more ? '+' : '')}</span
><span class="sr-only"> ${x => x.formattedLabel}</span>`,
)}
</span>
<button
type="button"
class="button"
?disabled="${x => !x.hasPrevious}"
@click="${(x, c) => x.handlePrevious(c.event)}"
@click="${(x, c) => x.handlePrevious(c.event as MouseEvent)}"
>
<code-icon
icon="arrow-up"
@ -41,7 +61,12 @@ const template = html`