Skip to content

Commit

Permalink
SCM - implement scm/inputBox menu (#199147)
Browse files Browse the repository at this point in the history
* SCM - fix regression related to the scm input box action button

* Single action working as expected

* Saving my work

* Fix enablement when there is only one action

* More polish when there are multiple actions

* WIP - Select default action

* Add proposal

* Another refactoring

* Update setting type

* Remove setting, store last executed command

* Revert code that was used for testing

* Fix compilation errors

* Remove test commands
  • Loading branch information
lszomoru authored Nov 27, 2023
1 parent c05fde5 commit abd2f00
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 102 deletions.
3 changes: 2 additions & 1 deletion extensions/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"timeline",
"contribMergeEditorMenus",
"scmInputBoxActionButton",
"scmInputBoxValueProvider"
"scmInputBoxValueProvider",
"contribSourceControlInputBoxMenu"
],
"categories": [
"Other"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as DOM from 'vs/base/browser/dom';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { ActionViewItem, BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem';
import { IAction } from 'vs/base/common/actions';
import { IAction, IActionRunner } from 'vs/base/common/actions';
import { Event } from 'vs/base/common/event';
import { KeyCode } from 'vs/base/common/keyCodes';
import { ResolvedKeybinding } from 'vs/base/common/keybindings';
Expand All @@ -21,6 +21,7 @@ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';

export interface IDropdownWithPrimaryActionViewItemOptions {
actionRunner?: IActionRunner;
getKeyBinding?: (action: IAction) => ResolvedKeybinding | undefined;
}

Expand Down Expand Up @@ -49,9 +50,14 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem {
) {
super(null, primaryAction);
this._primaryAction = new MenuEntryActionViewItem(primaryAction, undefined, _keybindingService, _notificationService, _contextKeyService, _themeService, _contextMenuProvider, _accessibilityService);
if (_options?.actionRunner) {
this._primaryAction.actionRunner = _options.actionRunner;
}

this._dropdown = new DropdownMenuActionViewItem(dropdownAction, dropdownMenuActions, this._contextMenuProvider, {
menuAsChild: true,
classNames: className ? ['codicon', 'codicon-chevron-down', className] : ['codicon', 'codicon-chevron-down'],
actionRunner: this._options?.actionRunner,
keybindingProvider: this._options?.getKeyBinding
});
}
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/actions/common/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class MenuId {
static readonly OpenEditorsContext = new MenuId('OpenEditorsContext');
static readonly OpenEditorsContextShare = new MenuId('OpenEditorsContextShare');
static readonly ProblemsPanelContext = new MenuId('ProblemsPanelContext');
static readonly SCMInputBox = new MenuId('SCMInputBox');
static readonly SCMHistoryItem = new MenuId('SCMHistoryItem');
static readonly SCMChangeContext = new MenuId('SCMChangeContext');
static readonly SCMResourceContext = new MenuId('SCMResourceContext');
Expand Down
15 changes: 15 additions & 0 deletions src/vs/workbench/contrib/scm/browser/media/scm.css
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@
display: none;
}

.scm-view .scm-input .scm-editor .scm-editor-toolbar.disabled .action-item {
cursor: default;
opacity: 0.6;
}

.scm-view .scm-input .scm-editor .scm-editor-toolbar.disabled .action-label:hover {
outline: unset;
outline-offset: unset;
background-color: unset;
}

.scm-view .scm-input .scm-editor .scm-editor-toolbar.disabled .action-item.monaco-dropdown-with-primary:hover {
background-color: unset;
}

.scm-view .scm-input .scm-editor .scm-editor-toolbar.scroll-decoration {
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset;
}
Expand Down
219 changes: 119 additions & 100 deletions src/vs/workbench/contrib/scm/browser/scmViewPane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ViewPane, IViewPaneOptions, ViewAction } from 'vs/workbench/browser/par
import { append, $, Dimension, asCSSUrl, trackFocus, clearNode, prepend } from 'vs/base/browser/dom';
import { IListVirtualDelegate, IIdentityProvider } from 'vs/base/browser/ui/list/list';
import { ISCMHistoryItem, ISCMHistoryItemChange, ISCMHistoryProviderCacheEntry, SCMHistoryItemChangeTreeElement, SCMHistoryItemGroupTreeElement, SCMHistoryItemTreeElement, SCMViewSeparatorElement } from 'vs/workbench/contrib/scm/common/history';
import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, REPOSITORIES_VIEW_PANE_ID, ISCMInputValueProviderContext, ISCMInputValueProvider } from 'vs/workbench/contrib/scm/common/scm';
import { ISCMResourceGroup, ISCMResource, InputValidationType, ISCMRepository, ISCMInput, IInputValidation, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ISCMService, SCMInputChangeReason, VIEW_PANE_ID, ISCMActionButton, ISCMActionButtonDescriptor, ISCMRepositorySortKey, REPOSITORIES_VIEW_PANE_ID, ISCMInputValueProviderContext } from 'vs/workbench/contrib/scm/common/scm';
import { ResourceLabels, IResourceLabel, IFileLabelOptions } from 'vs/workbench/browser/labels';
import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
Expand Down Expand Up @@ -75,7 +75,7 @@ import { ColorScheme } from 'vs/platform/theme/common/theme';
import { LabelFuzzyScore } from 'vs/base/browser/ui/tree/abstractTree';
import { Selection } from 'vs/editor/common/core/selection';
import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands';
import { createActionViewItem, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { createActionViewItem, createAndFillInActionBarActions, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { MarkdownRenderer, openLinkFromMarkdown } from 'vs/editor/contrib/markdownRenderer/browser/markdownRenderer';
import { Button, ButtonWithDescription, ButtonWithDropdown } from 'vs/base/browser/ui/button/button';
import { INotificationService } from 'vs/platform/notification/common/notification';
Expand All @@ -100,10 +100,9 @@ import { EditOperation } from 'vs/editor/common/core/editOperation';
import { stripIcons } from 'vs/base/common/iconLabels';
import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel';
import { foreground, listActiveSelectionForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
import { IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem';

// type SCMResourceTreeNode = IResourceNode<ISCMResource, ISCMResourceGroup>;
// type SCMHistoryItemChangeResourceTreeNode = IResourceNode<SCMHistoryItemChangeTreeElement, SCMHistoryItemTreeElement>;
Expand Down Expand Up @@ -1775,123 +1774,142 @@ class HistoryItemViewChangesAction extends Action2 {

registerAction2(HistoryItemViewChangesAction);

export const enum SCMInputCommandId {
ProvideValue = 'scm.input.provideValue'
const enum SCMInputCommandId {
CancelAction = 'scm.input.cancelAction',
SelectDefaultAction = 'scm.input.selectDefaultAction'
}

const SCMInputContextKeys = {
ActionIsEnabled: new RawContextKey<boolean>('scmInputActionIsEnabled', false),
ActionIsRunning: new RawContextKey<boolean>('scmInputActionIsRunning', false),
};

class SCMInputWidgetActionRunner extends ActionRunner {

private _runningActions = new Set<IAction>();
private _ctxIsActionRunning: IContextKey<boolean>;
private _cts: CancellationTokenSource | undefined;

private _isActionRunning: boolean = false;
get isActionRunning(): boolean { return this._isActionRunning; }

constructor(
private readonly input: ISCMInput,
@IProgressService private readonly progressService: IProgressService
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IStorageService private readonly storageService: IStorageService
) {
super();
}

override async run(action: IAction, context?: unknown): Promise<void> {
if (!action.enabled) {
return;
}

// Cancel previous action
if (this._isActionRunning) {
this._cts?.cancel();
this._ctxIsActionRunning = SCMInputContextKeys.ActionIsRunning.bindTo(contextKeyService);
}

// Cancel button was clicked
if (action instanceof SCMInputWidgetButtonAction) {
protected override async runAction(action: IAction): Promise<void> {
try {
// Check if toolbar is disabled
if (this.contextKeyService.getContextKeyValue(SCMInputContextKeys.ActionIsEnabled.key) === false) {
return;
}
}

this._isActionRunning = true;

super.run(action, context);
}
// Cancel previous action
if (this._ctxIsActionRunning.get() === true) {
this._cts?.cancel();

protected override async runAction(action: IAction): Promise<void> {
try {
await this.progressService.withProgress({ location: ProgressLocation.Scm }, async () => {
const context: ISCMInputValueProviderContext[] = [];
for (const group of this.input.repository.provider.groups) {
context.push({
resourceGroupId: group.id,
resources: [...group.resources.map(r => r.sourceUri)]
});
if (action.id === SCMInputCommandId.CancelAction) {
return;
}
}

this._cts = new CancellationTokenSource();
await action.run(context, this._cts.token);
});
this._runningActions.add(action);
if (action.id !== SCMInputCommandId.SelectDefaultAction) {
this._ctxIsActionRunning.set(true);
}

const context: ISCMInputValueProviderContext[] = [];
for (const group of this.input.repository.provider.groups) {
context.push({
resourceGroupId: group.id,
resources: [...group.resources.map(r => r.sourceUri)]
});
}

this._cts = new CancellationTokenSource();
await action.run(...[this.input.repository.provider.rootUri, context, this._cts.token]);
this.storageService.store('scm.input.lastActionId', action.id, StorageScope.PROFILE, StorageTarget.USER);
} finally {
this._isActionRunning = false;
this._runningActions.delete(action);

if (this._runningActions.size === 0) {
this._ctxIsActionRunning.set(false);
}
}
}

}

class SCMInputWidgetButtonAction extends Action {
class SCMInputWidgetToolbar extends WorkbenchToolBar {

private _dropdownActions: IAction[] = [];
get dropdownActions(): IAction[] { return this._dropdownActions; }

constructor(
private readonly input: ISCMInput,
private readonly provider: ISCMInputValueProvider,
readonly repository: ISCMRepository
container: HTMLElement,
menuId: MenuId,
options: IMenuWorkbenchToolBarOptions | undefined,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService,
@IContextMenuService contextMenuService: IContextMenuService,
@ICommandService commandService: ICommandService,
@IKeybindingService keybindingService: IKeybindingService,
@IStorageService storageService: IStorageService,
@ITelemetryService telemetryService: ITelemetryService,
) {
super(
SCMInputCommandId.ProvideValue,
provider.label,
ThemeIcon.isThemeIcon(provider.icon) ? ThemeIcon.asClassName(provider.icon) : ThemeIcon.asClassName(Codicon.sparkle),
repository.provider.groups.some(g => g.resources.length > 0));
super(container, { resetMenu: menuId, ...options }, menuService, contextKeyService, contextMenuService, keybindingService, telemetryService);

this._register(repository.provider.onDidChangeResources(() => this.enabled = repository.provider.groups.some(g => g.resources.length > 0)));
}
const menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true }));

override async run(context: ISCMInputValueProviderContext[], token: CancellationToken): Promise<void> {
const value = await this.provider.provideValue(this.input.repository.provider.rootUri!, context, token);
const cancelAction = new MenuItemAction({
id: SCMInputCommandId.CancelAction,
title: localize('scmInputCancelAction', "Cancel"),
icon: Codicon.debugStop,
}, undefined, undefined, undefined, contextKeyService, commandService);

if (value) {
this.input.setValue(value, false);
}
}
const updateToolbar = () => {
this._dropdownActions = [];

}
const actions: IAction[] = [];
createAndFillInActionBarActions(menu, options?.menuOptions, actions);

class SCMInputWidgetButtonActionViewItem extends ActionViewItem {
let primaryAction: IAction | undefined = undefined;

constructor(action: IAction, actionRunner: SCMInputWidgetActionRunner) {
super(undefined, action, { icon: true, label: false });
if (contextKeyService.getContextKeyValue(SCMInputContextKeys.ActionIsRunning.key) === true) {
primaryAction = cancelAction;
} else if (actions.length === 1) {
primaryAction = actions[0];
} else if (actions.length > 1) {
const lastActionId = storageService.get('scm.input.lastActionId', StorageScope.PROFILE, '');
primaryAction = actions.find(a => a.id === lastActionId) ?? actions[0];
}

this.actionRunner = actionRunner;
if (actions.length > 1) {
for (const action of actions) {
this._dropdownActions.push(action);
}
}

this._register(Event.any(actionRunner.onWillRun, actionRunner.onDidRun)(() => {
this.updateTooltip();
this.updateClass();
}));
}
container.classList.toggle('has-no-actions', actions.length === 0);
container.classList.toggle('disabled', contextKeyService.getContextKeyValue(SCMInputContextKeys.ActionIsEnabled.key) !== true);

protected override getClass(): string | undefined {
if (this.actionRunner instanceof SCMInputWidgetActionRunner) {
return this.actionRunner.isActionRunning ? ThemeIcon.asClassName(Codicon.debugStop) : super.getClass();
}
super.setActions(primaryAction ? [primaryAction] : [], []);
};

return super.getClass();
}
this._store.add(menu.onDidChange(() => updateToolbar()));

protected override getTooltip(): string | undefined {
if (this.actionRunner instanceof SCMInputWidgetActionRunner) {
return this.actionRunner.isActionRunning ? localize('cancel', "Cancel") : super.getTooltip();
}
const ctxKeys = new Set<string>([SCMInputContextKeys.ActionIsEnabled.key, SCMInputContextKeys.ActionIsRunning.key]);
Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(ctxKeys))(() => updateToolbar());

return super.getTooltip();
// Delay initial update to finish class initialization
setTimeout(() => updateToolbar(), 0);
}

}


class SCMInputWidget {

private static readonly ValidationTimeouts: { [severity: number]: number } = {
Expand Down Expand Up @@ -2103,34 +2121,34 @@ class SCMInputWidget {
}

private createToolbar(input: ISCMInput): void {
const actionRunner = this.instantiationService.createInstance(SCMInputWidgetActionRunner, input);
const contextKeyService2 = this.contextKeyService.createScoped(this.toolbarContainer);

const services = new ServiceCollection([IContextKeyService, contextKeyService2]);
const instantiationService2 = this.instantiationService.createChild(services);

const ctxIsActionEnabled = SCMInputContextKeys.ActionIsEnabled.bindTo(contextKeyService2);
this.repositoryDisposables.add(input.repository.provider.onDidChangeResources(() => ctxIsActionEnabled.set(input.repository.provider.groups.some(r => r.resources.length > 0))));

const actionRunner = instantiationService2.createInstance(SCMInputWidgetActionRunner, input);
this.repositoryDisposables.add(actionRunner);

const toolbar = this.instantiationService.createInstance(WorkbenchToolBar, this.toolbarContainer, {
const toolbar: SCMInputWidgetToolbar = instantiationService2.createInstance(SCMInputWidgetToolbar, this.toolbarContainer, MenuId.SCMInputBox, {
actionRunner,
actionViewItemProvider: action => {
// Button (single provider)
if (action instanceof SCMInputWidgetButtonAction) {
return this.instantiationService.createInstance(SCMInputWidgetButtonActionViewItem, action, actionRunner);
if (action instanceof MenuItemAction && toolbar.dropdownActions.length > 1) {
const scmInputActionIsEnabled = contextKeyService2.getContextKeyValue<boolean>('scmInputActionIsEnabled') === true;
const dropdownAction = new Action('scmInputMoreActions', localize('scmInputMoreActions', 'More Actions...'), 'codicon-chevron-down', scmInputActionIsEnabled);

return instantiationService2.createInstance(DropdownWithPrimaryActionViewItem, action, dropdownAction, toolbar.dropdownActions, '', this.contextMenuService, { actionRunner });
}

return createActionViewItem(this.instantiationService, action);
return createActionViewItem(instantiationService2, action);
},

menuOptions: {
shouldForwardArgs: true
}
});
this.repositoryDisposables.add(toolbar);

const updateToolbar = () => {
const showInputActionButton = this.configurationService.getValue<boolean>('scm.showInputActionButton') === true;
const defaultProvider = this.scmService.getDefaultInputValueProvider(input.repository);
toolbar.setActions(showInputActionButton && defaultProvider ? [this.instantiationService.createInstance(SCMInputWidgetButtonAction, input, defaultProvider, input.repository)] : []);

toolbar.getElement().classList.toggle('hidden', !showInputActionButton || defaultProvider === undefined);
this.layout();
};
this.repositoryDisposables.add(this.scmService.onDidChangeInputValueProviders(updateToolbar, this));
this.repositoryDisposables.add(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.showInputActionButton'))(updateToolbar, this));
updateToolbar();
}

private setValidation(validation: IInputValidation | undefined, options?: { focus?: boolean; timeout?: boolean }) {
Expand All @@ -2154,7 +2172,7 @@ class SCMInputWidget {
constructor(
container: HTMLElement,
overflowWidgetsDomNode: HTMLElement,
@IContextKeyService contextKeyService: IContextKeyService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IModelService private modelService: IModelService,
@ITextModelService private textModelService: ITextModelService,
@IKeybindingService private keybindingService: IKeybindingService,
Expand All @@ -2164,6 +2182,7 @@ class SCMInputWidget {
@ISCMViewService private readonly scmViewService: ISCMViewService,
@IContextViewService private readonly contextViewService: IContextViewService,
@IOpenerService private readonly openerService: IOpenerService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@ICommandService private readonly commandService: ICommandService
) {
this.element = append(container, $('.scm-editor'));
Expand Down
Loading

0 comments on commit abd2f00

Please sign in to comment.