diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index df42c35..5fc56a6 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -2242,6 +2242,14 @@ export class LocalGitProvider implements GitProvider, Disposable { count: commits.size, limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, hasMore: moreUntil == null ? moreLog.hasMore : true, + pagedCommits: () => { + // Remove any duplicates + for (const sha of log.commits.keys()) { + moreLog.commits.delete(sha); + } + return moreLog.commits; + }, + previousCursor: last(log.commits)?.[0], query: (limit: number | undefined) => this.getLog(log.repoPath, { ...options, limit: limit }), }; mergedLog.more = this.getLogMoreFn(mergedLog, options); diff --git a/src/git/models/log.ts b/src/git/models/log.ts index a527cb2..cbb6006 100644 --- a/src/git/models/log.ts +++ b/src/git/models/log.ts @@ -13,6 +13,9 @@ export interface GitLog { readonly hasMore: boolean; readonly cursor?: string; + readonly pagedCommits?: () => Map; + readonly previousCursor?: string; + query?(limit: number | undefined): Promise; more?(limit: number | { until?: string } | undefined): Promise; } diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts index 11a7c51..8ecdfa8 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/github/githubGitProvider.ts @@ -59,7 +59,7 @@ import type { LogScope } from '../../logger'; import { Logger } from '../../logger'; import { gate } from '../../system/decorators/gate'; import { debug, getLogScope, log } from '../../system/decorators/log'; -import { filterMap, some } from '../../system/iterable'; +import { filterMap, last, some } from '../../system/iterable'; import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; import { getSettledValue } from '../../system/promise'; import type { CachedBlame, CachedLog } from '../../trackers/gitDocumentTracker'; @@ -1358,6 +1358,14 @@ export class GitHubGitProvider implements GitProvider, Disposable { limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, hasMore: moreUntil == null ? moreLog.hasMore : true, cursor: moreLog.cursor, + pagedCommits: () => { + // Remove any duplicates + for (const sha of log.commits.keys()) { + moreLog.commits.delete(sha); + } + return moreLog.commits; + }, + previousCursor: last(log.commits)?.[0], query: log.query, }; mergedLog.more = this.getLogMoreFn(mergedLog, options); diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index 9cb4917..69fecd9 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -1,4 +1,4 @@ -import type { CommitType } from '@gitkraken/gitkraken-components'; +import type { CommitType, GraphRow, Head, Remote, Tag } from '@gitkraken/gitkraken-components'; import { commitNodeType, mergeNodeType, stashNodeType } from '@gitkraken/gitkraken-components'; import type { ColorTheme, ConfigurationChangeEvent, Disposable, Event, StatusBarItem } from 'vscode'; import { ColorThemeKind, EventEmitter, MarkdownString, StatusBarAlignment, Uri, ViewColumn, window } from 'vscode'; @@ -16,14 +16,20 @@ import type { GitLog } from '../../../git/models/log'; import type { GitRemote } from '../../../git/models/remote'; import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import type { GitStash } from '../../../git/models/stash'; import type { GitTag } from '../../../git/models/tag'; +import { debug } from '../../../system/decorators/log'; +import type { Deferrable } from '../../../system/function'; +import { debounce } from '../../../system/function'; +import { filter, filterMap, union } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; +import { getSettledValue } from '../../../system/promise'; import { RepositoryFolderNode } from '../../../views/nodes/viewNode'; import type { IpcMessage } from '../../../webviews/protocol'; import { onIpc } from '../../../webviews/protocol'; import { WebviewBase } from '../../../webviews/webviewBase'; import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; -import type { GraphCommit, GraphCompositeConfig, GraphRemote, GraphRepository, State } from './protocol'; +import type { GraphCompositeConfig, GraphLog, GraphRepository, State } from './protocol'; import { DidChangeCommitsNotificationType, DidChangeGraphConfigurationNotificationType, @@ -45,12 +51,37 @@ export class GraphWebview extends WebviewBase { return this._onDidChangeSelection.event; } + private _repository?: Repository; + get repository(): Repository | undefined { + return this._repository; + } + + set repository(value: Repository | undefined) { + if (this._repository === value) return; + + this._repositoryEventsDisposable?.dispose(); + this._repository = value; + this._etagRepository = value?.etag; + this._repositoryLog = undefined; + + if (value != null) { + this._repositoryEventsDisposable = value.onDidChange(this.onRepositoryChanged, this); + } + + this.updateState(); + } + + private _selection: readonly GitCommit[] | undefined; + get selection(): readonly GitCommit[] | undefined { + return this._selection; + } + + private _etagRepository?: number; private _repositoryEventsDisposable: Disposable | undefined; + private _repositoryLog?: GitLog; private _statusBarItem: StatusBarItem | undefined; + private _theme: ColorTheme | undefined; - private selectedRepository?: Repository; - private selection?: GitCommit[]; - private currentLog?: GitLog; private previewBanner?: boolean; constructor(container: Container) { @@ -83,13 +114,13 @@ export class GraphWebview extends WebviewBase { if (context.type === 'scm' && context.scm.rootUri != null) { const repository = this.container.git.getRepository(context.scm.rootUri); if (repository != null) { - this.selectedRepository = repository; + this.repository = repository; } } else if (context.type === 'viewItem' && context.node instanceof RepositoryFolderNode) { - this.selectedRepository = context.node.repo; + this.repository = context.node.repo; } - if (this.selectedRepository != null) { + if (this.repository != null) { void this.refresh(); } } @@ -97,7 +128,10 @@ export class GraphWebview extends WebviewBase { return super.show(column, ...args); } - private _theme: ColorTheme | undefined; + protected override async includeBootstrap(): Promise { + return this.getState(); + } + protected override onInitializing(): Disposable[] | undefined { this._theme = window.activeColorTheme; return [window.onDidChangeActiveColorTheme(this.onThemeChanged, this)]; @@ -109,13 +143,13 @@ export class GraphWebview extends WebviewBase { onIpc(DismissPreviewCommandType, e, () => this.dismissPreview()); break; case GetMoreCommitsCommandType.method: - onIpc(GetMoreCommitsCommandType, e, params => this.moreCommits(params.limit)); + onIpc(GetMoreCommitsCommandType, e, params => this.onGetMoreCommits(params.limit)); break; case UpdateColumnCommandType.method: - onIpc(UpdateColumnCommandType, e, params => this.changeColumn(params.name, params.config)); + onIpc(UpdateColumnCommandType, e, params => this.onColumnUpdated(params.name, params.config)); break; case UpdateSelectedRepositoryCommandType.method: - onIpc(UpdateSelectedRepositoryCommandType, e, params => this.changeRepository(params.path)); + onIpc(UpdateSelectedRepositoryCommandType, e, params => this.onRepositorySelectionChanged(params.path)); break; case UpdateSelectionCommandType.method: onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(params.selection)); @@ -129,6 +163,13 @@ export class GraphWebview extends WebviewBase { } } + protected override onVisibilityChanged(visible: boolean): void { + if (visible && this.repository != null && this.repository.etag !== this._etagRepository) { + this._repositoryLog = undefined; + void this.refresh(); + } + } + private onConfigurationChanged(e?: ConfigurationChangeEvent) { if (configuration.changed(e, 'graph.statusBar.enabled') || configuration.changed(e, 'plusFeatures.enabled')) { const enabled = configuration.get('graph.statusBar.enabled') && configuration.get('plusFeatures.enabled'); @@ -157,8 +198,30 @@ export class GraphWebview extends WebviewBase { } if (e != null && configuration.changed(e, 'graph')) { - void this.notifyDidChangeConfig(); + this.updateState(); + } + } + + private onRepositoryChanged(e: RepositoryChangeEvent) { + if ( + !e.changed( + RepositoryChange.Config, + RepositoryChange.Heads, + RepositoryChange.Index, + RepositoryChange.Remotes, + RepositoryChange.RemoteProviders, + RepositoryChange.Stash, + RepositoryChange.Status, + RepositoryChange.Tags, + RepositoryChange.Unknown, + RepositoryChangeComparisonMode.Any, + ) + ) { + return; } + + this._repositoryLog = undefined; + this.updateState(); } private onThemeChanged(theme: ColorTheme) { @@ -172,7 +235,7 @@ export class GraphWebview extends WebviewBase { } this._theme = theme; - void this.notifyDidChangeState(); + this.updateState(); } private dismissPreview() { @@ -183,44 +246,43 @@ export class GraphWebview extends WebviewBase { void this.container.storage.storeWorkspace('graph:banners:dismissed', banners); } - private changeColumn(name: string, config: GraphColumnConfig) { - const columns = this.container.storage.getWorkspace('graph:columns') ?? {}; - columns[name] = config; + private onColumnUpdated(name: string, config: GraphColumnConfig) { + let columns = this.container.storage.getWorkspace('graph:columns'); + columns = updateRecordValue(columns, name, config); void this.container.storage.storeWorkspace('graph:columns', columns); - void this.notifyDidChangeConfig(); + + void this.notifyDidChangeGraphConfiguration(); } - private async moreCommits(limit?: number) { - if (this.currentLog?.more !== undefined) { + private async onGetMoreCommits(limit?: number) { + if (this._repositoryLog?.more != null) { const { defaultItemLimit, pageItemLimit } = this.getConfig(); - const nextLog = await this.currentLog.more(limit ?? pageItemLimit ?? defaultItemLimit); - if (nextLog !== undefined) { - this.currentLog = nextLog; + const nextLog = await this._repositoryLog.more(limit ?? pageItemLimit ?? defaultItemLimit); + if (nextLog != null) { + this._repositoryLog = nextLog; } } void this.notifyDidChangeCommits(); } - private changeRepository(path: string) { - if (this.selectedRepository?.path !== path) { - this.selectedRepository = path ? this.getRepos().find(r => r.path === path) : undefined; - this.currentLog = undefined; + private onRepositorySelectionChanged(path: string) { + if (this.repository?.path !== path) { + this.repository = this.container.git.getRepository(path); } - void this.notifyDidChangeState(); } - private async onSelectionChanged(selection: GraphCommit[]) { - const ref = selection[0]?.sha; + private async onSelectionChanged(selection: string[]) { + const ref = selection[0]; let commits: GitCommit[] | undefined; if (ref != null) { - const commit = await this.selectedRepository?.getCommit(ref); + const commit = await this.repository?.getCommit(ref); if (commit != null) { commits = [commit]; } } - this.selection = commits; + this._selection = commits; this._onDidChangeSelection.fire({ selection: commits ?? [] }); if (commits == null) return; @@ -228,141 +290,132 @@ export class GraphWebview extends WebviewBase { void GitActions.Commit.showDetailsView(commits[0], { pin: true, preserveFocus: true }); } - private async notifyDidChangeConfig() { - return this.notify(DidChangeGraphConfigurationNotificationType, { - config: this.getConfig(), - }); - } + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; - private async notifyDidChangeCommits() { - const [commitsAndLog, stashCommits] = await Promise.all([this.getCommits(), this.getStashCommits()]); + @debug() + private updateState(immediate: boolean = false) { + if (!this.isReady || !this.visible) return; - const log = commitsAndLog?.log; - const combinedCommitsWithFilteredStashes = combineAndFilterStashCommits( - commitsAndLog?.commits, - stashCommits, - log, - ); + if (immediate) { + void this.notifyDidChangeState(); + return; + } - return this.notify(DidChangeCommitsNotificationType, { - commits: formatCommits(combinedCommitsWithFilteredStashes), - log: log != null ? formatLog(log) : undefined, - }); + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); } + @debug() private async notifyDidChangeState() { + if (!this.isReady || !this.visible) return false; + return this.notify(DidChangeNotificationType, { state: await this.getState(), }); - // return window.withProgress({ location: { viewId: this.id } }, async () => { - // void this.notify(DidChangeNotificationType, { - // state: await this.getState(), - // }); - // }); } - private getRepos(): Repository[] { - return this.container.git.openRepositories; + @debug() + private async notifyDidChangeGraphConfiguration() { + if (!this.isReady || !this.visible) return false; + + return this.notify(DidChangeGraphConfigurationNotificationType, { + config: this.getConfig(), + }); } - private async getLog(repo: string | Repository): Promise { - const repository = typeof repo === 'string' ? this.container.git.getRepository(repo) : repo; - if (repository === undefined) { - return undefined; - } + @debug() + private async notifyDidChangeCommits() { + if (!this.isReady || !this.visible) return false; - const { defaultItemLimit, pageItemLimit } = this.getConfig(); - return this.container.git.getLog(repository.uri, { - all: true, - limit: pageItemLimit ?? defaultItemLimit, + const data = await this.getGraphData(true); + return this.notify(DidChangeCommitsNotificationType, { + rows: data.rows, + log: formatLog(data.log), + previousCursor: data.log?.previousCursor, }); } - private async getCommits(): Promise<{ log: GitLog; commits: GitCommit[] } | undefined> { - if (this.selectedRepository === undefined) { - return undefined; - } + private async getGraphData(paging: boolean = false): Promise<{ log: GitLog | undefined; rows: GraphRow[] }> { + const [logResult, stashResult, branchesResult, tagsResult, remotesResult] = await Promise.allSettled([ + this.getLog(), + this.getStash(), + this.getBranches(), + this.getTags(), + this.getRemotes(), + ]); - if (this.currentLog === undefined) { - const log = await this.getLog(this.selectedRepository); - if (log?.commits === undefined) { - return undefined; - } - this.currentLog = log; - } + const log = getSettledValue(logResult); + const combinedCommits = combineLogAndStash(log, getSettledValue(stashResult), paging); - if (this.currentLog?.commits === undefined) { - return undefined; - } + const rows = await convertToRows( + combinedCommits, + getSettledValue(branchesResult) ?? [], + getSettledValue(tagsResult) ?? [], + getSettledValue(remotesResult) ?? [], + icon => + this._panel?.webview + .asWebviewUri( + Uri.joinPath( + this.container.context.extensionUri, + `images/${isLightTheme(window.activeColorTheme) ? 'light' : 'dark'}/icon-${icon}.svg`, + ), + ) + .toString(), + ); return { - log: this.currentLog, - commits: Array.from(this.currentLog.commits.values()), + log: log, + rows: rows, }; } - private async getRemotes(): Promise { - if (this.selectedRepository === undefined) { - return undefined; - } + private async getLog(): Promise { + if (this.repository == null) return undefined; - return this.selectedRepository.getRemotes(); - } + if (this._repositoryLog == null) { + const { defaultItemLimit, pageItemLimit } = this.getConfig(); + const log = await this.container.git.getLog(this.repository.uri, { + all: true, + limit: defaultItemLimit ?? pageItemLimit, + }); + if (log?.commits == null) return undefined; - private async getTags(): Promise { - if (this.selectedRepository === undefined) { - return undefined; + this._repositoryLog = log; } - const tags = await this.container.git.getTags(this.selectedRepository.uri); - if (tags === undefined) { - return undefined; - } + if (this._repositoryLog?.commits == null) return undefined; - return Array.from(tags.values); + return this._repositoryLog; } private async getBranches(): Promise { - if (this.selectedRepository === undefined) { - return undefined; - } - - const branches = await this.container.git.getBranches(this.selectedRepository.uri); - if (branches === undefined) { - return undefined; + const branches = await this.repository?.getBranches(); + if (branches?.paging?.more) { + debugger; + // TODO@eamodio - implement paging } - - return Array.from(branches.values); + return branches?.values; } - private async getStashCommits(): Promise { - if (this.selectedRepository === undefined) { - return undefined; - } - - const stash = await this.container.git.getStash(this.selectedRepository.uri); - if (stash === undefined || stash.commits === undefined) { - return undefined; + private async getTags(): Promise { + const tags = await this.repository?.getTags(); + if (tags?.paging?.more) { + debugger; + // TODO@eamodio - implement paging } - - return Array.from(stash?.commits?.values()); + return tags?.values; } - private pickRepository(repositories: Repository[]): Repository | undefined { - if (repositories.length === 0) { - return undefined; - } - - if (repositories.length === 1) { - return repositories[0]; - } - - const bestRepo = this.container.git.getBestRepository(window.activeTextEditor); - if (bestRepo != null) { - return bestRepo; - } + private async getRemotes(): Promise { + return this.repository?.getRemotes(); + } - return repositories[0]; + private async getStash(): Promise { + // TODO@eamodio look into using `git log -g stash` to get stashes with the commits + return this.repository?.getStash(); } private getConfig(): GraphCompositeConfig { @@ -374,115 +427,175 @@ export class GraphWebview extends WebviewBase { return config; } - private onRepositoryChanged(e: RepositoryChangeEvent) { - if ( - !e.changed( - RepositoryChange.Config, - RepositoryChange.Heads, - RepositoryChange.Index, - RepositoryChange.Remotes, - RepositoryChange.RemoteProviders, - RepositoryChange.Stash, - RepositoryChange.Status, - RepositoryChange.Tags, - RepositoryChange.Unknown, - RepositoryChangeComparisonMode.Any, - ) - ) { - return; - } - - this.currentLog = undefined; - void this.notifyDidChangeState(); - } - private async getState(): Promise { - const repositories = this.getRepos(); - if (repositories.length === 0) { - return { - repositories: [], - }; - } + if (this.container.git.repositoryCount === 0) return { repositories: [] }; if (this.previewBanner == null) { const banners = this.container.storage.getWorkspace('graph:banners:dismissed'); this.previewBanner = !banners?.['preview']; } - if (this.selectedRepository === undefined) { - const idealRepo = this.pickRepository(repositories); - this.selectedRepository = idealRepo; - this._repositoryEventsDisposable?.dispose(); - if (this.selectedRepository != null) { - this._repositoryEventsDisposable = this.selectedRepository.onDidChange(this.onRepositoryChanged, this); - } + if (this.repository == null) { + this.repository = this.container.git.getBestRepositoryOrFirst(); } - - if (this.selectedRepository !== undefined) { - this.title = `${this.originalTitle}: ${this.selectedRepository.formattedName}`; + if (this.repository != null) { + this.title = `${this.originalTitle}: ${this.repository.formattedName}`; } - const [commitsAndLog, remotes, tags, branches, stashCommits] = await Promise.all([ - this.getCommits(), - this.getRemotes(), - this.getTags(), - this.getBranches(), - this.getStashCommits(), - ]); - - const log = commitsAndLog?.log; - const combinedCommitsWithFilteredStashes = combineAndFilterStashCommits( - commitsAndLog?.commits, - stashCommits, - log, - ); - - const theme = window.activeColorTheme; + const data = await this.getGraphData(false); return { previewBanner: this.previewBanner, - repositories: formatRepositories(repositories), - selectedRepository: this.selectedRepository?.path, - commits: formatCommits(combinedCommitsWithFilteredStashes), - remotes: formatRemotes(remotes, icon => - this._panel?.webview - .asWebviewUri( - Uri.joinPath( - this.container.context.extensionUri, - `images/${isLightTheme(theme) ? 'light' : 'dark'}/icon-${icon}.svg`, - ), - ) - .toString(), - ), - branches: branches, // TODO: add a format function - tags: tags, // TODO: add a format function + repositories: formatRepositories(this.container.git.openRepositories), + selectedRepository: this.repository?.path, + rows: data.rows, + log: formatLog(data.log), config: this.getConfig(), - log: log != null ? formatLog(log) : undefined, nonce: this.cspNonce, }; } +} - protected override async includeBootstrap(): Promise { - return this.getState(); - } +function combineLogAndStash( + log: GitLog | undefined, + stash: GitStash | undefined, + paging = false, +): Iterable { + // let commits = log?.commits; + // if (commits == null) return []; + + // if (paging && log?.previousCursor != null) { + // let pagedCommits = [...commits.values()]; + // const index = pagedCommits.findIndex(c => c.sha === log?.previousCursor); + // if (index !== -1) { + // pagedCommits = pagedCommits.slice(index + 1); + // } else { + // debugger; + // } + + // commits = new Map(pagedCommits.map(c => [c.sha, c])); + // } + + const commits = (paging ? log?.pagedCommits?.() : undefined) ?? log?.commits; + if (commits == null) return []; + if (stash?.commits == null) return [...commits.values()]; + + const stashCommitShaSecondParents = new Set( + filterMap(stash.commits.values(), c => (c.parents.length > 1 ? c.parents[1] : undefined)), + ); + const filteredCommits = filter( + commits.values(), + c => !stash.commits.has(c.sha) && !stashCommitShaSecondParents.has(c.sha), + ); + + const filteredStashCommits = filter(stash.commits.values(), c => !c.parents?.length || commits.has(c.parents[0])); + + return union(filteredCommits, filteredStashCommits); } -function isDarkTheme(theme: ColorTheme): boolean { - return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast; +async function convertToRows( + commits: Iterable, + branches: GitBranch[], + tags: GitTag[], + remotes: GitRemote[], + getRemoteIconUrl: (icon?: string) => string | undefined, +): Promise { + const rows: GraphRow[] = []; + + let graphHeads: Head[]; + let graphTags: Tag[]; + let graphRemotes: Remote[]; + let parents: string[]; + let stash: boolean; + + const remoteMap = new Map(remotes.map(r => [r.name, r])); + + for (const commit of commits) { + graphHeads = [ + ...filterMap(branches, b => { + if (b.sha !== commit.sha || b.remote) return undefined; + + return { + name: b.name, + isCurrentHead: b.current, + }; + }), + ]; + + graphRemotes = [ + ...filterMap(branches, b => { + if (b.sha !== commit.sha || !b.remote) return undefined; + + const remoteName = b.getRemoteName(); + const remote = remoteName != null ? remoteMap.get(remoteName) : undefined; + + return { + name: b.getNameWithoutRemote(), + url: remote?.url, + avatarUrl: + remote?.provider?.avatarUri?.toString(true) ?? + (remote?.provider?.icon != null ? getRemoteIconUrl(remote.provider.icon) : undefined), + owner: remote?.name, + }; + }), + ]; + + graphTags = [ + ...filterMap(tags, t => { + if (t.sha !== commit.sha) return undefined; + + return { + name: t.name, + annotated: Boolean(t.message), + }; + }), + ]; + + stash = isStash(commit); + + parents = commit.parents; + // Remove the second parent, if existing, from each stash commit as it affects column processing + if (parents.length > 1 && stash) { + parents = [...parents].splice(1, 1); + } + + rows.push({ + sha: commit.sha, + parents: parents, + author: commit.author.name, + avatarUrl: !stash ? (await commit.getAvatarUri())?.toString(true) : undefined, + email: commit.author.email ?? '', + date: commit.committer.date.getTime(), + message: emojify(commit.message && String(commit.message).length ? commit.message : commit.summary), + type: getCommitType(commit), // TODO: review logic for stash, wip, etc + heads: graphHeads, + remotes: graphRemotes, + tags: graphTags, + }); + } + + return rows; } -function isLightTheme(theme: ColorTheme): boolean { - return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight; +function formatLog(log: GitLog | undefined): GraphLog | undefined { + if (log == null) return undefined; + + return { + count: log.count, + limit: log.limit, + hasMore: log.hasMore, + cursor: log.cursor, + }; } -function formatCommits(commits: (GitCommit | GitStashCommit)[]): GraphCommit[] { - return commits.map((commit: GitCommit) => ({ - sha: commit.sha, - author: commit.author, - message: emojify(commit.message && String(commit.message).length ? commit.message : commit.summary), - parents: commit.parents, - committer: commit.committer, - type: getCommitType(commit), +function formatRepositories(repositories: Repository[]): GraphRepository[] { + if (repositories.length === 0) return repositories; + + return repositories.map(r => ({ + formattedName: r.formattedName, + id: r.id, + name: r.name, + path: r.path, })); } @@ -499,75 +612,10 @@ function getCommitType(commit: GitCommit | GitStashCommit): CommitType { return commitNodeType as CommitType; } -function combineAndFilterStashCommits( - commits: GitCommit[] | undefined, - stashCommits: GitStashCommit[] | undefined, - log: GitLog | undefined, -): (GitCommit | GitStashCommit)[] { - if (commits === undefined || log === undefined) { - return []; - } - - if (stashCommits === undefined) { - return commits; - } - - const stashCommitShas = stashCommits?.map(c => c.sha); - const stashCommitShaSecondParents = stashCommits?.map(c => (c.parents.length > 1 ? c.parents[1] : undefined)); - const filteredCommits = commits.filter( - (commit: GitCommit): boolean => - !stashCommitShas.includes(commit.sha) && !stashCommitShaSecondParents.includes(commit.sha), - ); - - const filteredStashCommits = stashCommits.filter((stashCommit: GitStashCommit): boolean => { - if (!stashCommit.parents?.length) { - return true; - } - const parentCommit: GitCommit | undefined = log.commits.get(stashCommit.parents[0]); - return parentCommit !== undefined; - }); - - // Remove the second parent, if existing, from each stash commit as it affects column processing - for (const stashCommit of filteredStashCommits) { - if (stashCommit.parents.length > 1) { - stashCommit.parents.splice(1, 1); - } - } - - return [...filteredCommits, ...filteredStashCommits]; -} - -function formatRemotes( - remotes: GitRemote[] | undefined, - getIconUrl: (icon?: string) => string | undefined, -): GraphRemote[] | undefined { - return remotes?.map(r => ({ - name: r.name, - url: r.url, - avatarUrl: - r.provider?.avatarUri?.toString(true) ?? - (r.provider?.icon != null ? getIconUrl(r.provider.icon) : undefined), - })); -} - -function formatRepositories(repositories: Repository[]): GraphRepository[] { - if (repositories.length === 0) { - return repositories; - } - - return repositories.map(({ formattedName, id, name, path }) => ({ - formattedName: formattedName, - id: id, - name: name, - path: path, - })); +function isDarkTheme(theme: ColorTheme): boolean { + return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast; } -function formatLog(log: GitLog) { - return { - count: log.count, - limit: log.limit, - hasMore: log.hasMore, - cursor: log.cursor, - }; +function isLightTheme(theme: ColorTheme): boolean { + return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight; } diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index 87b3bbf..f329c9b 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -1,15 +1,12 @@ -import type { Remote } from '@gitkraken/gitkraken-components'; +import type { CommitType, GraphRow, Remote } from '@gitkraken/gitkraken-components'; import type { GraphColumnConfig, GraphConfig } from '../../../config'; import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; export interface State { repositories?: GraphRepository[]; selectedRepository?: string; - commits?: GraphCommit[]; + rows?: GraphRow[]; config?: GraphCompositeConfig; - remotes?: GraphRemote[]; - tags?: GraphTag[]; - branches?: GraphBranch[]; log?: GraphLog; nonce?: string; mixedColumnColors?: Record; @@ -23,8 +20,28 @@ export interface GraphLog { cursor?: string; } -export type GraphRepository = Record; -export type GraphCommit = Record; +export interface GraphRepository { + formattedName: string; + id: string; + name: string; + path: string; +} + +export interface GraphCommitIdentity { + name: string; + email: string | undefined; + date: number; +} +export interface GraphCommit { + sha: string; + author: GraphCommitIdentity; + message: string; + parents: string[]; + committer: GraphCommitIdentity; + type: CommitType; + + avatarUrl: string | undefined; +} export type GraphRemote = Remote; export type GraphTag = Record; export type GraphBranch = Record; @@ -55,11 +72,11 @@ export interface UpdateSelectedRepositoryParams { path: string; } export const UpdateSelectedRepositoryCommandType = new IpcCommandType( - 'graph/update/selectedRepository', + 'graph/update/repositorySelection', ); export interface UpdateSelectionParams { - selection: GraphCommit[]; + selection: string[]; } export const UpdateSelectionCommandType = new IpcCommandType('graph/update/selection'); @@ -77,7 +94,8 @@ export const DidChangeGraphConfigurationNotificationType = new IpcNotificationTy ); export interface DidChangeCommitsParams { - commits: GraphCommit[]; + rows: GraphRow[]; + previousCursor?: string; log?: GraphLog; } export const DidChangeCommitsNotificationType = new IpcNotificationType( diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index 5a6c462..e24830a 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -4,21 +4,14 @@ import GraphContainer, { type GraphColumnsSettings as GKGraphColumnsSettings, type GraphRow, type GraphZoneType, - type Head, - type Remote, - type Tag, } from '@gitkraken/gitkraken-components'; import type { ReactElement } from 'react'; import React, { createElement, useEffect, useRef, useState } from 'react'; import type { GraphColumnConfig } from '../../../../config'; import type { CommitListCallback, - GraphBranch, - GraphCommit, GraphCompositeConfig, - GraphRemote, GraphRepository, - GraphTag, State, } from '../../../../plus/webviews/graph/protocol'; @@ -29,7 +22,7 @@ export interface GraphWrapperProps extends State { onColumnChange?: (name: string, settings: GraphColumnConfig) => void; onMoreCommits?: (limit?: number) => void; onDismissPreview?: () => void; - onSelectionChange?: (selection: GraphCommit[]) => void; + onSelectionChange?: (selection: string[]) => void; } // Copied from original pushed code of Miggy E. @@ -64,71 +57,6 @@ const getStyleProps = ( }; }; -const getGraphModel = ( - gitCommits: GraphCommit[] = [], - gitRemotes: GraphRemote[] = [], - gitTags: GraphTag[] = [], - gitBranches: GraphBranch[] = [], -): GraphRow[] => { - const graphRows: GraphRow[] = []; - - // console.log('gitCommits -> ', gitCommits); - // console.log('gitRemotes -> ', gitRemotes); - // console.log('gitTags -> ', gitTags); - // console.log('gitBranches -> ', gitBranches); - - // TODO: review if that code is correct and see if we need to add more data - for (const gitCommit of gitCommits) { - const graphRemotes: Remote[] = gitBranches - .filter((branch: GraphBranch) => branch.sha === gitCommit.sha && branch.remote) - .map((branch: GraphBranch) => { - const matchingRemote: GraphRemote | undefined = gitRemotes.find((remote: GraphRemote) => - branch.name.startsWith(remote.name), - ); - - return { - // If a matching remote is found, remove the remote name and slash from the branch name - name: - matchingRemote !== undefined ? branch.name.replace(`${matchingRemote.name}/`, '') : branch.name, - url: matchingRemote?.url, - avatarUrl: matchingRemote?.avatarUrl ?? undefined, - ...(matchingRemote?.name !== undefined ? { owner: matchingRemote.name } : {}), - }; - }); - - const graphHeads: Head[] = gitBranches - .filter((branch: GraphBranch) => branch.sha === gitCommit.sha && branch.remote === false) - .map((branch: GraphBranch) => { - return { - name: branch.name, - isCurrentHead: branch.current, - }; - }); - - const graphTags: Tag[] = gitTags - .filter((tag: GraphTag) => tag.sha === gitCommit.sha) - .map((tag: GraphTag) => ({ - name: tag.name, - annotated: Boolean(tag.message), - })); - - graphRows.push({ - sha: gitCommit.sha, - parents: gitCommit.parents, - author: gitCommit.author.name, - email: gitCommit.author.email, - date: new Date(gitCommit.committer.date).getTime(), - message: gitCommit.message, - type: gitCommit.type, // TODO: review logic for stash, wip, etc - heads: graphHeads, - remotes: graphRemotes, - tags: graphTags, - }); - } - - return graphRows; -}; - const defaultGraphColumnsSettings: GKGraphColumnsSettings = { commitAuthorZone: { width: 110 }, commitDateTimeZone: { width: 130 }, @@ -192,11 +120,8 @@ const getIconElementLibrary = (iconKey: string) => { // eslint-disable-next-line @typescript-eslint/naming-convention export function GraphWrapper({ subscriber, - commits = [], repositories = [], - remotes = [], - tags = [], - branches = [], + rows = [], selectedRepository, config, log, @@ -209,7 +134,7 @@ export function GraphWrapper({ previewBanner = true, onDismissPreview, }: GraphWrapperProps) { - const [graphList, setGraphList] = useState(getGraphModel(commits, remotes, tags, branches)); + const [graphList, setGraphList] = useState(rows); const [reposList, setReposList] = useState(repositories); const [currentRepository, setCurrentRepository] = useState( reposList.find(item => item.path === selectedRepository), @@ -250,7 +175,7 @@ export function GraphWrapper({ }, [mainRef]); function transformData(state: State) { - setGraphList(getGraphModel(state.commits, state.remotes, state.tags, state.branches)); + setGraphList(state.rows ?? []); setReposList(state.repositories ?? []); setCurrentRepository(reposList.find(item => item.path === state.selectedRepository)); setGraphColSettings(getGraphColSettingsModel(state.config)); @@ -288,7 +213,7 @@ export function GraphWrapper({ }; const handleSelectGraphRows = (graphRows: GraphRow[]) => { - onSelectionChange?.(graphRows); + onSelectionChange?.(graphRows.map(r => r.sha)); }; const handleDismissBanner = () => { @@ -301,7 +226,7 @@ export function GraphWrapper({ {showBanner && (
- +

Preview Feature

diff --git a/src/webviews/apps/plus/graph/graph.tsx b/src/webviews/apps/plus/graph/graph.tsx index f0bfba9..8342e7c 100644 --- a/src/webviews/apps/plus/graph/graph.tsx +++ b/src/webviews/apps/plus/graph/graph.tsx @@ -3,7 +3,7 @@ import type { CssVariables } from '@gitkraken/gitkraken-components'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import type { GraphColumnConfig } from '../../../../config'; -import type { CommitListCallback, GraphCommit, GraphRepository, State } from '../../../../plus/webviews/graph/protocol'; +import type { CommitListCallback, GraphRepository, State } from '../../../../plus/webviews/graph/protocol'; import { DidChangeCommitsNotificationType, DidChangeGraphConfigurationNotificationType, @@ -11,7 +11,7 @@ import { DismissPreviewCommandType, GetMoreCommitsCommandType, UpdateColumnCommandType, - UpdateSelectedRepositoryCommandType, + UpdateSelectedRepositoryCommandType as UpdateRepositorySelectionCommandType, UpdateSelectionCommandType, } from '../../../../plus/webviews/graph/protocol'; import { debounce } from '../../../../system/function'; @@ -36,7 +36,6 @@ const graphLaneThemeColors = new Map([ export class GraphApp extends App { private callback?: CommitListCallback; - private $menu?: HTMLElement; constructor() { super('GraphApp'); @@ -56,9 +55,12 @@ export class GraphApp extends App { (name: string, settings: GraphColumnConfig) => this.onColumnChanged(name, settings), 250, )} - onSelectRepository={debounce((path: GraphRepository) => this.onRepositoryChanged(path), 250)} - onMoreCommits={(...params) => this.onMoreCommits(...params)} - onSelectionChange={debounce((selection: GraphCommit[]) => this.onSelectionChanged(selection), 250)} + onSelectRepository={debounce( + (path: GraphRepository) => this.onRepositorySelectionChanged(path), + 250, + )} + onMoreCommits={(...params) => this.onGetMoreCommits(...params)} + onSelectionChange={debounce((selection: string[]) => this.onSelectionChanged(selection), 250)} onDismissPreview={() => this.onDismissPreview()} {...this.state} />, @@ -90,9 +92,50 @@ export class GraphApp extends App { this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); onIpc(DidChangeCommitsNotificationType, msg, params => { + let rows; + if (params?.previousCursor != null && this.state.rows != null) { + const previousRows = this.state.rows; + const lastSha = previousRows[previousRows.length - 1]?.sha; + + let previousRowsLength = previousRows.length; + const newRowsLength = params.rows.length; + + rows = []; + // Preallocate the array to avoid reallocations + rows.length = previousRowsLength + newRowsLength; + + if (params.previousCursor !== lastSha) { + let i = 0; + let row; + for (row of previousRows) { + rows[i++] = row; + if (row.sha === params.previousCursor) { + previousRowsLength = i; + + if (previousRowsLength !== previousRows.length) { + // If we stopped before the end of the array, we need to trim it + rows.length = previousRowsLength + newRowsLength; + } + + break; + } + } + } else { + for (let i = 0; i < previousRowsLength; i++) { + rows[i] = previousRows[i]; + } + } + + for (let i = 0; i < newRowsLength; i++) { + rows[previousRowsLength + i] = params.rows[i]; + } + } else { + rows = params.rows; + } + this.setState({ ...this.state, - commits: params.commits, + rows: rows, log: params.log, }); this.refresh(this.state); @@ -167,19 +210,19 @@ export class GraphApp extends App { }); } - private onRepositoryChanged(repo: GraphRepository) { - this.sendCommand(UpdateSelectedRepositoryCommandType, { + private onRepositorySelectionChanged(repo: GraphRepository) { + this.sendCommand(UpdateRepositorySelectionCommandType, { path: repo.path, }); } - private onMoreCommits(limit?: number) { + private onGetMoreCommits(limit?: number) { this.sendCommand(GetMoreCommitsCommandType, { limit: limit, }); } - private onSelectionChanged(selection: GraphCommit[]) { + private onSelectionChanged(selection: string[]) { this.sendCommand(UpdateSelectionCommandType, { selection: selection, }); @@ -194,9 +237,7 @@ export class GraphApp extends App { } private refresh(state: State) { - if (this.callback !== undefined) { - this.callback(state); - } + this.callback?.(state); } }