diff --git a/extensions/git/package.json b/extensions/git/package.json index bee5fc15c87..a36b08c0baa 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -3176,6 +3176,46 @@ "highContrast": "#8db9e2", "highContrastLight": "#1258a7" } + }, + { + "id": "gitDecoration.incomingAddedForegroundColor", + "description": "%colors.incomingAdded%", + "defaults": { + "light": "#587c0c", + "dark": "#81b88b", + "highContrast": "#1b5225", + "highContrastLight": "#374e06" + } + }, + { + "id": "gitDecoration.incomingDeletedForegroundColor", + "description": "%colors.incomingDeleted%", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#c74e39", + "highContrastLight": "#ad0707" + } + }, + { + "id": "gitDecoration.incomingRenamedForegroundColor", + "description": "%colors.incomingRenamed%", + "defaults": { + "light": "#007100", + "dark": "#73C991", + "highContrast": "#73C991", + "highContrastLight": "#007100" + } + }, + { + "id": "gitDecoration.incomingModifiedForegroundColor", + "description": "%colors.incomingModified%", + "defaults": { + "light": "#895503", + "dark": "#E2C08D", + "highContrast": "#E2C08D", + "highContrastLight": "#895503" + } } ], "configurationDefaults": { diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 5cc838d25c3..388d5387261 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -286,6 +286,10 @@ "colors.ignored": "Color for ignored resources.", "colors.conflict": "Color for resources with conflicts.", "colors.submodule": "Color for submodule resources.", + "colors.incomingAdded": "Color for added incoming resource.", + "colors.incomingDeleted": "Color for deleted incoming resource.", + "colors.incomingRenamed": "Color for renamed incoming resource.", + "colors.incomingModified": "Color for modified incoming resource.", "view.workbench.scm.missing.windows": { "message": "[Download Git for Windows](https://git-scm.com/download/win)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).", "comment": [ diff --git a/extensions/git/src/decorationProvider.ts b/extensions/git/src/decorationProvider.ts index c630f00c712..943af377eb7 100644 --- a/extensions/git/src/decorationProvider.ts +++ b/extensions/git/src/decorationProvider.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { window, workspace, Uri, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, ThemeColor } from 'vscode'; +import { window, workspace, Uri, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, ThemeColor, l10n } from 'vscode'; import * as path from 'path'; import { Repository, GitResourceGroup } from './repository'; import { Model } from './model'; import { debounce } from './decorators'; -import { filterEvent, dispose, anyEvent, fireEvent, PromiseSource } from './util'; -import { GitErrorCodes, Status } from './api/git'; +import { filterEvent, dispose, anyEvent, fireEvent, PromiseSource, combinedDisposable } from './util'; +import { Change, GitErrorCodes, Status } from './api/git'; class GitIgnoreDecorationProvider implements FileDecorationProvider { @@ -153,6 +153,100 @@ class GitDecorationProvider implements FileDecorationProvider { } } +class GitIncomingChangesFileDecorationProvider implements FileDecorationProvider { + + private readonly _onDidChangeDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations: Event = this._onDidChangeDecorations.event; + + private decorations = new Map(); + private readonly disposables: Disposable[] = []; + + constructor(private readonly repository: Repository) { + this.disposables.push(window.registerFileDecorationProvider(this)); + repository.historyProvider.onDidChangeCurrentHistoryItemGroupBase(this.onDidChangeCurrentHistoryItemGroupBase, this, this.disposables); + } + + private async onDidChangeCurrentHistoryItemGroupBase(): Promise { + const newDecorations = new Map(); + await this.collectIncomingChangesFileDecorations(newDecorations); + const uris = new Set([...this.decorations.keys()].concat([...newDecorations.keys()])); + + this.decorations = newDecorations; + this._onDidChangeDecorations.fire([...uris.values()].map(value => Uri.parse(value, true))); + } + + private async collectIncomingChangesFileDecorations(bucket: Map): Promise { + for (const change of await this.getIncomingChanges()) { + switch (change.status) { + case Status.INDEX_ADDED: + bucket.set(change.uri.toString(), { + badge: '↓A', + color: new ThemeColor('gitDecoration.incomingAddedForegroundColor'), + tooltip: l10n.t('Incoming Changes (added)'), + }); + break; + case Status.DELETED: + bucket.set(change.uri.toString(), { + badge: '↓D', + color: new ThemeColor('gitDecoration.incomingDeletedForegroundColor'), + tooltip: l10n.t('Incoming Changes (deleted)'), + }); + break; + case Status.INDEX_RENAMED: + bucket.set(change.originalUri.toString(), { + badge: '↓R', + color: new ThemeColor('gitDecoration.incomingRenamedForegroundColor'), + tooltip: l10n.t('Incoming Changes (renamed)'), + }); + break; + case Status.MODIFIED: + bucket.set(change.uri.toString(), { + badge: '↓M', + color: new ThemeColor('gitDecoration.incomingModifiedForegroundColor'), + tooltip: l10n.t('Incoming Changes (modified)'), + }); + break; + default: { + bucket.set(change.uri.toString(), { + badge: '↓~', + color: new ThemeColor('gitDecoration.incomingModifiedForegroundColor'), + tooltip: l10n.t('Incoming Changes'), + }); + break; + } + } + } + } + + private async getIncomingChanges(): Promise { + try { + const historyProvider = this.repository.historyProvider; + const currentHistoryItemGroup = historyProvider.currentHistoryItemGroup; + + if (!currentHistoryItemGroup?.base) { + return []; + } + + const ancestor = await historyProvider.resolveHistoryItemGroupCommonAncestor(currentHistoryItemGroup.id, currentHistoryItemGroup.base.id); + if (!ancestor) { + return []; + } + + const changes = await this.repository.diffBetween(ancestor.id, currentHistoryItemGroup.base.id); + return changes; + } catch (err) { + return []; + } + } + + provideFileDecoration(uri: Uri): FileDecoration | undefined { + return this.decorations.get(uri.toString()); + } + + dispose(): void { + dispose(this.disposables); + } +} export class GitDecorations { @@ -191,8 +285,12 @@ export class GitDecorations { } private onDidOpenRepository(repository: Repository): void { - const provider = new GitDecorationProvider(repository); - this.providers.set(repository, provider); + const providers = combinedDisposable([ + new GitDecorationProvider(repository), + new GitIncomingChangesFileDecorationProvider(repository) + ]); + + this.providers.set(repository, providers); } private onDidCloseRepository(repository: Repository): void { diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index dbb7b8d8a36..80fbbe52799 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -6,7 +6,7 @@ import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryItemGroup, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel } from 'vscode'; import { Repository, Resource } from './repository'; -import { IDisposable, filterEvent } from './util'; +import { IDisposable, dispose, filterEvent } from './util'; import { toGitUri } from './uri'; import { Branch, RefType, UpstreamRef } from './api/git'; import { emojify, ensureEmojis } from './emoji'; @@ -17,16 +17,20 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec private readonly _onDidChangeCurrentHistoryItemGroup = new EventEmitter(); readonly onDidChangeCurrentHistoryItemGroup: Event = this._onDidChangeCurrentHistoryItemGroup.event; + private readonly _onDidChangeCurrentHistoryItemGroupBase = new EventEmitter(); + readonly onDidChangeCurrentHistoryItemGroupBase: Event = this._onDidChangeCurrentHistoryItemGroupBase.event; + private readonly _onDidChangeDecorations = new EventEmitter(); readonly onDidChangeFileDecorations: Event = this._onDidChangeDecorations.event; private _HEAD: Branch | undefined; private _currentHistoryItemGroup: SourceControlHistoryItemGroup | undefined; - get currentHistoryItemGroup(): SourceControlHistoryItemGroup | undefined { return this._currentHistoryItemGroup; } set currentHistoryItemGroup(value: SourceControlHistoryItemGroup | undefined) { this._currentHistoryItemGroup = value; this._onDidChangeCurrentHistoryItemGroup.fire(); + + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - currentHistoryItemGroup:', JSON.stringify(value)); } private historyItemDecorations = new Map(); @@ -55,26 +59,35 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec return; } - this._HEAD = this.repository.HEAD; - // Check if HEAD does not support incoming/outgoing (detached commit, tag) - if (!this._HEAD?.name || !this._HEAD?.commit || this._HEAD.type === RefType.Tag) { - this.currentHistoryItemGroup = undefined; + if (!this.repository.HEAD?.name || !this.repository.HEAD?.commit || this.repository.HEAD.type === RefType.Tag) { this.logger.trace('GitHistoryProvider:onDidRunGitStatus - HEAD does not support incoming/outgoing'); + + this.currentHistoryItemGroup = undefined; + this._HEAD = this.repository.HEAD; return; } this.currentHistoryItemGroup = { - id: `refs/heads/${this._HEAD.name ?? ''}`, - label: this._HEAD.name ?? '', - base: this._HEAD.upstream ? + id: `refs/heads/${this.repository.HEAD.name ?? ''}`, + label: this.repository.HEAD.name ?? '', + base: this.repository.HEAD.upstream ? { - id: `refs/remotes/${this._HEAD.upstream.remote}/${this._HEAD.upstream.name}`, - label: `${this._HEAD.upstream.remote}/${this._HEAD.upstream.name}`, + id: `refs/remotes/${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`, + label: `${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`, } : undefined }; - this.logger.trace('GitHistoryProvider:onDidRunGitStatus - currentHistoryItemGroup:', JSON.stringify(this.currentHistoryItemGroup)); + // Check if Upstream has changed + if (force || + this._HEAD?.upstream?.name !== this.repository.HEAD?.upstream?.name || + this._HEAD?.upstream?.remote === this.repository.HEAD?.upstream?.remote || + this._HEAD?.upstream?.commit === this.repository.HEAD?.upstream?.commit) { + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - Upstream has changed'); + this._onDidChangeCurrentHistoryItemGroupBase.fire(); + } + + this._HEAD = this.repository.HEAD; } async provideHistoryItems(historyItemGroupId: string, options: SourceControlHistoryOptions): Promise { @@ -216,6 +229,6 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec } dispose(): void { - this.disposables.forEach(d => d.dispose()); + dispose(this.disposables); } }