From c05b49710b0fff24aa4380751e02b53c54e784ce Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Sat, 21 Oct 2023 09:51:24 +0200 Subject: [PATCH] SCM - Add proposal for inputBox action button (#196121) * SCM - Add proposal for inputBox action button * More reliable fix for overlapping --- extensions/git/package.json | 3 +- extensions/git/src/api/api1.ts | 6 +- extensions/git/src/api/git.d.ts | 9 +- extensions/git/src/commands.ts | 20 +++ extensions/git/src/commitMessageProvider.ts | 154 ++++++++++++++++++ extensions/git/src/model.ts | 25 ++- extensions/git/src/repository.ts | 63 +++++++ extensions/git/tsconfig.json | 1 + src/vs/workbench/api/browser/mainThreadSCM.ts | 25 ++- .../workbench/api/common/extHost.protocol.ts | 7 + src/vs/workbench/api/common/extHostSCM.ts | 40 ++++- .../contrib/scm/browser/media/scm.css | 12 +- .../contrib/scm/browser/scmViewPane.ts | 50 ++++-- src/vs/workbench/contrib/scm/common/scm.ts | 9 + .../contrib/scm/common/scmService.ts | 15 +- .../common/extensionsApiProposals.ts | 1 + ...code.proposed.scmInputBoxActionButton.d.ts | 19 +++ 17 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 extensions/git/src/commitMessageProvider.ts create mode 100644 src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts diff --git a/extensions/git/package.json b/extensions/git/package.json index 797dc472f1f..b981bbad747 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -23,7 +23,8 @@ "scmValidation", "tabInputTextMerge", "timeline", - "contribMergeEditorMenus" + "contribMergeEditorMenus", + "scmInputBoxActionButton" ], "categories": [ "Other" diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 8168b7201fd..e77f0f2d381 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,7 +5,7 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, CommitMessageProvider } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -341,6 +341,10 @@ export class ApiImpl implements API { return this._model.registerBranchProtectionProvider(root, provider); } + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable { + return this._model.registerCommitMessageProvider(provider); + } + constructor(private _model: Model) { } } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 8dd2b7addb3..186f50fd589 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken, ThemeIcon } from 'vscode'; export { ProviderResult } from 'vscode'; export interface Git { @@ -300,6 +300,12 @@ export interface BranchProtectionProvider { provideBranchProtection(): BranchProtection[]; } +export interface CommitMessageProvider { + readonly title: string; + readonly icon?: Uri | { light: Uri, dark: Uri } | ThemeIcon; + provideCommitMessage(changes: string[], cancellationToken?: CancellationToken): Promise; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -327,6 +333,7 @@ export interface API { registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable; } export interface GitExtension { diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 5930bb23fef..9b6b93d4078 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -3581,6 +3581,26 @@ export class CommandCenter { } } + @command('git.generateCommitMessage', { repository: true }) + async generateCommitMessage(repository: Repository): Promise { + if (!repository || !this.model.commitMessageProvider) { + return; + } + + await window.withProgress({ location: ProgressLocation.SourceControl }, async () => { + await repository.generateCommitMessage(); + }); + } + + @command('git.generateCommitMessageCancel', { repository: true }) + generateCommitMessageCancel(repository: Repository): void { + if (!repository || !this.model.commitMessageProvider) { + return; + } + + repository.generateCommitMessageCancel(); + } + private createCommand(id: string, key: string, method: Function, options: ScmCommandOptions): (...args: any[]) => any { const result = (...args: any[]) => { let result: Promise; diff --git a/extensions/git/src/commitMessageProvider.ts b/extensions/git/src/commitMessageProvider.ts new file mode 100644 index 00000000000..573f3da0961 --- /dev/null +++ b/extensions/git/src/commitMessageProvider.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, Disposable, Event, EventEmitter, Uri, workspace, SourceControlInputBoxActionButton, ThemeIcon, l10n } from 'vscode'; +import { CommitMessageProvider, Status } from './api/git'; +import { Repository } from './repository'; +import { dispose } from './util'; + +export interface ICommitMessageProviderRegistry { + readonly onDidChangeCommitMessageProvider: Event; + + commitMessageProvider: CommitMessageProvider | undefined; + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable; +} + +export class TestCommitMessageProvider implements CommitMessageProvider { + + readonly icon = new ThemeIcon('rocket'); + readonly title = 'Generate Commit Message (Test)'; + + async provideCommitMessage(_: string[], token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + return new Promise(resolve => { + token.onCancellationRequested(() => resolve(undefined)); + setTimeout(() => resolve(`Test commit message (${Math.random()})`), 5000); + }); + } +} + +interface ActionButtonState { + readonly isGenerating: boolean; + readonly enabled: boolean; +} + +export class GenerateCommitMessageActionButton { + + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { return this._onDidChange.event; } + + private _state: ActionButtonState; + get state() { return this._state; } + set state(state: ActionButtonState) { + if (this._state.enabled === state.enabled && + this._state.isGenerating === state.isGenerating) { + return; + } + + this._state = state; + this._onDidChange.fire(); + } + + get button(): SourceControlInputBoxActionButton | undefined { + if (this.commitMessageProviderRegistry.commitMessageProvider === undefined) { + return undefined; + } + + return this.state.isGenerating ? + { + icon: new ThemeIcon('debug-stop'), + command: { title: l10n.t('Cancel'), command: 'git.generateCommitMessageCancel' }, + enabled: this.state.enabled + } : + { + icon: this.commitMessageProviderRegistry.commitMessageProvider.icon ?? new ThemeIcon('sparkle'), + command: { title: this.commitMessageProviderRegistry.commitMessageProvider.title, command: 'git.generateCommitMessage' }, + enabled: this.state.enabled + }; + } + + private disposables: Disposable[] = []; + + constructor( + private readonly repository: Repository, + private readonly commitMessageProviderRegistry: ICommitMessageProviderRegistry + ) { + this._state = { + enabled: false, + isGenerating: false + }; + + const root = Uri.file(repository.root); + this.disposables.push(workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('git.enableSmartCommit', root) || + e.affectsConfiguration('git.smartCommitChanges', root) || + e.affectsConfiguration('git.suggestSmartCommit', root)) { + this.onDidChangeSmartCommitSettings(); + } + })); + repository.onDidRunGitStatus(this.onDidRunGitStatus, this, this.disposables); + repository.onDidStartCommitMessageGeneration(this.onDidStartCommitMessageGeneration, this, this.disposables); + repository.onDidEndCommitMessageGeneration(this.onDidEndCommitMessageGeneration, this, this.disposables); + commitMessageProviderRegistry.onDidChangeCommitMessageProvider(this.onDidChangeCommitMessageProvider, this, this.disposables); + } + + private onDidChangeCommitMessageProvider(): void { + this._onDidChange.fire(); + } + + private onDidStartCommitMessageGeneration(): void { + this.state = { ...this.state, isGenerating: true }; + } + + private onDidEndCommitMessageGeneration(): void { + this.state = { ...this.state, isGenerating: false }; + } + + private onDidChangeSmartCommitSettings(): void { + this.state = { + ...this.state, + enabled: this.repositoryHasChangesToCommit() + }; + } + + private onDidRunGitStatus(): void { + this.state = { + ...this.state, + enabled: this.repositoryHasChangesToCommit() + }; + } + + private repositoryHasChangesToCommit(): boolean { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const enableSmartCommit = config.get('enableSmartCommit') === true; + const suggestSmartCommit = config.get('suggestSmartCommit') === true; + const smartCommitChanges = config.get<'all' | 'tracked'>('smartCommitChanges', 'all'); + + const resources = [...this.repository.indexGroup.resourceStates]; + + if ( + // Smart commit enabled (all) + (enableSmartCommit && smartCommitChanges === 'all') || + // Smart commit disabled, smart suggestion enabled + (!enableSmartCommit && suggestSmartCommit) + ) { + resources.push(...this.repository.workingTreeGroup.resourceStates); + } + + // Smart commit enabled (tracked only) + if (enableSmartCommit && smartCommitChanges === 'tracked') { + resources.push(...this.repository.workingTreeGroup.resourceStates.filter(r => r.type !== Status.UNTRACKED)); + } + + return resources.length !== 0; + } + + dispose(): void { + this.disposables = dispose(this.disposables); + } +} diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index fef934fd506..250008ee311 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -12,13 +12,14 @@ import { Git } from './git'; import * as path from 'path'; import * as fs from 'fs'; import { fromGitUri } from './uri'; -import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider } from './api/git'; +import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, CommitMessageProvider } from './api/git'; import { Askpass } from './askpass'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { IPostCommitCommandsProviderRegistry } from './postCommitCommands'; import { IBranchProtectionProviderRegistry } from './branchProtection'; +import { ICommitMessageProviderRegistry } from './commitMessageProvider'; class RepositoryPick implements QuickPickItem { @memoize get label(): string { @@ -170,7 +171,7 @@ class UnsafeRepositoriesManager { } } -export class Model implements IRepositoryResolver, IBranchProtectionProviderRegistry, IRemoteSourcePublisherRegistry, IPostCommitCommandsProviderRegistry, IPushErrorHandlerRegistry { +export class Model implements IRepositoryResolver, IBranchProtectionProviderRegistry, ICommitMessageProviderRegistry, IRemoteSourcePublisherRegistry, IPostCommitCommandsProviderRegistry, IPushErrorHandlerRegistry { private _onDidOpenRepository = new EventEmitter(); readonly onDidOpenRepository: Event = this._onDidOpenRepository.event; @@ -237,6 +238,14 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi private pushErrorHandlers = new Set(); + private _commitMessageProvider: CommitMessageProvider | undefined; + get commitMessageProvider(): CommitMessageProvider | undefined { + return this._commitMessageProvider; + } + + private _onDidChangeCommitMessageProvider = new EventEmitter(); + readonly onDidChangeCommitMessageProvider = this._onDidChangeCommitMessageProvider.event; + private _unsafeRepositoriesManager: UnsafeRepositoriesManager; get unsafeRepositories(): string[] { return this._unsafeRepositoriesManager.repositories; @@ -578,7 +587,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi // Open repository const [dotGit, repositoryRootRealPath] = await Promise.all([this.git.getRepositoryDotGit(repositoryRoot), this.getRepositoryRootRealPath(repositoryRoot)]); - const repository = new Repository(this.git.open(repositoryRoot, repositoryRootRealPath, dotGit, this.logger), this, this, this, this, this, this.globalState, this.logger, this.telemetryReporter); + const repository = new Repository(this.git.open(repositoryRoot, repositoryRootRealPath, dotGit, this.logger), this, this, this, this, this, this, this.globalState, this.logger, this.telemetryReporter); this.open(repository); this._closedRepositoriesManager.deleteRepository(repository.root); @@ -939,6 +948,16 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi return toDisposable(() => this.pushErrorHandlers.delete(handler)); } + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable { + this._commitMessageProvider = provider; + this._onDidChangeCommitMessageProvider.fire(); + + return toDisposable(() => { + this._commitMessageProvider = undefined; + this._onDidChangeCommitMessageProvider.fire(); + }); + } + getPushErrorHandlers(): PushErrorHandler[] { return [...this.pushErrorHandlers]; } diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 3b14f3cfc82..ea78dbeae56 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -24,6 +24,7 @@ import { IPostCommitCommandsProviderRegistry, CommitCommandsCenter } from './pos import { Operation, OperationKind, OperationManager, OperationResult } from './operation'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { GitHistoryProvider } from './historyProvider'; +import { GenerateCommitMessageActionButton, ICommitMessageProviderRegistry } from './commitMessageProvider'; const timeout = (millis: number) => new Promise(c => setTimeout(c, millis)); @@ -673,6 +674,12 @@ export class Repository implements Disposable { private _onDidChangeBranchProtection = new EventEmitter(); readonly onDidChangeBranchProtection: Event = this._onDidChangeBranchProtection.event; + private _onDidStartCommitMessageGeneration = new EventEmitter(); + readonly onDidStartCommitMessageGeneration: Event = this._onDidStartCommitMessageGeneration.event; + + private _onDidEndCommitMessageGeneration = new EventEmitter(); + readonly onDidEndCommitMessageGeneration: Event = this._onDidEndCommitMessageGeneration.event; + @memoize get onDidChangeOperations(): Event { return anyEvent(this.onRunOperation as Event, this.onDidRunOperation as Event); @@ -797,6 +804,7 @@ export class Repository implements Disposable { private commitCommandCenter: CommitCommandsCenter; private resourceCommandResolver = new ResourceCommandResolver(this); private updateModelStateCancellationTokenSource: CancellationTokenSource | undefined; + private generateCommitMessageCancellationTokenSource: CancellationTokenSource | undefined; private disposables: Disposable[] = []; constructor( @@ -806,6 +814,7 @@ export class Repository implements Disposable { remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry, postCommitCommandsProviderRegistry: IPostCommitCommandsProviderRegistry, private readonly branchProtectionProviderRegistry: IBranchProtectionProviderRegistry, + private readonly commitMessageProviderRegistry: ICommitMessageProviderRegistry, globalState: Memento, private readonly logger: LogOutputChannel, private telemetryReporter: TelemetryReporter @@ -850,6 +859,12 @@ export class Repository implements Disposable { this._sourceControl.acceptInputCommand = { command: 'git.commit', title: l10n.t('Commit'), arguments: [this._sourceControl] }; this._sourceControl.inputBox.validateInput = this.validateInput.bind(this); + + const inputActionButton = new GenerateCommitMessageActionButton(this, commitMessageProviderRegistry); + this.disposables.push(inputActionButton); + inputActionButton.onDidChange(() => this._sourceControl.inputBox.actionButton = inputActionButton.button); + this._sourceControl.inputBox.actionButton = inputActionButton.button; + this.disposables.push(this._sourceControl); this.updateInputBoxPlaceholder(); @@ -2006,6 +2021,54 @@ export class Repository implements Disposable { }); } + async generateCommitMessage(): Promise { + if (!this.commitMessageProviderRegistry.commitMessageProvider) { + return; + } + + this._onDidStartCommitMessageGeneration.fire(); + this.generateCommitMessageCancellationTokenSource?.cancel(); + this.generateCommitMessageCancellationTokenSource = new CancellationTokenSource(); + + try { + const diff: string[] = []; + if (this.indexGroup.resourceStates.length !== 0) { + for (const file of this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)) { + diff.push(await this.diffIndexWithHEAD(file)); + } + } else { + for (const file of this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)) { + diff.push(await this.diffWithHEAD(file)); + } + } + + if (diff.length === 0) { + return; + } + + const token = this.generateCommitMessageCancellationTokenSource.token; + const provider = this.commitMessageProviderRegistry.commitMessageProvider; + const commitMessage = await provider.provideCommitMessage(diff, token); + if (commitMessage) { + this.inputBox.value = commitMessage; + } + } + catch (err) { + this.logger.error(err); + } + finally { + this._onDidEndCommitMessageGeneration.fire(); + } + } + + generateCommitMessageCancel(): void { + this.generateCommitMessageCancellationTokenSource?.cancel(); + this.generateCommitMessageCancellationTokenSource?.dispose(); + this.generateCommitMessageCancellationTokenSource = undefined; + + this._onDidEndCommitMessageGeneration.fire(); + } + // Parses output of `git check-ignore -v -z` and returns only those paths // that are actually ignored by git. // Matches to a negative pattern (starting with '!') are filtered out. diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index d5fdbd539da..022beed0bcb 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -13,6 +13,7 @@ "../../src/vscode-dts/vscode.proposed.diffCommand.d.ts", "../../src/vscode-dts/vscode.proposed.scmActionButton.d.ts", "../../src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts", + "../../src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts", "../../src/vscode-dts/vscode.proposed.scmSelectedProvider.d.ts", "../../src/vscode-dts/vscode.proposed.scmValidation.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 5ddf13e573e..57dbf7b41f4 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -7,7 +7,7 @@ import { URI, UriComponents } from 'vs/base/common/uri'; import { Event, Emitter } from 'vs/base/common/event'; import { IDisposable, DisposableStore, combinedDisposable, dispose } from 'vs/base/common/lifecycle'; import { ISCMService, ISCMRepository, ISCMProvider, ISCMResource, ISCMResourceGroup, ISCMResourceDecorations, IInputValidation, ISCMViewService, InputValidationType, ISCMActionButtonDescriptor } from 'vs/workbench/contrib/scm/common/scm'; -import { ExtHostContext, MainThreadSCMShape, ExtHostSCMShape, SCMProviderFeatures, SCMRawResourceSplices, SCMGroupFeatures, MainContext, SCMHistoryItemDto, SCMActionButtonDto, SCMHistoryItemGroupDto } from '../common/extHost.protocol'; +import { ExtHostContext, MainThreadSCMShape, ExtHostSCMShape, SCMProviderFeatures, SCMRawResourceSplices, SCMGroupFeatures, MainContext, SCMHistoryItemDto, SCMActionButtonDto, SCMHistoryItemGroupDto, SCMInputActionButtonDto } from '../common/extHost.protocol'; import { Command } from 'vs/editor/common/languages'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { ISplice, Sequence } from 'vs/base/common/sequence'; @@ -18,6 +18,19 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IQuickDiffService, QuickDiffProvider } from 'vs/workbench/contrib/scm/common/quickDiff'; import { ISCMHistoryItem, ISCMHistoryItemChange, ISCMHistoryItemGroup, ISCMHistoryOptions, ISCMHistoryProvider } from 'vs/workbench/contrib/scm/common/history'; +function getSCMInputBoxActionButtonIcon(actionButton: SCMInputActionButtonDto): URI | { light: URI; dark: URI } | ThemeIcon | undefined { + if (!actionButton.icon) { + return undefined; + } else if (URI.isUri(actionButton.icon)) { + return URI.revive(actionButton.icon); + } else if (ThemeIcon.isThemeIcon(actionButton.icon)) { + return actionButton.icon; + } else { + const icon = actionButton.icon as { light: UriComponents; dark: UriComponents }; + return { light: URI.revive(icon.light), dark: URI.revive(icon.dark) }; + } +} + function getSCMHistoryItemIcon(historyItem: SCMHistoryItemDto): URI | { light: URI; dark: URI } | ThemeIcon | undefined { if (!historyItem.icon) { return undefined; @@ -554,6 +567,16 @@ export class MainThreadSCM implements MainThreadSCMShape { repository.input.visible = visible; } + $setInputBoxActionButton(sourceControlHandle: number, actionButton?: SCMInputActionButtonDto | null | undefined): void { + const repository = this._repositories.get(sourceControlHandle); + + if (!repository) { + return; + } + + repository.input.actionButton = actionButton ? { ...actionButton, icon: getSCMInputBoxActionButtonIcon(actionButton) } : undefined; + } + $showValidationMessage(sourceControlHandle: number, message: string | IMarkdownString, type: InputValidationType) { const repository = this._repositories.get(sourceControlHandle); if (!repository) { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7567bca077f..efc6230b5d8 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1415,6 +1415,12 @@ export interface SCMActionButtonDto { enabled: boolean; } +export interface SCMInputActionButtonDto { + command: ICommandDto; + icon?: UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon; + enabled: boolean; +} + export interface SCMGroupFeatures { hideWhenEmpty?: boolean; } @@ -1484,6 +1490,7 @@ export interface MainThreadSCMShape extends IDisposable { $setInputBoxPlaceholder(sourceControlHandle: number, placeholder: string): void; $setInputBoxEnablement(sourceControlHandle: number, enabled: boolean): void; $setInputBoxVisibility(sourceControlHandle: number, visible: boolean): void; + $setInputBoxActionButton(sourceControlHandle: number, actionButton?: SCMInputActionButtonDto | null): void; $showValidationMessage(sourceControlHandle: number, message: string | IMarkdownString, type: InputValidationType): void; $setValidationProviderIsEnabled(sourceControlHandle: number, enabled: boolean): void; diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index 561592baf84..fe6da7f00c0 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -45,6 +45,19 @@ function getIconResource(decorations?: vscode.SourceControlResourceThemableDecor } } +function getInputBoxActionButtonIcon(actionButton?: vscode.SourceControlInputBoxActionButton): UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon | undefined { + if (!actionButton?.icon) { + return undefined; + } else if (URI.isUri(actionButton.icon)) { + return actionButton.icon; + } else if (ThemeIcon.isThemeIcon(actionButton.icon)) { + return actionButton.icon; + } else { + const icon = actionButton.icon as { light: URI; dark: URI }; + return { light: icon.light, dark: icon.dark }; + } +} + function getHistoryItemIconDto(historyItem: vscode.SourceControlHistoryItem): UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon | undefined { if (!historyItem.icon) { return undefined; @@ -313,13 +326,36 @@ export class ExtHostSCMInputBox implements vscode.SourceControlInputBox { this.#proxy.$setInputBoxVisibility(this._sourceControlHandle, visible); } + private _actionButton: vscode.SourceControlInputBoxActionButton | undefined; + private _actionButtonDisposables = new MutableDisposable(); + + get actionButton(): vscode.SourceControlInputBoxActionButton | undefined { + checkProposedApiEnabled(this._extension, 'scmInputBoxActionButton'); + return this._actionButton; + } + + set actionButton(actionButton: vscode.SourceControlInputBoxActionButton | undefined) { + checkProposedApiEnabled(this._extension, 'scmInputBoxActionButton'); + this._actionButtonDisposables.value = new DisposableStore(); + + this._actionButton = actionButton; + + const internal = actionButton !== undefined ? + { + command: this._commands.converter.toInternal(actionButton.command, this._actionButtonDisposables.value), + icon: getInputBoxActionButtonIcon(actionButton), + enabled: actionButton.enabled + } : undefined; + this.#proxy.$setInputBoxActionButton(this._sourceControlHandle, internal ?? null); + } + get document(): vscode.TextDocument { checkProposedApiEnabled(this._extension, 'scmTextDocument'); return this.#extHostDocuments.getDocument(this._documentUri); } - constructor(private _extension: IExtensionDescription, _extHostDocuments: ExtHostDocuments, proxy: MainThreadSCMShape, private _sourceControlHandle: number, private _documentUri: URI) { + constructor(private _extension: IExtensionDescription, private _commands: ExtHostCommands, _extHostDocuments: ExtHostDocuments, proxy: MainThreadSCMShape, private _sourceControlHandle: number, private _documentUri: URI) { this.#extHostDocuments = _extHostDocuments; this.#proxy = proxy; } @@ -680,7 +716,7 @@ class ExtHostSourceControl implements vscode.SourceControl { query: _rootUri ? `rootUri=${encodeURIComponent(_rootUri.toString())}` : undefined }); - this._inputBox = new ExtHostSCMInputBox(_extension, _extHostDocuments, this.#proxy, this.handle, inputBoxDocumentUri); + this._inputBox = new ExtHostSCMInputBox(_extension, _commands, _extHostDocuments, this.#proxy, this.handle, inputBoxDocumentUri); this.#proxy.$registerSourceControl(this.handle, _id, _label, _rootUri, inputBoxDocumentUri); } diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index 6ccd5dbf96d..16274449aff 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -199,16 +199,12 @@ flex-grow: 100; } -.scm-view .monaco-list .monaco-list-row .scm-input > .actions, .scm-view .monaco-list .monaco-list-row .resource-group > .actions, .scm-view .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions { display: none; max-width: fit-content; } -.scm-view .monaco-list .monaco-list-row:hover .scm-input > .actions, -.scm-view .monaco-list .monaco-list-row.selected .scm-input > .actions, -.scm-view .monaco-list .monaco-list-row.focused .scm-input > .actions, .scm-view .monaco-list .monaco-list-row:hover .resource-group > .actions, .scm-view .monaco-list .monaco-list-row.selected .resource-group > .actions, .scm-view .monaco-list .monaco-list-row.focused .resource-group > .actions, @@ -231,7 +227,7 @@ } .scm-view.show-actions .scm-provider > .actions, -.scm-view.show-actions > .monaco-list .monaco-list-row .scm-input > .actions, +.scm-view.show-actions > .monaco-list .monaco-list-row .scm-input > .scm-editor > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .resource-group > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions { display: block; @@ -251,6 +247,11 @@ position: absolute; top: 7px; right: 20px; + z-index: 2; +} + +.scm-view .scm-input .actions .actions-container { + background-color: var(--vscode-input-background); } .scm-view .scm-input .actions .action-label { @@ -418,7 +419,6 @@ z-index: 1; padding: 2px 6px; box-sizing: border-box; - width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 46151c7881d..a9cb19a3718 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -227,7 +227,6 @@ class SCMTreeDragAndDrop implements ITreeDragAndDrop { interface InputTemplate { readonly inputWidget: SCMInputWidget; inputWidgetHeight: number; - actionBar: ActionBar; readonly elementDisposables: DisposableStore; readonly templateDisposable: IDisposable; } @@ -247,9 +246,7 @@ class InputRenderer implements ICompressibleTreeRenderer void, - private actionViewItemProvider: IActionViewItemProvider, - @IInstantiationService private instantiationService: IInstantiationService, - @ISCMViewService private scmViewService: ISCMViewService + @IInstantiationService private instantiationService: IInstantiationService ) { } renderTemplate(container: HTMLElement): InputTemplate { @@ -264,10 +261,7 @@ class InputRenderer implements ICompressibleTreeRenderer, index: number, templateData: InputTemplate): void { @@ -320,13 +314,6 @@ class InputRenderer implements ICompressibleTreeRenderer templateData.inputWidget.layout(); templateData.elementDisposables.add(this.outerLayout.onDidChange(layoutEditor)); layoutEditor(); - - // Action bar - templateData.actionBar.clear(); - templateData.actionBar.context = input.repository.provider; - - const menus = this.scmViewService.menus.getRepositoryMenus(input.repository.provider); - templateData.elementDisposables.add(connectPrimaryMenuToInlineActionBar(menus.inputBoxMenu, templateData.actionBar)); } renderCompressedElements(): void { @@ -1285,6 +1272,7 @@ class ViewModel { for (const repository of added) { const disposable = combinedDisposable( repository.provider.groups.onDidSplice(splice => this._onDidSpliceGroups(item, splice)), + repository.input.onDidChangeActionButton(() => this.refresh(item)), repository.input.onDidChangeVisibility(() => this.refresh(item)), repository.provider.onDidChange(() => { if (this.showActionButton) { @@ -1820,6 +1808,7 @@ class SCMInputWidget { private editorContainer: HTMLElement; private placeholderTextContainer: HTMLElement; private inputEditor: CodeEditorWidget; + private actionBar: ActionBar; private readonly disposables = new DisposableStore(); private model: { readonly input: ISCMInput; textModelRef?: IReference } | undefined; @@ -1968,6 +1957,31 @@ class SCMInputWidget { }; this.repositoryDisposables.add(input.onDidChangeEnablement(enabled => updateEnablement(enabled))); updateEnablement(input.enabled); + + // ActionBar + this.actionBar.context = input.repository.provider; + + const onDidChangeActionButton = () => { + this.actionBar.clear(); + if (!input.actionButton) { + return; + } + + const action = new Action( + input.actionButton.command.id, + input.actionButton.command.title, + ThemeIcon.isThemeIcon(input.actionButton.icon) ? ThemeIcon.asClassName(input.actionButton.icon) : undefined, + input.actionButton.enabled, + () => this.commandService.executeCommand(input.actionButton!.command.id, ...(input.actionButton!.command.arguments || []))); + + this.actionBar.push(action, { icon: true, label: false }); + + // Update placeholder width to accommodate for the action bar + this.placeholderTextContainer.style.width = input.actionButton ? 'calc(100% - 26px)' : '100%'; + }; + + this.repositoryDisposables.add(input.onDidChangeActionButton(onDidChangeActionButton, this)); + onDidChangeActionButton(); } get selections(): Selection[] | null { @@ -2010,6 +2024,7 @@ class SCMInputWidget { @ISCMViewService private readonly scmViewService: ISCMViewService, @IContextViewService private readonly contextViewService: IContextViewService, @IOpenerService private readonly openerService: IOpenerService, + @ICommandService private readonly commandService: ICommandService, ) { this.element = append(container, $('.scm-editor')); this.editorContainer = append(this.element, $('.scm-editor-container')); @@ -2063,6 +2078,9 @@ class SCMInputWidget { ]) }; + this.actionBar = new ActionBar(append(this.element, $('.actions'))); + this.disposables.add(this.actionBar); + const services = new ServiceCollection([IContextKeyService, contextKeyService2]); const instantiationService2 = instantiationService.createChild(services); this.inputEditor = instantiationService2.createInstance(CodeEditorWidget, this.editorContainer, editorOptions, codeEditorWidgetOptions); @@ -2361,7 +2379,7 @@ export class SCMViewPane extends ViewPane { this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.providerCountBadge'), this.disposables)(updateProviderCountVisibility)); updateProviderCountVisibility(); - this.inputRenderer = this.instantiationService.createInstance(InputRenderer, this.layoutCache, overflowWidgetsDomNode, (input, height) => this.tree.updateElementHeight(input, height), getActionViewItemProvider(this.instantiationService)); + this.inputRenderer = this.instantiationService.createInstance(InputRenderer, this.layoutCache, overflowWidgetsDomNode, (input, height) => this.tree.updateElementHeight(input, height)); const delegate = new ListDelegate(this.inputRenderer); this.actionButtonRenderer = this.instantiationService.createInstance(ActionButtonRenderer); diff --git a/src/vs/workbench/contrib/scm/common/scm.ts b/src/vs/workbench/contrib/scm/common/scm.ts index af7852084c7..4327c3c83d8 100644 --- a/src/vs/workbench/contrib/scm/common/scm.ts +++ b/src/vs/workbench/contrib/scm/common/scm.ts @@ -102,6 +102,12 @@ export interface ISCMInputChangeEvent { readonly reason?: SCMInputChangeReason; } +export interface ISCMInputActionButtonDescriptor { + command: Command; + icon?: URI | { light: URI; dark: URI } | ThemeIcon; + enabled: boolean; +} + export interface ISCMActionButtonDescriptor { command: Command; secondaryCommands?: Command[][]; @@ -134,6 +140,9 @@ export interface ISCMInput { visible: boolean; readonly onDidChangeVisibility: Event; + actionButton: ISCMInputActionButtonDescriptor | undefined; + readonly onDidChangeActionButton: Event; + setFocus(): void; readonly onDidChangeFocus: Event; diff --git a/src/vs/workbench/contrib/scm/common/scmService.ts b/src/vs/workbench/contrib/scm/common/scmService.ts index b8a34af87b1..3498d5c4041 100644 --- a/src/vs/workbench/contrib/scm/common/scmService.ts +++ b/src/vs/workbench/contrib/scm/common/scmService.ts @@ -5,7 +5,7 @@ import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { Event, Emitter } from 'vs/base/common/event'; -import { ISCMService, ISCMProvider, ISCMInput, ISCMRepository, IInputValidator, ISCMInputChangeEvent, SCMInputChangeReason, InputValidationType, IInputValidation } from './scm'; +import { ISCMService, ISCMProvider, ISCMInput, ISCMRepository, IInputValidator, ISCMInputChangeEvent, SCMInputChangeReason, InputValidationType, IInputValidation, ISCMActionButtonDescriptor } from './scm'; import { ILogService } from 'vs/platform/log/common/log'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; @@ -69,6 +69,19 @@ class SCMInput implements ISCMInput { private readonly _onDidChangeVisibility = new Emitter(); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + private _actionButton: ISCMActionButtonDescriptor | undefined; + get actionButton(): ISCMActionButtonDescriptor | undefined { + return this._actionButton; + } + + set actionButton(actionButton: ISCMActionButtonDescriptor) { + this._actionButton = actionButton; + this._onDidChangeActionButton.fire(); + } + + private readonly _onDidChangeActionButton = new Emitter(); + readonly onDidChangeActionButton: Event = this._onDidChangeActionButton.event; + setFocus(): void { this._onDidChangeFocus.fire(); } diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 3f07e089f9e..b76468a8e26 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -80,6 +80,7 @@ export const allApiProposals = Object.freeze({ saveEditor: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.saveEditor.d.ts', scmActionButton: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmActionButton.d.ts', scmHistoryProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts', + scmInputBoxActionButton: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts', scmSelectedProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmSelectedProvider.d.ts', scmTextDocument: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmTextDocument.d.ts', scmValidation: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmValidation.d.ts', diff --git a/src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts b/src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts new file mode 100644 index 00000000000..890cadaab91 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.scmInputBoxActionButton.d.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/195474 + + export interface SourceControlInputBoxActionButton { + readonly command: Command; + readonly enabled: boolean; + readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + } + + export interface SourceControlInputBox { + actionButton?: SourceControlInputBoxActionButton; + } + +}