From b8683921cd527b6ffc1fcdf8e962d947d35734a5 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Sat, 30 Jul 2022 02:43:40 -0400 Subject: [PATCH] Improves view perf when connected to rich remote - Showing and refreshing the _Commits_ view - Expanding commits, branches, and worktrees --- CHANGELOG.md | 3 + src/git/models/branch.ts | 18 +- src/git/models/commit.ts | 10 +- src/views/nodes/branchNode.ts | 208 ++++++++++++-------- src/views/nodes/commitNode.ts | 156 +++++++++++---- src/views/nodes/fileRevisionAsCommitNode.ts | 17 +- src/views/nodes/rebaseStatusNode.ts | 8 +- src/views/nodes/viewNode.ts | 38 +++- src/views/nodes/worktreeNode.ts | 140 ++++++++----- src/views/viewBase.ts | 17 +- 10 files changed, 439 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e94123c32a7..453c88b4c4306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed +- Greatly improves performance of many view interactions when connected to a rich integration and pull request details are enabled, including: + - Showing and refreshing the _Commits_ view + - Expanding commits, branches, and worktrees - Remembers chosen filter on files nodes in comparisons when refreshing - Changes display of filtered state of files nodes in comparisons - Improves diff stat parsing performance and reduced memory usage diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts index 0031450c4c767..5bf843bc8dac9 100644 --- a/src/git/models/branch.ts +++ b/src/git/models/branch.ts @@ -4,6 +4,7 @@ import { Starred, WorkspaceStorageKeys } from '../../storage'; import { formatDate, fromNow } from '../../system/date'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; +import { cancellable } from '../../system/promise'; import { sortCompare } from '../../system/string'; import { PullRequest, PullRequestState } from './pullRequest'; import { GitBranchReference, GitReference, GitRevision } from './reference'; @@ -154,6 +155,8 @@ export class GitBranch implements GitBranchReference { return this.date != null ? fromNow(this.date) : ''; } + private _pullRequest: Promise | undefined; + @debug() async getAssociatedPullRequest(options?: { avatarSize?: number; @@ -161,11 +164,18 @@ export class GitBranch implements GitBranchReference { limit?: number; timeout?: number; }): Promise { - const remote = await this.getRemote(); - if (remote == null) return undefined; + if (this._pullRequest == null) { + async function getCore(this: GitBranch): Promise { + const remote = await this.getRemote(); + if (remote == null) return undefined; + + const branch = this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(); + return Container.instance.git.getPullRequestForBranch(branch, remote, options); + } + this._pullRequest = getCore.call(this); + } - const branch = this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(); - return Container.instance.git.getPullRequestForBranch(branch, remote, options); + return cancellable(this._pullRequest, options?.timeout); } @memoize() diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index d5ea553a74f7e..d2244af440961 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -10,9 +10,11 @@ import { cancellable } from '../../system/promise'; import { pad, pluralize } from '../../system/string'; import { PreviousLineComparisonUrisResult } from '../gitProvider'; import { GitUri } from '../gitUri'; +import { RichRemoteProvider } from '../remotes/provider'; import { GitFile, GitFileChange, GitFileWorkingTreeStatus } from './file'; import { PullRequest } from './pullRequest'; import { GitReference, GitRevision, GitRevisionReference, GitStashReference } from './reference'; +import { GitRemote } from './remote'; import { Repository } from './repository'; const stashNumberRegex = /stash@{(\d+)}/; @@ -389,10 +391,14 @@ export class GitCommit implements GitRevisionReference { } private _pullRequest: Promise | undefined; - async getAssociatedPullRequest(options?: { timeout?: number }): Promise { + async getAssociatedPullRequest(options?: { + remote?: GitRemote; + timeout?: number; + }): Promise { if (this._pullRequest == null) { async function getCore(this: GitCommit): Promise { - const remote = await this.container.git.getBestRemoteWithRichProvider(this.repoPath); + const remote = + options?.remote ?? (await this.container.git.getBestRemoteWithRichProvider(this.repoPath)); if (remote?.provider == null) return undefined; return this.container.git.getPullRequestForCommit(this.ref, remote, options); diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 0e873538fd2ec..108467dffa5d7 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -10,11 +10,13 @@ import { GitRemote, GitRemoteType, GitUser, + PullRequest, PullRequestState, } from '../../git/models'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; import { map } from '../../system/iterable'; +import { getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; import { BranchesView } from '../branchesView'; import { CommitsView } from '../commitsView'; @@ -31,8 +33,13 @@ import { RebaseStatusNode } from './rebaseStatusNode'; import { RepositoryNode } from './repositoryNode'; import { ContextValues, PageableViewNode, ViewNode, ViewRefNode } from './viewNode'; +type State = { + pullRequest: PullRequest | null | undefined; + pendingPullRequest: Promise | undefined; +}; + export class BranchNode - extends ViewRefNode + extends ViewRefNode implements PageableViewNode { static key = ':branch'; @@ -40,7 +47,6 @@ export class BranchNode return `${RepositoryNode.getId(repoPath)}${this.key}(${name})${root ? ':root' : ''}`; } - private _children: ViewNode[] | undefined; private readonly options: { expanded: boolean; limitCommits: boolean; @@ -129,67 +135,103 @@ export class BranchNode : this.branch.getNameWithoutRemote().split('/'); } + private _children: ViewNode[] | undefined; + async getChildren(): Promise { if (this._children == null) { - let prPromise; + const branch = this.branch; + + const pullRequest = this.getState('pullRequest'); + if ( this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && - (this.branch.upstream != null || this.branch.remote) + (branch.upstream != null || branch.remote) ) { - prPromise = this.branch.getAssociatedPullRequest( - this.root ? { include: [PullRequestState.Open, PullRequestState.Merged] } : undefined, - ); + if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { + void this.getAssociatedPullRequest( + branch, + this.root ? { include: [PullRequestState.Open, PullRequestState.Merged] } : undefined, + ).then(pr => { + // If we found a pull request, insert it into the children cache (if loaded) and refresh the node + if (pr != null && this._children != null) { + this._children.splice( + this._children[0] instanceof CompareBranchNode ? 1 : 0, + 0, + new PullRequestNode(this.view, this, pr, branch), + ); + } + this.view.triggerNodeChange(this); + }); + + // If we are showing the node, then refresh this node to show a spinner while the pull request is loading + if (!this.splatted) { + queueMicrotask(() => this.view.triggerNodeChange(this)); + return []; + } + } } - const range = !this.branch.remote - ? await this.view.container.git.getBranchAheadRange(this.branch) - : undefined; - const [log, getBranchAndTagTips, status, mergeStatus, rebaseStatus, unpublishedCommits] = await Promise.all( - [ - this.getLog(), - this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name), - this.options.showStatus && this.branch.current - ? this.view.container.git.getStatusForRepo(this.uri.repoPath) - : undefined, - this.options.showStatus && this.branch.current - ? this.view.container.git.getMergeStatus(this.uri.repoPath!) - : undefined, - this.options.showStatus ? this.view.container.git.getRebaseStatus(this.uri.repoPath!) : undefined, - range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { - limit: 0, - ref: range, - }) - : undefined, - ], - ); + const [ + logResult, + getBranchAndTagTipsResult, + statusResult, + mergeStatusResult, + rebaseStatusResult, + unpublishedCommitsResult, + ] = await Promise.allSettled([ + this.getLog(), + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, branch.name), + this.options.showStatus && branch.current + ? this.view.container.git.getStatusForRepo(this.uri.repoPath) + : undefined, + this.options.showStatus && branch.current + ? this.view.container.git.getMergeStatus(this.uri.repoPath!) + : undefined, + this.options.showStatus ? this.view.container.git.getRebaseStatus(this.uri.repoPath!) : undefined, + !branch.remote + ? this.view.container.git.getBranchAheadRange(branch).then(range => + range + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + limit: 0, + ref: range, + }) + : undefined, + ) + : undefined, + ]); + const log = getSettledValue(logResult); if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; const children = []; - let prInsertIndex = 0; - if (this.options.showComparison !== false && !(this.view instanceof RemotesView)) { - prInsertIndex++; children.push( new CompareBranchNode( this.uri, this.view, this, - this.branch, + branch, this.options.showComparison, this.splatted, ), ); } + if (pullRequest != null) { + children.push(new PullRequestNode(this.view, this, pullRequest, branch)); + } + + const status = getSettledValue(statusResult); + const mergeStatus = getSettledValue(mergeStatusResult); + const rebaseStatus = getSettledValue(rebaseStatusResult); + if (this.options.showStatus && mergeStatus != null) { children.push( new MergeStatusNode( this.view, this, - this.branch, + branch, mergeStatus, status ?? (await this.view.container.git.getStatusForRepo(this.uri.repoPath)), this.root, @@ -198,13 +240,13 @@ export class BranchNode } else if ( this.options.showStatus && rebaseStatus != null && - (this.branch.current || this.branch.name === rebaseStatus.incoming.name) + (branch.current || branch.name === rebaseStatus.incoming.name) ) { children.push( new RebaseStatusNode( this.view, this, - this.branch, + branch, rebaseStatus, status ?? (await this.view.container.git.getStatusForRepo(this.uri.repoPath)), this.root, @@ -212,34 +254,30 @@ export class BranchNode ); } else if (this.options.showTracking) { const status = { - ref: this.branch.ref, - repoPath: this.branch.repoPath, - state: this.branch.state, - upstream: this.branch.upstream?.name, + ref: branch.ref, + repoPath: branch.repoPath, + state: branch.state, + upstream: branch.upstream?.name, }; - if (this.branch.upstream != null) { + if (branch.upstream != null) { if (this.root && !status.state.behind && !status.state.ahead) { - children.push( - new BranchTrackingStatusNode(this.view, this, this.branch, status, 'same', this.root), - ); + children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'same', this.root)); } else { if (status.state.behind) { children.push( - new BranchTrackingStatusNode(this.view, this, this.branch, status, 'behind', this.root), + new BranchTrackingStatusNode(this.view, this, branch, status, 'behind', this.root), ); } if (status.state.ahead) { children.push( - new BranchTrackingStatusNode(this.view, this, this.branch, status, 'ahead', this.root), + new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', this.root), ); } } } else { - children.push( - new BranchTrackingStatusNode(this.view, this, this.branch, status, 'none', this.root), - ); + children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', this.root)); } } @@ -247,6 +285,9 @@ export class BranchNode children.push(new MessageNode(this.view, this, '', GlyphChars.Dash.repeat(2), '')); } + const unpublishedCommits = getSettledValue(unpublishedCommitsResult); + const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); + children.push( ...insertDateMarkers( map( @@ -257,7 +298,7 @@ export class BranchNode this, c, unpublishedCommits?.has(c.ref), - this.branch, + branch, getBranchAndTagTips, ), ), @@ -268,34 +309,14 @@ export class BranchNode if (log.hasMore) { children.push( new LoadMoreNode(this.view, this, children[children.length - 1], { - getCount: () => this.view.container.git.getCommitCount(this.branch.repoPath, this.branch.name), + getCount: () => this.view.container.git.getCommitCount(branch.repoPath, branch.name), }), ); } - if (prPromise != null) { - const pr = await prPromise; - if (pr != null) { - children.splice(prInsertIndex, 0, new PullRequestNode(this.view, this, pr, this.branch)); - } - - // const pr = await Promise.race([ - // prPromise, - // new Promise(resolve => setTimeout(() => resolve(null), 100)), - // ]); - // if (pr != null) { - // children.splice(prInsertIndex, 0, new PullRequestNode(this.view, this, pr, this.branch)); - // } else if (pr === null) { - // void prPromise.then(pr => { - // if (pr == null) return; - - // void this.triggerChange(); - // }); - // } - } - this._children = children; } + return this._children; } @@ -425,6 +446,11 @@ export class BranchNode tooltip.appendMarkdown('\\\n$(star-full) Favorited'); } + const pendingPullRequest = this.getState('pendingPullRequest'); + if (pendingPullRequest != null) { + tooltip.appendMarkdown(`\n\n$(loading~spin) Loading associated pull request${GlyphChars.Ellipsis}`); + } + const item = new TreeItem( this.label, this.options.expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, @@ -432,12 +458,15 @@ export class BranchNode item.id = this.id; item.contextValue = contextValue; item.description = description; - item.iconPath = this.options.showAsCommits - ? new ThemeIcon('git-commit', color) - : { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-branch${iconSuffix}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-branch${iconSuffix}.svg`), - }; + item.iconPath = + pendingPullRequest != null + ? new ThemeIcon('loading~spin') + : this.options.showAsCommits + ? new ThemeIcon('git-commit', color) + : { + dark: this.view.container.context.asAbsolutePath(`images/dark/icon-branch${iconSuffix}.svg`), + light: this.view.container.context.asAbsolutePath(`images/light/icon-branch${iconSuffix}.svg`), + }; item.tooltip = tooltip; item.resourceUri = Uri.parse( `gitlens-view://branch/status/${await this.branch.getStatus()}${ @@ -466,7 +495,30 @@ export class BranchNode this._children = undefined; if (reset) { this._log = undefined; + this.deleteState(); + } + } + + private async getAssociatedPullRequest( + branch: GitBranch, + options?: { include?: PullRequestState[] }, + ): Promise { + let pullRequest = this.getState('pullRequest'); + if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); + + let pendingPullRequest = this.getState('pendingPullRequest'); + if (pendingPullRequest == null) { + pendingPullRequest = branch.getAssociatedPullRequest(options); + this.storeState('pendingPullRequest', pendingPullRequest); + + pullRequest = await pendingPullRequest; + this.storeState('pullRequest', pullRequest ?? null); + this.deleteState('pendingPullRequest'); + + return pullRequest; } + + return pendingPullRequest; } private _log: GitLog | undefined; diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index a3be732bae1e6..c28ca175e01ae 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -3,9 +3,12 @@ import type { DiffWithPreviousCommandArgs } from '../../commands'; import { ViewFilesLayout } from '../../configuration'; import { Colors, Commands } from '../../constants'; import { CommitFormatter } from '../../git/formatters'; -import { GitBranch, GitCommit, GitRevisionReference } from '../../git/models'; +import { GitBranch, GitCommit, GitRemote, GitRevisionReference, PullRequest } from '../../git/models'; +import { RichRemoteProvider } from '../../git/remotes/provider'; import { makeHierarchical } from '../../system/array'; +import { gate } from '../../system/decorators/gate'; import { joinPaths, normalizePath } from '../../system/path'; +import { getSettledValue } from '../../system/promise'; import { sortCompare } from '../../system/string'; import { FileHistoryView } from '../fileHistoryView'; import { TagsView } from '../tagsView'; @@ -13,9 +16,20 @@ import { ViewsWithCommits } from '../viewBase'; import { CommitFileNode } from './commitFileNode'; import { FileNode, FolderNode } from './folderNode'; import { PullRequestNode } from './pullRequestNode'; +import { RepositoryNode } from './repositoryNode'; import { ContextValues, ViewNode, ViewRefNode } from './viewNode'; -export class CommitNode extends ViewRefNode { +type State = { + pullRequest: PullRequest | null | undefined; + pendingPullRequest: Promise | undefined; +}; + +export class CommitNode extends ViewRefNode { + static key = ':commit'; + static getId(repoPath: string, sha: string): string { + return `${RepositoryNode.getId(repoPath)}${this.key}(${sha})`; + } + constructor( view: ViewsWithCommits | FileHistoryView, parent: ViewNode, @@ -32,6 +46,10 @@ export class CommitNode extends ViewRefNode { - const commit = this.commit; + if (this._children == null) { + const commit = this.commit; - const commits = await commit.getCommitsForFiles(); - let children: (PullRequestNode | FileNode)[] = commits.map( - c => new CommitFileNode(this.view, this, c.file!, c), - ); + const pullRequest = this.getState('pullRequest'); - if (this.view.config.files.layout !== ViewFilesLayout.List) { - const hierarchy = makeHierarchical( - children as FileNode[], - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); - children = root.getChildren() as FileNode[]; - } else { - (children as FileNode[]).sort((a, b) => sortCompare(a.label!, b.label!)); - } + let children: (PullRequestNode | FileNode)[] = []; - if (!(this.view instanceof TagsView) && !(this.view instanceof FileHistoryView)) { - if (this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForCommits) { - const pr = await commit.getAssociatedPullRequest(); - if (pr != null) { - children.splice(0, 0, new PullRequestNode(this.view, this, pr, commit)); + if ( + !(this.view instanceof TagsView) && + !(this.view instanceof FileHistoryView) && + this.view.config.pullRequests.enabled && + this.view.config.pullRequests.showForCommits + ) { + if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { + void this.getAssociatedPullRequest(commit).then(pr => { + // If we found a pull request, insert it into the children cache (if loaded) and refresh the node + if (pr != null && this._children != null) { + this._children.splice( + 0, + 0, + new PullRequestNode(this.view as ViewsWithCommits, this, pr, commit), + ); + } + // Refresh this node to show a spinner while the pull request is loading + this.view.triggerNodeChange(this); + }); + + // Refresh this node to show a spinner while the pull request is loading + queueMicrotask(() => this.view.triggerNodeChange(this)); + return []; } } + + const commits = await commit.getCommitsForFiles(); + for (const c of commits) { + children.push(new CommitFileNode(this.view, this, c.file!, c)); + } + + if (this.view.config.files.layout !== ViewFilesLayout.List) { + const hierarchy = makeHierarchical( + children as FileNode[], + n => n.uri.relativePath.split('/'), + (...parts: string[]) => normalizePath(joinPaths(...parts)), + this.view.config.files.compact, + ); + + const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); + children = root.getChildren() as FileNode[]; + } else { + (children as FileNode[]).sort((a, b) => sortCompare(a.label!, b.label!)); + } + + if (pullRequest != null) { + children.splice(0, 0, new PullRequestNode(this.view as ViewsWithCommits, this, pullRequest, commit)); + } + + this._children = children; } - return children; + return this._children; } async getTreeItem(): Promise { @@ -85,7 +134,7 @@ export class CommitNode extends ViewRefNode this.getBranchAndTagTips?.(sha, { compact: true }), messageTruncateAtNewLine: true, }); - item.iconPath = this.unpublished - ? new ThemeIcon('arrow-up', new ThemeColor(Colors.UnpublishedCommitIconColor)) - : this.view.config.avatars - ? await this.commit.getAvatarUri({ defaultStyle: this.view.container.config.defaultGravatarsStyle }) - : new ThemeIcon('git-commit'); + + const pendingPullRequest = this.getState('pendingPullRequest'); + + item.iconPath = + pendingPullRequest != null + ? new ThemeIcon('loading~spin') + : this.unpublished + ? new ThemeIcon('arrow-up', new ThemeColor(Colors.UnpublishedCommitIconColor)) + : this.view.config.avatars + ? await this.commit.getAvatarUri({ defaultStyle: this.view.container.config.defaultGravatarsStyle }) + : new ThemeIcon('git-commit'); // item.tooltip = this.tooltip; return item; @@ -122,6 +177,14 @@ export class CommitNode extends ViewRefNode { if (item.tooltip == null) { item.tooltip = await this.getTooltip(); @@ -129,6 +192,28 @@ export class CommitNode extends ViewRefNode, + ): Promise { + let pullRequest = this.getState('pullRequest'); + if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); + + let pendingPullRequest = this.getState('pendingPullRequest'); + if (pendingPullRequest == null) { + pendingPullRequest = commit.getAssociatedPullRequest({ remote: remote }); + this.storeState('pendingPullRequest', pendingPullRequest); + + pullRequest = await pendingPullRequest; + this.storeState('pullRequest', pullRequest ?? null); + this.deleteState('pendingPullRequest'); + + return pullRequest; + } + + return pendingPullRequest; + } + private async getTooltip() { const remotes = await this.view.container.git.getRemotesWithProviders(this.commit.repoPath, { sort: true }); const remote = await this.view.container.git.getBestRemoteWithRichProvider(remotes); @@ -141,13 +226,16 @@ export class CommitNode extends ViewRefNode { if (!this.commit.file?.hasConflicts) return []; - const [mergeStatus, rebaseStatus] = await Promise.all([ + const [mergeStatusResult, rebaseStatusResult] = await Promise.allSettled([ this.view.container.git.getMergeStatus(this.commit.repoPath), this.view.container.git.getRebaseStatus(this.commit.repoPath), ]); - if (mergeStatus == null && rebaseStatus == null) return []; + + const mergeStatus = getSettledValue(mergeStatusResult); + if (mergeStatus == null) return []; + + const rebaseStatus = getSettledValue(rebaseStatusResult); + if (rebaseStatus == null) return []; return [ new MergeConflictCurrentChangesNode(this.view, this, (mergeStatus ?? rebaseStatus)!, this.file), @@ -208,13 +214,16 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) -export abstract class ViewNode { +export abstract class ViewNode { static is(node: any): node is ViewNode { return node instanceof ViewNode; } @@ -148,12 +148,40 @@ export abstract class ViewNode { } getSplattedChild?(): Promise; + + deleteState = StateKey>(key?: T): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to delete state'); + } + return this.view.nodeState.deleteState(this.id, key as string); + } + + getState = StateKey>(key: T): StateValue | undefined { + if (this.id == null) { + debugger; + throw new Error('Id is required to get state'); + } + return this.view.nodeState.getState(this.id, key as string); + } + + storeState = StateKey>(key: T, value: StateValue): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to store state'); + } + this.view.nodeState.storeState(this.id, key as string, value); + } } +type StateKey = keyof T; +type StateValue> = P extends keyof T ? T[P] : never; + export abstract class ViewRefNode< TView extends View = View, TReference extends GitReference = GitReference, -> extends ViewNode { + State extends object = any, +> extends ViewNode { abstract get ref(): TReference; get repoPath(): string { @@ -165,7 +193,11 @@ export abstract class ViewRefNode< } } -export abstract class ViewRefFileNode extends ViewRefNode { +export abstract class ViewRefFileNode extends ViewRefNode< + TView, + GitRevisionReference, + State +> { abstract get file(): GitFile; override toString(): string { diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts index 221890ed3eb01..08e001109365b 100644 --- a/src/views/nodes/worktreeNode.ts +++ b/src/views/nodes/worktreeNode.ts @@ -8,11 +8,13 @@ import { GitRemoteType, GitRevision, GitWorktree, + PullRequest, PullRequestState, } from '../../git/models'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; +import { getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; import { RepositoriesView } from '../repositoriesView'; import { WorktreesView } from '../worktreesView'; @@ -25,14 +27,18 @@ import { RepositoryNode } from './repositoryNode'; import { UncommittedFilesNode } from './UncommittedFilesNode'; import { ContextValues, ViewNode } from './viewNode'; -export class WorktreeNode extends ViewNode { +type State = { + pullRequest: PullRequest | null | undefined; + pendingPullRequest: Promise | undefined; +}; + +export class WorktreeNode extends ViewNode { static key = ':worktree'; static getId(repoPath: string, uri: Uri): string { return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`; } private _branch: GitBranch | undefined; - private _children: ViewNode[] | undefined; constructor( uri: GitUri, @@ -55,45 +61,65 @@ export class WorktreeNode extends ViewNode { return this.uri.repoPath!; } + private _children: ViewNode[] | undefined; + async getChildren(): Promise { if (this._children == null) { const branch = this._branch; - let prPromise; + const pullRequest = this.getState('pullRequest'); + if ( branch != null && this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && (branch.upstream != null || branch.remote) ) { - prPromise = branch.getAssociatedPullRequest({ - include: [PullRequestState.Open, PullRequestState.Merged], - }); + if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { + void this.getAssociatedPullRequest(branch, { + include: [PullRequestState.Open, PullRequestState.Merged], + }).then(pr => { + // If we found a pull request, insert it into the children cache (if loaded) and refresh the node + if (pr != null && this._children != null) { + this._children.splice( + this._children[0] instanceof CompareBranchNode ? 1 : 0, + 0, + new PullRequestNode(this.view, this, pr, branch), + ); + } + this.view.triggerNodeChange(this); + }); + + // If we are showing the node, then refresh this node to show a spinner while the pull request is loading + if (!this.splatted) { + queueMicrotask(() => this.view.triggerNodeChange(this)); + return []; + } + } } - const range = - branch != null && !branch.remote - ? await this.view.container.git.getBranchAheadRange(branch) - : undefined; - const [log, getBranchAndTagTips, status, unpublishedCommits] = await Promise.all([ - this.getLog(), - this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), - this.worktree.getStatus(), - range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { - limit: 0, - ref: range, - }) - : undefined, - ]); + const [logResult, getBranchAndTagTipsResult, statusResult, unpublishedCommitsResult] = + await Promise.allSettled([ + this.getLog(), + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), + this.worktree.getStatus(), + branch != null && !branch.remote + ? this.view.container.git.getBranchAheadRange(branch).then(range => + range + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + limit: 0, + ref: range, + }) + : undefined, + ) + : undefined, + ]); + const log = getSettledValue(logResult); if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; const children = []; - let prInsertIndex = 0; - if (branch != null && this.view.config.showBranchComparison !== false) { - prInsertIndex++; children.push( new CompareBranchNode( this.uri, @@ -106,6 +132,13 @@ export class WorktreeNode extends ViewNode { ); } + if (branch != null && pullRequest != null) { + children.push(new PullRequestNode(this.view, this, pullRequest, branch)); + } + + const unpublishedCommits = getSettledValue(unpublishedCommitsResult); + const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); + children.push( ...insertDateMarkers( map( @@ -128,33 +161,15 @@ export class WorktreeNode extends ViewNode { children.push(new LoadMoreNode(this.view, this, children[children.length - 1])); } + const status = getSettledValue(statusResult); + if (status?.hasChanges) { children.splice(0, 0, new UncommittedFilesNode(this.view, this, status, undefined)); } - if (prPromise != null) { - const pr = await prPromise; - if (pr != null) { - children.splice(prInsertIndex, 0, new PullRequestNode(this.view, this, pr, branch!)); - } - - // const pr = await Promise.race([ - // prPromise, - // new Promise(resolve => setTimeout(() => resolve(null), 100)), - // ]); - // if (pr != null) { - // children.splice(prInsertIndex, 0, new PullRequestNode(this.view, this, pr, this.branch)); - // } else if (pr === null) { - // void prPromise.then(pr => { - // if (pr == null) return; - - // void this.triggerChange(); - // }); - // } - } - this._children = children; } + return this._children; } @@ -300,13 +315,23 @@ export class WorktreeNode extends ViewNode { } } + const pendingPullRequest = this.getState('pendingPullRequest'); + if (pendingPullRequest != null) { + tooltip.appendMarkdown(`\n\n$(loading~spin) Loading associated pull request${GlyphChars.Ellipsis}`); + } + const item = new TreeItem(this.worktree.name, TreeItemCollapsibleState.Collapsed); item.id = this.id; item.description = description; item.contextValue = `${ContextValues.Worktree}${this.worktree.main ? '+main' : ''}${ this.worktree.opened ? '+active' : '' }`; - item.iconPath = this.worktree.opened ? new ThemeIcon('check') : icon; + item.iconPath = + pendingPullRequest != null + ? new ThemeIcon('loading~spin') + : this.worktree.opened + ? new ThemeIcon('check') + : icon; item.tooltip = tooltip; item.resourceUri = hasChanges ? Uri.parse('gitlens-view://worktree/changes') : undefined; return item; @@ -318,9 +343,32 @@ export class WorktreeNode extends ViewNode { this._children = undefined; if (reset) { this._log = undefined; + this.deleteState(); } } + private async getAssociatedPullRequest( + branch: GitBranch, + options?: { include?: PullRequestState[] }, + ): Promise { + let pullRequest = this.getState('pullRequest'); + if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); + + let pendingPullRequest = this.getState('pendingPullRequest'); + if (pendingPullRequest == null) { + pendingPullRequest = branch.getAssociatedPullRequest(options); + this.storeState('pendingPullRequest', pendingPullRequest); + + pullRequest = await pendingPullRequest; + this.storeState('pullRequest', pullRequest ?? null); + this.deleteState('pendingPullRequest'); + + return pullRequest; + } + + return pendingPullRequest; + } + private _log: GitLog | undefined; private async getLog() { if (this._log == null) { diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index f127a35e83324..325d113e12552 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -166,6 +166,8 @@ export abstract class ViewBase< } dispose() { + this._nodeState?.dispose(); + this._nodeState = undefined; Disposable.from(...this.disposables).dispose(); } @@ -586,11 +588,20 @@ export abstract class ViewBase< } } -export class ViewNodeState { +export class ViewNodeState implements Disposable { private _state: Map> | undefined; - deleteState(id: string, key: string): void { - this._state?.get(id)?.delete(key); + dispose() { + this._state?.clear(); + this._state = undefined; + } + + deleteState(id: string, key?: string): void { + if (key == null) { + this._state?.delete(id); + } else { + this._state?.get(id)?.delete(key); + } } getState(id: string, key: string): T | undefined {