diff --git a/extensions/git/package.json b/extensions/git/package.json index a4ec3efa60496..ae77863d17b49 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -2543,6 +2543,15 @@ "default": false, "markdownDescription": "%config.mergeEditor%", "scope": "window" + }, + "git.optimisticUpdate": { + "type": "boolean", + "default": true, + "markdownDescription": "%config.optimisticUpdate%", + "scope": "resource", + "tags": [ + "experimental" + ] } } }, diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 1cbb8966207aa..eee625d02d4f2 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -236,6 +236,7 @@ "config.repositoryScanMaxDepth": "Controls the depth used when scanning workspace folders for Git repositories when `#git.autoRepositoryDetection#` is set to `true` or `subFolders`. Can be set to `-1` for no limit.", "config.useIntegratedAskPass": "Controls whether GIT_ASKPASS should be overwritten to use the integrated version.", "config.mergeEditor": "Open the merge editor for files that are currently under conflict.", + "config.optimisticUpdate": "Controls whether to optimistically update the state of the Source Control view after running git commands.", "submenu.explorer": "Git", "submenu.commit": "Commit", "submenu.commit.amend": "Amend", diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 1ff16ab7c3900..4a2acd2f2dd60 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -1571,7 +1571,7 @@ export class CommandCenter { repository: Repository, getCommitMessage: () => Promise, opts: CommitOptions - ): Promise { + ): Promise { const config = workspace.getConfiguration('git', Uri.file(repository.root)); let promptToSaveFilesBeforeCommit = config.get<'always' | 'staged' | 'never'>('promptToSaveFilesBeforeCommit'); @@ -1611,7 +1611,7 @@ export class CommandCenter { noStagedChanges = repository.indexGroup.resourceStates.length === 0; noUnstagedChanges = repository.workingTreeGroup.resourceStates.length === 0; } else if (pick !== commit) { - return false; // do not commit on cancel + return; // do not commit on cancel } } } @@ -1621,7 +1621,7 @@ export class CommandCenter { const suggestSmartCommit = config.get('suggestSmartCommit') === true; if (!suggestSmartCommit) { - return false; + return; } // prompt the user if we want to commit all or not @@ -1635,9 +1635,9 @@ export class CommandCenter { config.update('enableSmartCommit', true, true); } else if (pick === never) { config.update('suggestSmartCommit', false, true); - return false; + return; } else if (pick !== yes) { - return false; // do not commit on cancel + return; // do not commit on cancel } } @@ -1683,7 +1683,7 @@ export class CommandCenter { const answer = await window.showInformationMessage(l10n.t('There are no changes to commit.'), commitAnyway); if (answer !== commitAnyway) { - return false; + return; } opts.empty = true; @@ -1692,7 +1692,7 @@ export class CommandCenter { if (opts.noVerify) { if (!config.get('allowNoVerifyCommit')) { await window.showErrorMessage(l10n.t('Commits without verification are not allowed, please enable them with the "git.allowNoVerifyCommit" setting.')); - return false; + return; } if (config.get('confirmNoVerifyCommit')) { @@ -1704,7 +1704,7 @@ export class CommandCenter { if (pick === neverAgain) { config.update('confirmNoVerifyCommit', false, true); } else if (pick !== yes) { - return false; + return; } } } @@ -1712,7 +1712,7 @@ export class CommandCenter { const message = await getCommitMessage(); if (!message && !opts.amend && !opts.useEditor) { - return false; + return; } if (opts.all && smartCommitChanges === 'tracked') { @@ -1738,12 +1738,12 @@ export class CommandCenter { } if (!pick) { - return false; + return; } else if (pick === commitToNewBranch) { const branchName = await this.promptForBranchName(repository); if (!branchName) { - return false; + return; } await repository.branch(branchName, true); @@ -1751,8 +1751,6 @@ export class CommandCenter { } await repository.commit(message, opts); - - return true; } private async commitWithAnyInput(repository: Repository, opts: CommitOptions): Promise { @@ -1790,11 +1788,7 @@ export class CommandCenter { return _message; }; - const didCommit = await this.smartCommit(repository, getCommitMessage, opts); - - if (message && didCommit) { - repository.inputBox.value = await repository.getInputTemplate(); - } + await this.smartCommit(repository, getCommitMessage, opts); } @command('git.commit', { repository: true }) diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index b937fcdccaa5e..f86b0549f9afc 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -447,6 +447,13 @@ export interface GitResourceGroup extends SourceControlResourceGroup { resourceStates: Resource[]; } +interface GitResourceGroups { + indexGroup?: Resource[]; + mergeGroup?: Resource[]; + untrackedGroup?: Resource[]; + workingTreeGroup?: Resource[]; +} + export interface OperationResult { operation: Operation; error: any; @@ -974,7 +981,7 @@ export class Repository implements Disposable { || e.affectsConfiguration('git.ignoreSubmodules', root) || e.affectsConfiguration('git.openDiffOnClick', root) || e.affectsConfiguration('git.showActionButton', root) - )(this.updateModelState, this, this.disposables); + )(() => this.updateModelState(), this, this.disposables); const updateInputBoxVisibility = () => { const config = workspace.getConfiguration('git', root); @@ -1247,40 +1254,53 @@ export class Repository implements Disposable { } async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise { - const indexResources = [...this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)]; - const workingGroupResources = opts.all && opts.all !== 'tracked' ? - [...this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)] : []; - if (this.rebaseCommit) { - await this.run(Operation.RebaseContinue, async () => { - if (opts.all) { - const addOpts = opts.all === 'tracked' ? { update: true } : {}; - await this.repository.add([], addOpts); - } + await this.run( + Operation.RebaseContinue, + async () => { + if (opts.all) { + const addOpts = opts.all === 'tracked' ? { update: true } : {}; + await this.repository.add([], addOpts); + } - await this.repository.rebaseContinue(); - this.closeDiffEditors(indexResources, workingGroupResources); - }); + await this.repository.rebaseContinue(); + await this.commitOperationCleanup(message, opts); + }); } else { // Set post-commit command to render the correct action button this.commitCommandCenter.postCommitCommand = opts.postCommitCommand; - await this.run(Operation.Commit, async () => { - if (opts.all) { - const addOpts = opts.all === 'tracked' ? { update: true } : {}; - await this.repository.add([], addOpts); - } + await this.run( + Operation.Commit, + async () => { + if (opts.all) { + const addOpts = opts.all === 'tracked' ? { update: true } : {}; + await this.repository.add([], addOpts); + } - delete opts.all; + delete opts.all; - if (opts.requireUserConfig === undefined || opts.requireUserConfig === null) { - const config = workspace.getConfiguration('git', Uri.file(this.root)); - opts.requireUserConfig = config.get('requireGitUserConfig'); - } + if (opts.requireUserConfig === undefined || opts.requireUserConfig === null) { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + opts.requireUserConfig = config.get('requireGitUserConfig'); + } - await this.repository.commit(message, opts); - this.closeDiffEditors(indexResources, workingGroupResources); - }); + await this.repository.commit(message, opts); + await this.commitOperationCleanup(message, opts); + }, + (): GitResourceGroups => { + let untrackedGroup: Resource[] | undefined = undefined, + workingTreeGroup: Resource[] | undefined = undefined; + + if (opts.all === 'tracked') { + workingTreeGroup = this.workingTreeGroup.resourceStates + .filter(r => r.type === Status.UNTRACKED); + } else if (opts.all) { + untrackedGroup = workingTreeGroup = []; + } + + return { indexGroup: [], untrackedGroup, workingTreeGroup }; + }); // Execute post-commit command await this.run(Operation.PostCommitCommand, async () => { @@ -1289,6 +1309,18 @@ export class Repository implements Disposable { } } + private async commitOperationCleanup(message: string | undefined, opts: CommitOptions) { + if (message) { + this.inputBox.value = await this.getInputTemplate(); + } + + const indexResources = [...this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)]; + const workingGroupResources = opts.all && opts.all !== 'tracked' ? + [...this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)] : []; + + this.closeDiffEditors(indexResources, workingGroupResources); + } + async clean(resources: Uri[]): Promise { await this.run(Operation.Clean, async () => { const toClean: string[] = []; @@ -1869,7 +1901,10 @@ export class Repository implements Disposable { } } - private async run(operation: Operation, runOperation: () => Promise = () => Promise.resolve(null)): Promise { + private async run( + operation: Operation, + runOperation: () => Promise = () => Promise.resolve(null), + getOptimisticResourceGroups: () => GitResourceGroups | undefined = () => undefined): Promise { if (this.state !== RepositoryState.Idle) { throw new Error('Repository not initialized'); } @@ -1883,7 +1918,10 @@ export class Repository implements Disposable { const result = await this.retryRun(operation, runOperation); if (!isReadOnly(operation)) { - await this.updateModelState(); + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const optimisticUpdate = scopedConfig.get('optimisticUpdate') === true; + + await this.updateModelState(optimisticUpdate ? getOptimisticResourceGroups() : undefined); } return result; @@ -1942,18 +1980,14 @@ export class Repository implements Disposable { return folderPaths.filter(p => !ignored.has(p)); } - private async updateModelState() { + private async updateModelState(optimisticResourcesGroups?: GitResourceGroups) { this.updateModelStateCancellationTokenSource?.cancel(); this.updateModelStateCancellationTokenSource = new CancellationTokenSource(); - await this._updateModelState(this.updateModelStateCancellationTokenSource.token); + await this._updateModelState(optimisticResourcesGroups, this.updateModelStateCancellationTokenSource.token); } - private async _updateModelState(cancellationToken?: CancellationToken): Promise { - if (cancellationToken && cancellationToken.isCancellationRequested) { - return; - } - + private async _updateModelState(optimisticResourcesGroups?: GitResourceGroups, cancellationToken?: CancellationToken): Promise { try { const config = workspace.getConfiguration('git'); let sort = config.get<'alphabetically' | 'committerdate'>('branchSortOrder') || 'alphabetically'; @@ -1961,13 +1995,12 @@ export class Repository implements Disposable { sort = 'alphabetically'; } - const [HEAD, refs, remotes, submodules, status, rebaseCommit, mergeInProgress, commitTemplate] = + const [HEAD, refs, remotes, submodules, rebaseCommit, mergeInProgress, commitTemplate] = await Promise.all([ this.repository.getHEADBranch(), this.repository.getRefs({ sort }), this.repository.getRemotes(), this.repository.getSubmodules(), - this.getStatus(cancellationToken), this.getRebaseCommit(), this.isMergeInProgress(), this.getInputTemplate()]); @@ -1979,18 +2012,15 @@ export class Repository implements Disposable { this.rebaseCommit = rebaseCommit; this.mergeInProgress = mergeInProgress; - // set resource groups - this.mergeGroup.resourceStates = status.merge; - this.indexGroup.resourceStates = status.index; - this.workingTreeGroup.resourceStates = status.workingTree; - this.untrackedGroup.resourceStates = status.untracked; - - // set count badge - this.setCountBadge(); + this._sourceControl.commitTemplate = commitTemplate; - this._onDidChangeStatus.fire(); + // Optimistically update the resource states + if (optimisticResourcesGroups) { + this._updateResourceGroupsState(optimisticResourcesGroups); + } - this._sourceControl.commitTemplate = commitTemplate; + // Update resource states based on status information + this._updateResourceGroupsState(await this.getStatus(cancellationToken)); } catch (err) { if (err instanceof CancellationError) { @@ -2001,7 +2031,20 @@ export class Repository implements Disposable { } } - private async getStatus(cancellationToken?: CancellationToken): Promise<{ index: Resource[]; workingTree: Resource[]; merge: Resource[]; untracked: Resource[] }> { + private _updateResourceGroupsState(resourcesGroups: GitResourceGroups): void { + // set resource groups + if (resourcesGroups.indexGroup) { this.indexGroup.resourceStates = resourcesGroups.indexGroup; } + if (resourcesGroups.mergeGroup) { this.mergeGroup.resourceStates = resourcesGroups.mergeGroup; } + if (resourcesGroups.untrackedGroup) { this.untrackedGroup.resourceStates = resourcesGroups.untrackedGroup; } + if (resourcesGroups.workingTreeGroup) { this.workingTreeGroup.resourceStates = resourcesGroups.workingTreeGroup; } + + // set count badge + this.setCountBadge(); + + this._onDidChangeStatus.fire(); + } + + private async getStatus(cancellationToken?: CancellationToken): Promise { if (cancellationToken && cancellationToken.isCancellationRequested) { throw new CancellationError(); } @@ -2088,10 +2131,10 @@ export class Repository implements Disposable { } } - const index: Resource[] = [], - workingTree: Resource[] = [], - merge: Resource[] = [], - untracked: Resource[] = []; + const indexGroup: Resource[] = [], + mergeGroup: Resource[] = [], + untrackedGroup: Resource[] = [], + workingTreeGroup: Resource[] = []; status.forEach(raw => { const uri = Uri.file(path.join(this.repository.root, raw.path)); @@ -2101,42 +2144,42 @@ export class Repository implements Disposable { switch (raw.x + raw.y) { case '??': switch (untrackedChanges) { - case 'mixed': return workingTree.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.UNTRACKED, useIcons)); - case 'separate': return untracked.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.UNTRACKED, useIcons)); + case 'mixed': return workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.UNTRACKED, useIcons)); + case 'separate': return untrackedGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.UNTRACKED, useIcons)); default: return undefined; } case '!!': switch (untrackedChanges) { - case 'mixed': return workingTree.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.IGNORED, useIcons)); - case 'separate': return untracked.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.IGNORED, useIcons)); + case 'mixed': return workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.IGNORED, useIcons)); + case 'separate': return untrackedGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Untracked, uri, Status.IGNORED, useIcons)); default: return undefined; } - case 'DD': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_DELETED, useIcons)); - case 'AU': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_US, useIcons)); - case 'UD': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_THEM, useIcons)); - case 'UA': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_THEM, useIcons)); - case 'DU': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_US, useIcons)); - case 'AA': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_ADDED, useIcons)); - case 'UU': return merge.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_MODIFIED, useIcons)); + case 'DD': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_DELETED, useIcons)); + case 'AU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_US, useIcons)); + case 'UD': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_THEM, useIcons)); + case 'UA': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.ADDED_BY_THEM, useIcons)); + case 'DU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.DELETED_BY_US, useIcons)); + case 'AA': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_ADDED, useIcons)); + case 'UU': return mergeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Merge, uri, Status.BOTH_MODIFIED, useIcons)); } switch (raw.x) { - case 'M': index.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_MODIFIED, useIcons)); break; - case 'A': index.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_ADDED, useIcons)); break; - case 'D': index.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_DELETED, useIcons)); break; - case 'R': index.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_RENAMED, useIcons, renameUri)); break; - case 'C': index.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_COPIED, useIcons, renameUri)); break; + case 'M': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_MODIFIED, useIcons)); break; + case 'A': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_ADDED, useIcons)); break; + case 'D': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_DELETED, useIcons)); break; + case 'R': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_RENAMED, useIcons, renameUri)); break; + case 'C': indexGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.Index, uri, Status.INDEX_COPIED, useIcons, renameUri)); break; } switch (raw.y) { - case 'M': workingTree.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.MODIFIED, useIcons, renameUri)); break; - case 'D': workingTree.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.DELETED, useIcons, renameUri)); break; - case 'A': workingTree.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.INTENT_TO_ADD, useIcons, renameUri)); break; + case 'M': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.MODIFIED, useIcons, renameUri)); break; + case 'D': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.DELETED, useIcons, renameUri)); break; + case 'A': workingTreeGroup.push(new Resource(this.resourceCommandResolver, ResourceGroupType.WorkingTree, uri, Status.INTENT_TO_ADD, useIcons, renameUri)); break; } return undefined; }); - return { index, workingTree, merge, untracked }; + return { indexGroup, mergeGroup, untrackedGroup, workingTreeGroup }; } private setCountBadge(): void {