From 55c726b53e6ebcc04553fe095fcd0ac01c095e02 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Mon, 27 Sep 2021 01:01:00 -0400 Subject: [PATCH] Adds scm action button Refs: #110882 --- extensions/git/src/repository.ts | 27 ++++ src/vs/vscode.proposed.d.ts | 4 + src/vs/workbench/api/browser/mainThreadSCM.ts | 1 + .../workbench/api/common/extHost.protocol.ts | 1 + src/vs/workbench/api/common/extHostSCM.ts | 14 ++ .../contrib/scm/browser/media/scm.css | 11 ++ .../contrib/scm/browser/scm.contribution.ts | 5 + .../contrib/scm/browser/scmViewPane.ts | 151 ++++++++++++++++-- src/vs/workbench/contrib/scm/browser/util.ts | 6 +- src/vs/workbench/contrib/scm/common/scm.ts | 7 + 10 files changed, 214 insertions(+), 13 deletions(-) diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 2c07c275720..e9064338e0b 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1906,6 +1906,33 @@ export class Repository implements Disposable { return undefined; }); + let actionButton: SourceControl['actionButton']; + if (HEAD !== undefined && workingTree.length === 0 && index.length === 0 && untracked.length === 0 && merge.length === 0) { + if (HEAD.name && HEAD.commit) { + if (HEAD.upstream) { + if (HEAD.ahead) { + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const rebaseWhenSync = config.get('rebaseWhenSync'); + + actionButton = { + command: rebaseWhenSync ? 'git.syncRebase' : 'git.sync', + title: localize('scm button sync title', ' Sync Changes \u00a0$(sync){0}{1}', HEAD.behind ? `${HEAD.behind}$(arrow-down) ` : '', `${HEAD.ahead}$(arrow-up)`), + tooltip: this.syncTooltip, + arguments: [this._sourceControl], + }; + } + } else { + actionButton = { + command: 'git.publish', + title: localize('scm button publish title', "$(cloud-upload) Publish Changes"), + tooltip: localize('scm button publish tooltip', "Publish Changes"), + arguments: [this._sourceControl], + }; + } + } + } + this._sourceControl.actionButton = actionButton; + // set resource groups this.mergeGroup.resourceStates = merge; this.indexGroup.resourceStates = index; diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index bfe8c8d4690..b232f7a9448 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2820,4 +2820,8 @@ declare module 'vscode' { } //#endregion + + export interface SourceControl { + actionButton?: Command; + } } diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 03b04b12744..75328bdf9ae 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -120,6 +120,7 @@ class MainThreadSCMProvider implements ISCMProvider { get commitTemplate(): string { return this.features.commitTemplate || ''; } get acceptInputCommand(): Command | undefined { return this.features.acceptInputCommand; } + get actionButton(): Command | undefined { return this.features.actionButton ?? undefined; } get statusBarCommands(): Command[] | undefined { return this.features.statusBarCommands; } get count(): number | undefined { return this.features.count; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4e773d799ce..0670effe6c0 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1053,6 +1053,7 @@ export interface SCMProviderFeatures { count?: number; commitTemplate?: string; acceptInputCommand?: modes.Command; + actionButton?: ICommandDto | null; statusBarCommands?: ICommandDto[]; } diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index 089f22519c5..17b10ac3df3 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -509,6 +509,19 @@ class ExtHostSourceControl implements vscode.SourceControl { this._proxy.$updateSourceControl(this.handle, { acceptInputCommand: internal }); } + private _actionButtonDisposables = new MutableDisposable(); + private _actionButton: vscode.Command | undefined; + get actionButton(): vscode.Command | undefined { return this._actionButton; } + set actionButton(actionButton: vscode.Command | undefined) { + this._actionButtonDisposables.value = new DisposableStore(); + + this._actionButton = actionButton; + + const internal = actionButton !== undefined ? this._commands.converter.toInternal(this._actionButton, this._actionButtonDisposables.value) : undefined; + this._proxy.$updateSourceControl(this.handle, { actionButton: internal ?? null }); + } + + private _statusBarDisposables = new MutableDisposable(); private _statusBarCommands: vscode.Command[] | undefined = undefined; @@ -630,6 +643,7 @@ class ExtHostSourceControl implements vscode.SourceControl { dispose(): void { this._acceptInputDisposables.dispose(); + this._actionButtonDisposables.dispose(); this._statusBarDisposables.dispose(); this._groups.forEach(group => group.dispose()); diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index 4de2e55d1be..72859ad9f81 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -194,6 +194,17 @@ justify-content: center; } +.scm-view .button-container { + margin-left: 8px; + height: 100%; + display: flex; + align-items: center; +} + +.scm-view .button-container .codicon { + font-size: small !important; +} + .scm-view .scm-editor.hidden { display: none; } diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 06d45b6ed5f..30dc1958b76 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -207,6 +207,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'number', description: localize('providersVisible', "Controls how many repositories are visible in the Source Control Repositories section. Set to `0` to be able to manually resize the view."), default: 10 + }, + 'scm.showActionButtons': { + type: 'boolean', + markdownDescription: localize('showActionButtons', "Controls whether action buttons are visible in the SCM view."), + default: true } } }); diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 9565b76cf4d..f6623f23e0e 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -6,11 +6,11 @@ import 'vs/css!./media/scm'; import { Event, Emitter } from 'vs/base/common/event'; import { basename, dirname } from 'vs/base/common/resources'; -import { IDisposable, Disposable, DisposableStore, combinedDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, DisposableStore, combinedDisposable, dispose, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ViewPane, IViewPaneOptions, ViewAction } from 'vs/workbench/browser/parts/views/viewPane'; -import { append, $, Dimension, asCSSUrl, trackFocus } from 'vs/base/browser/dom'; +import { append, $, Dimension, asCSSUrl, trackFocus, clearNode } from 'vs/base/browser/dom'; import { IListVirtualDelegate, IIdentityProvider } from 'vs/base/browser/ui/list/list'; -import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID } from 'vs/workbench/contrib/scm/common/scm'; +import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID, ISCMActionButton } from 'vs/workbench/contrib/scm/common/scm'; import { ResourceLabels, IResourceLabel } from 'vs/workbench/browser/labels'; import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; @@ -23,8 +23,8 @@ import { MenuItemAction, IMenuService, registerAction2, MenuId, IAction2Options, import { IAction, ActionRunner } from 'vs/base/common/actions'; import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; import { IThemeService, registerThemingParticipant, IFileIconTheme, ThemeIcon, IColorTheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; -import { isSCMResource, isSCMResourceGroup, connectPrimaryMenuToInlineActionBar, isSCMRepository, isSCMInput, collectContextMenuActions, getActionViewItemProvider } from './util'; -import { attachBadgeStyler } from 'vs/platform/theme/common/styler'; +import { isSCMResource, isSCMResourceGroup, connectPrimaryMenuToInlineActionBar, isSCMRepository, isSCMInput, collectContextMenuActions, getActionViewItemProvider, isSCMActionButton } from './util'; +import { attachBadgeStyler, attachButtonStyler } from 'vs/platform/theme/common/styler'; import { WorkbenchCompressibleObjectTree, IOpenEvent } from 'vs/platform/list/browser/listService'; import { IConfigurationService, ConfigurationTarget, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { disposableTimeout, ThrottledDelayer } from 'vs/base/common/async'; @@ -82,8 +82,11 @@ import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from 'vs/ import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer'; +import { Button } from 'vs/base/browser/ui/button/button'; +import { Command } from 'vs/editor/common/modes'; +import { INotificationService } from 'vs/platform/notification/common/notification'; -type TreeElement = ISCMRepository | ISCMInput | ISCMResourceGroup | IResourceNode | ISCMResource; +type TreeElement = ISCMRepository | ISCMInput | ISCMActionButton | ISCMResourceGroup | IResourceNode | ISCMResource; interface ISCMLayout { height: number | undefined; @@ -91,6 +94,51 @@ interface ISCMLayout { readonly onDidChange: Event; } +interface ActionButtonTemplate { + readonly actionButton: ScmActionButton; + disposable: IDisposable; + readonly templateDisposable: IDisposable; +} + +class ActionButtonRenderer implements ICompressibleTreeRenderer { + static readonly DEFAULT_HEIGHT = 30; + + static readonly TEMPLATE_ID = 'actionButton'; + get templateId(): string { return ActionButtonRenderer.TEMPLATE_ID; } + + constructor( + @ICommandService private commandService: ICommandService, + @IThemeService private themeService: IThemeService, + @INotificationService private notificationService: INotificationService, + ) { } + + renderTemplate(container: HTMLElement): ActionButtonTemplate { + const buttonContainer = append(container, $('.button-container')); + const actionButton = new ScmActionButton(buttonContainer, this.commandService, this.themeService, this.notificationService); + + return { actionButton, disposable: Disposable.None, templateDisposable: actionButton }; + } + + renderElement(node: ITreeNode, index: number, templateData: ActionButtonTemplate, height: number | undefined): void { + templateData.disposable.dispose(); + + templateData.actionButton.setCommand(node.element.button); + } + + renderCompressedElements(): void { + throw new Error('Should never happen since node is incompressible'); + } + + disposeElement(node: ITreeNode, index: number, template: ActionButtonTemplate): void { + template.disposable.dispose(); + } + + disposeTemplate(templateData: ActionButtonTemplate): void { + templateData.disposable.dispose(); + templateData.templateDisposable.dispose(); + } +} + interface InputTemplate { readonly inputWidget: SCMInputWidget; disposable: IDisposable; @@ -529,6 +577,8 @@ class ListDelegate implements IListVirtualDelegate { getHeight(element: TreeElement) { if (isSCMInput(element)) { return this.inputRenderer.getHeight(element); + } else if (isSCMActionButton(element)) { + return ActionButtonRenderer.DEFAULT_HEIGHT + 10; } else { return 22; } @@ -539,6 +589,8 @@ class ListDelegate implements IListVirtualDelegate { return RepositoryRenderer.TEMPLATE_ID; } else if (isSCMInput(element)) { return InputRenderer.TEMPLATE_ID; + } else if (isSCMActionButton(element)) { + return ActionButtonRenderer.TEMPLATE_ID; } else if (ResourceTree.isResourceNode(element) || isSCMResource(element)) { return ResourceRenderer.TEMPLATE_ID; } else { @@ -582,6 +634,12 @@ export class SCMTreeSorter implements ITreeSorter { return 1; } + if (isSCMActionButton(one)) { + return -1; + } else if (isSCMActionButton(other)) { + return 1; + } + if (isSCMResourceGroup(one)) { if (!isSCMResourceGroup(other)) { throw new Error('Invalid comparison'); @@ -642,9 +700,7 @@ export class SCMTreeKeyboardNavigationLabelProvider implements ICompressibleKeyb getKeyboardNavigationLabel(element: TreeElement): { toString(): string; } | { toString(): string; }[] | undefined { if (ResourceTree.isResourceNode(element)) { return element.name; - } else if (isSCMRepository(element)) { - return undefined; - } else if (isSCMInput(element)) { + } else if (isSCMRepository(element) || isSCMInput(element) || isSCMActionButton(element)) { return undefined; } else if (isSCMResourceGroup(element)) { return element.label; @@ -682,6 +738,9 @@ function getSCMResourceId(element: TreeElement): string { } else if (isSCMInput(element)) { const provider = element.repository.provider; return `input:${provider.id}`; + } else if (isSCMActionButton(element)) { + const provider = element.repository.provider; + return `actionButton:${provider.id}`; } else if (isSCMResource(element)) { const group = element.resourceGroup; const provider = group.provider; @@ -727,6 +786,8 @@ export class SCMAccessibilityProvider implements IListAccessibilityProvider('scm.alwaysShowRepositories'); + this.showActionButtons = this.configurationService.getValue('scm.showActionButtons'); this.refresh(); } } @@ -1176,10 +1239,23 @@ class ViewModel { children.push({ element: item.element.input, incompressible: true, collapsible: false }); } - if (this.items.size === 1 || hasSomeChanges) { + if (hasSomeChanges || (this.items.size === 1 && (!this.showActionButtons || !item.element.provider.actionButton))) { children.push(...item.groupItems.map(i => this.render(i, treeViewState))); } + if (this.showActionButtons && item.element.provider.actionButton) { + const button: ICompressedTreeElement = { + element: { + type: 'actionButton', + repository: item.element, + button: item.element.provider.actionButton, + }, + incompressible: true, + collapsible: false + }; + children.push(button); + } + const collapsed = treeViewState ? treeViewState.collapsed.indexOf(getSCMResourceId(item.element)) > -1 : false; return { element: item.element, children, incompressible: true, collapsed, collapsible: true }; @@ -1969,6 +2045,7 @@ export class SCMViewPane extends ViewPane { const renderers: ICompressibleTreeRenderer[] = [ this.instantiationService.createInstance(RepositoryRenderer, getActionViewItemProvider(this.instantiationService)), this.inputRenderer, + this.instantiationService.createInstance(ActionButtonRenderer), this.instantiationService.createInstance(ResourceGroupRenderer, getActionViewItemProvider(this.instantiationService)), this.instantiationService.createInstance(ResourceRenderer, () => this._viewModel, this.listLabels, getActionViewItemProvider(this.instantiationService), actionRunner) ]; @@ -2118,6 +2195,10 @@ export class SCMViewPane extends ViewPane { } } + return; + } else if (isSCMActionButton(e.element)) { + this.scmViewService.focus(e.element.repository); + return; } @@ -2170,7 +2251,7 @@ export class SCMViewPane extends ViewPane { const menu = menus.repositoryMenu; context = element.provider; [actions, disposable] = collectContextMenuActions(menu); - } else if (isSCMInput(element)) { + } else if (isSCMInput(element) || isSCMActionButton(element)) { // noop } else if (isSCMResourceGroup(element)) { const menus = this.scmViewService.menus.getRepositoryMenus(element.provider); @@ -2310,3 +2391,49 @@ registerThemingParticipant((theme, collector) => { collector.addRule(`.scm-view .scm-provider > .status > .monaco-action-bar > .actions-container { border-color: ${repositoryStatusActionsBorderColor}; }`); } }); + +export class ScmActionButton implements IDisposable { + private button: Button | undefined; + private readonly disposables = new MutableDisposable(); + + constructor( + private readonly container: HTMLElement, + private readonly commandService: ICommandService, + private readonly themeService: IThemeService, + private readonly notificationService: INotificationService + ) { + } + + dispose(): void { + this.disposables?.dispose(); + } + + + setCommand(command: Command | undefined): void { + // Clear old button + this.clear(); + if (!command) { + return; + } + + this.button = new Button(this.container, { title: command.tooltip, supportIcons: true }); + this.button.label = command.title; + this.button.onDidClick(async () => { + try { + await this.commandService.executeCommand(command!.id, ...(command!.arguments || [])); + } catch (ex) { + this.notificationService.error(ex); + } + }, null, this.disposables.value); + + this.disposables.value!.add(this.button); + this.disposables.value!.add(attachButtonStyler(this.button, this.themeService)); + + } + + private clear(): void { + this.disposables.value = new DisposableStore(); + this.button = undefined; + clearNode(this.container); + } +} diff --git a/src/vs/workbench/contrib/scm/browser/util.ts b/src/vs/workbench/contrib/scm/browser/util.ts index 250b8cfce6b..842a0b6fff0 100644 --- a/src/vs/workbench/contrib/scm/browser/util.ts +++ b/src/vs/workbench/contrib/scm/browser/util.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ISCMResource, ISCMRepository, ISCMResourceGroup, ISCMInput } from 'vs/workbench/contrib/scm/common/scm'; +import { ISCMResource, ISCMRepository, ISCMResourceGroup, ISCMInput, ISCMActionButton } from 'vs/workbench/contrib/scm/common/scm'; import { IMenu } from 'vs/platform/actions/common/actions'; import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; import { IDisposable, Disposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; @@ -25,6 +25,10 @@ export function isSCMInput(element: any): element is ISCMInput { return !!(element as ISCMInput).validateInput && typeof (element as ISCMInput).value === 'string'; } +export function isSCMActionButton(element: any): element is ISCMActionButton { + return (element as ISCMActionButton).type === 'actionButton'; +} + export function isSCMResourceGroup(element: any): element is ISCMResourceGroup { return !!(element as ISCMResourceGroup).provider && !!(element as ISCMResourceGroup).elements; } diff --git a/src/vs/workbench/contrib/scm/common/scm.ts b/src/vs/workbench/contrib/scm/common/scm.ts index ff7b24c4d68..b68d7b9f4a2 100644 --- a/src/vs/workbench/contrib/scm/common/scm.ts +++ b/src/vs/workbench/contrib/scm/common/scm.ts @@ -65,6 +65,7 @@ export interface ISCMProvider extends IDisposable { readonly onDidChangeCommitTemplate: Event; readonly onDidChangeStatusBarCommands?: Event; readonly acceptInputCommand?: Command; + readonly actionButton?: Command; readonly statusBarCommands?: Command[]; readonly onDidChange: Event; @@ -96,6 +97,12 @@ export interface ISCMInputChangeEvent { readonly reason?: SCMInputChangeReason; } +export interface ISCMActionButton { + readonly type: 'actionButton'; + readonly repository: ISCMRepository; + readonly button?: Command; +} + export interface ISCMInput { readonly repository: ISCMRepository;