From 0574f8e7265fb0ad066ce74ec49b0ce5b478038b Mon Sep 17 00:00:00 2001 From: Nigel Westbury Date: Wed, 1 Apr 2020 11:09:12 +0100 Subject: [PATCH] Show file changes as a tree in Source Control view Signed-off-by: Nigel Westbury --- packages/git/src/browser/git-contribution.ts | 9 +- packages/git/src/browser/git-scm-provider.ts | 11 +- .../menus/menus-contribution-handler.ts | 5 +- packages/scm/src/browser/scm-amend-widget.tsx | 87 ++ .../scm/src/browser/scm-commit-widget.tsx | 170 ++++ packages/scm/src/browser/scm-contribution.ts | 66 +- .../scm/src/browser/scm-frontend-module.ts | 49 +- packages/scm/src/browser/scm-provider.ts | 1 + packages/scm/src/browser/scm-repository.ts | 54 +- packages/scm/src/browser/scm-service.ts | 12 - .../src/browser/scm-tree-label-provider.ts | 37 + packages/scm/src/browser/scm-tree-widget.tsx | 803 ++++++++++++++++++ packages/scm/src/browser/scm-widget.tsx | 652 ++------------ packages/scm/src/browser/style/index.css | 12 +- 14 files changed, 1300 insertions(+), 668 deletions(-) create mode 100644 packages/scm/src/browser/scm-amend-widget.tsx create mode 100644 packages/scm/src/browser/scm-commit-widget.tsx create mode 100644 packages/scm/src/browser/scm-tree-label-provider.ts create mode 100644 packages/scm/src/browser/scm-tree-widget.tsx diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index 62b986e08653e..dbd8504ca3ee6 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -27,6 +27,7 @@ import { WorkspaceService } from '@theia/workspace/lib/browser'; import { GitRepositoryProvider } from './git-repository-provider'; import { GitErrorHandler } from '../browser/git-error-handler'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; +import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; import { ScmResource, ScmCommand } from '@theia/scm/lib/browser/scm-provider'; import { ProgressService } from '@theia/core/lib/common/progress-service'; import { GitPreferences } from './git-preferences'; @@ -225,8 +226,8 @@ export class GitContribution implements CommandContribution, MenuContribution, T }); const registerResourceAction = (group: string, action: MenuAction) => { - menus.registerMenuAction(ScmWidget.RESOURCE_INLINE_MENU, action); - menus.registerMenuAction([...ScmWidget.RESOURCE_CONTEXT_MENU, group], action); + menus.registerMenuAction(ScmTreeWidget.RESOURCE_INLINE_MENU, action); + menus.registerMenuAction([...ScmTreeWidget.RESOURCE_CONTEXT_MENU, group], action); }; registerResourceAction('navigation', { @@ -265,8 +266,8 @@ export class GitContribution implements CommandContribution, MenuContribution, T }); const registerResourceGroupAction = (group: string, action: MenuAction) => { - menus.registerMenuAction(ScmWidget.RESOURCE_GROUP_INLINE_MENU, action); - menus.registerMenuAction([...ScmWidget.RESOURCE_GROUP_CONTEXT_MENU, group], action); + menus.registerMenuAction(ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU, action); + menus.registerMenuAction([...ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU, group], action); }; registerResourceGroupAction('1_modification', { diff --git a/packages/git/src/browser/git-scm-provider.ts b/packages/git/src/browser/git-scm-provider.ts index b0a931ad087cf..4d3b1e01435cf 100644 --- a/packages/git/src/browser/git-scm-provider.ts +++ b/packages/git/src/browser/git-scm-provider.ts @@ -258,7 +258,10 @@ export class GitScmProvider implements ScmProvider { async stage(uri: string): Promise { try { const { repository, unstagedChanges, mergeChanges } = this; - const hasUnstagedChanges = unstagedChanges.some(change => change.uri === uri) || mergeChanges.some(change => change.uri === uri); + const resourceUri = new URI(uri); + const hasUnstagedChanges = + unstagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri))) + || mergeChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri))); if (hasUnstagedChanges) { // TODO resolve deletion conflicts // TODO confirm staging of a unresolved file @@ -281,7 +284,8 @@ export class GitScmProvider implements ScmProvider { async unstage(uri: string): Promise { try { const { repository, stagedChanges } = this; - if (stagedChanges.some(change => change.uri === uri)) { + const resourceUri = new URI(uri); + if (stagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)))) { await this.git.unstage(repository, uri); } } catch (error) { @@ -306,7 +310,8 @@ export class GitScmProvider implements ScmProvider { async discard(uri: string): Promise { const { repository } = this; const status = this.getStatus(); - if (!(status && status.changes.some(change => change.uri === uri))) { + const resourceUri = new URI(uri); + if (!(status && status.changes.some(change => resourceUri.isEqualOrParent(new URI(change.uri))))) { return; } // Allow deletion, only iff the same file is not yet in the Git index. diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index 38b13a8cbe91a..f834de78957dc 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -31,6 +31,7 @@ import { DebugStackFramesWidget } from '@theia/debug/lib/browser/view/debug-stac import { DebugThreadsWidget } from '@theia/debug/lib/browser/view/debug-threads-widget'; import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; import { ScmWidget } from '@theia/scm/lib/browser/scm-widget'; +import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget'; import { ScmService } from '@theia/scm/lib/browser/scm-service'; import { ScmRepository } from '@theia/scm/lib/browser/scm-repository'; import { PluginScmProvider, PluginScmResourceGroup, PluginScmResource } from '../scm-main'; @@ -131,13 +132,13 @@ export class MenusContributionPointHandler { } else if (location === 'scm/resourceGroup/context') { for (const menu of allMenus[location]) { const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? ScmWidget.RESOURCE_GROUP_INLINE_MENU : ScmWidget.RESOURCE_GROUP_CONTEXT_MENU; + const menuPath = inline ? ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU : ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU; toDispose.push(this.registerScmMenuAction(menuPath, menu)); } } else if (location === 'scm/resourceState/context') { for (const menu of allMenus[location]) { const inline = menu.group && /^inline/.test(menu.group) || false; - const menuPath = inline ? ScmWidget.RESOURCE_INLINE_MENU : ScmWidget.RESOURCE_CONTEXT_MENU; + const menuPath = inline ? ScmTreeWidget.RESOURCE_INLINE_MENU : ScmTreeWidget.RESOURCE_CONTEXT_MENU; toDispose.push(this.registerScmMenuAction(menuPath, menu)); } } else if (location === 'debug/callstack/context') { diff --git a/packages/scm/src/browser/scm-amend-widget.tsx b/packages/scm/src/browser/scm-amend-widget.tsx new file mode 100644 index 0000000000000..db26e694aaf3b --- /dev/null +++ b/packages/scm/src/browser/scm-amend-widget.tsx @@ -0,0 +1,87 @@ +/******************************************************************************** + * Copyright (C) 2020 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject } from 'inversify'; +import { Message } from '@phosphor/messaging'; +import { SelectionService } from '@theia/core/lib/common'; +import * as React from 'react'; +import { + ContextMenuRenderer, ReactWidget, LabelProvider, KeybindingRegistry, StorageService +} from '@theia/core/lib/browser'; +import { ScmService } from './scm-service'; +import { ScmAvatarService } from './scm-avatar-service'; +import { ScmAmendComponent } from './scm-amend-component'; + +@injectable() +export class ScmAmendWidget extends ReactWidget { + + static ID = 'scm-amend-widget'; + + @inject(ScmService) protected readonly scmService: ScmService; + @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService; + @inject(StorageService) protected readonly storageService: StorageService; + @inject(SelectionService) protected readonly selectionService: SelectionService; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; + + protected shouldScrollToRow = true; + + constructor( + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer, + ) { + super(); + this.scrollOptions = { + suppressScrollX: true, + minScrollbarLength: 35 + }; + this.addClass('theia-scm-commit-container'); + this.id = ScmAmendWidget.ID; + } + + protected onUpdateRequest(msg: Message): void { + if (!this.isAttached || !this.isVisible) { + return; + } + super.onUpdateRequest(msg); + } + + protected render(): React.ReactNode { + const repository = this.scmService.selectedRepository; + if (repository && repository.provider.amendSupport) { + return React.createElement( + ScmAmendComponent, + { + key: `amend:${repository.provider.rootUri}`, + style: { flexGrow: 0 }, + id: 'amend', // ??? this was hack {this.scrollContainer} + repository: repository, + scmAmendSupport: repository.provider.amendSupport, + setCommitMessage: this.setInputValue, + avatarService: this.avatarService, + storageService: this.storageService, + } + ); + } + } + + protected setInputValue = (event: React.FormEvent | React.ChangeEvent | string) => { + const repository = this.scmService.selectedRepository; + if (repository) { + repository.input.value = typeof event === 'string' ? event : event.currentTarget.value; + } + }; + +} diff --git a/packages/scm/src/browser/scm-commit-widget.tsx b/packages/scm/src/browser/scm-commit-widget.tsx new file mode 100644 index 0000000000000..43134776afd79 --- /dev/null +++ b/packages/scm/src/browser/scm-commit-widget.tsx @@ -0,0 +1,170 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject, postConstruct } from 'inversify'; +import { Message } from '@phosphor/messaging'; +import { SelectionService } from '@theia/core/lib/common'; +import * as React from 'react'; +import TextareaAutosize from 'react-autosize-textarea'; +import { ScmInput } from './scm-input'; +import { + ContextMenuRenderer, ReactWidget, LabelProvider, KeybindingRegistry, StatefulWidget} from '@theia/core/lib/browser'; +import { ScmService } from './scm-service'; + +@injectable() +export class ScmCommitWidget extends ReactWidget implements StatefulWidget { + + static ID = 'scm-commit-widget'; + + @inject(ScmService) protected readonly scmService: ScmService; + @inject(SelectionService) protected readonly selectionService: SelectionService; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; + + protected shouldScrollToRow = true; + + /** don't modify DOM use React! only exposed for `focusInput` */ + protected readonly inputRef = React.createRef(); + + constructor( + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer, + ) { + super(); + this.scrollOptions = { + suppressScrollX: true, + minScrollbarLength: 35 + }; + this.addClass('theia-ScmCommit'); + this.id = ScmCommitWidget.ID; + } + + @postConstruct() + protected init(): void { + + } + + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.focus(); + } + + public focus(): boolean { + (this.inputRef.current || this.node).focus(); + return true; // returns false to set parent node focus - tidy this up + } + + protected onUpdateRequest(msg: Message): void { + if (!this.isAttached || !this.isVisible) { + return; + } + super.onUpdateRequest(msg); + } + + protected render(): React.ReactNode { + const repository = this.scmService.selectedRepository; + if (repository) { + return React.createElement('div', this.createContainerAttributes(), this.renderInput(repository.input)); + } + } + + /** + * Create the container attributes for the widget. + */ + protected createContainerAttributes(): React.HTMLAttributes { + return { + style: { flexGrow: 0 } + }; + } + + protected renderInput(input: ScmInput): React.ReactNode { + const validationStatus = input.issue ? input.issue.type : 'idle'; + const validationMessage = input.issue ? input.issue.message : ''; + const format = (value: string, ...args: string[]): string => { + if (args.length !== 0) { + return value.replace(/{(\d+)}/g, (found, n) => { + const i = parseInt(n); + return isNaN(i) || i < 0 || i >= args.length ? found : args[i]; + }); + } + return value; + }; + + const keybinding = this.keybindings.acceleratorFor(this.keybindings.getKeybindingsForCommand('scm.acceptInput')[0]).join('+'); + const message = format(input.placeholder || '', keybinding); + return
+ + +
{validationMessage}
+
; + } + + protected setInputValue = (event: React.FormEvent | React.ChangeEvent | string) => { + const repository = this.scmService.selectedRepository; + if (repository) { + repository.input.value = typeof event === 'string' ? event : event.currentTarget.value; + } + }; + + /** + * Store the tree state. + */ + storeState(): object { + const message = this.inputRef.current ? this.inputRef.current.value : ''; + const state: object = { + message + }; + return state; + } + + /** + * Restore the state. + * @param oldState the old state object. + */ + restoreState(oldState: object): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { message } = (oldState as any); + if (message && this.inputRef.current) { + this.inputRef.current.value = message; + } + } + +} + +export namespace ScmCommitWidget { + + export namespace Styles { + export const INPUT_MESSAGE_CONTAINER = 'theia-scm-input-message-container'; + export const INPUT_MESSAGE = 'theia-scm-input-message'; + export const VALIDATION_MESSAGE = 'theia-scm-input-validation-message'; + export const NO_SELECT = 'no-select'; + } +} diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index 19563e10d99e2..fc69012a5bb49 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { inject, injectable, postConstruct } from 'inversify'; +import { Emitter } from '@theia/core/lib/common/event'; +import { find } from '@phosphor/algorithm'; import { AbstractViewContribution, FrontendApplicationContribution, LabelProvider, @@ -22,9 +24,10 @@ import { StatusBarAlignment, StatusBarEntry, KeybindingRegistry, - ViewContainerTitleOptions -} from '@theia/core/lib/browser'; -import { CommandRegistry, Disposable, DisposableCollection, CommandService } from '@theia/core/lib/common'; + ViewContainerTitleOptions, + ViewContainer} from '@theia/core/lib/browser'; +import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { CommandRegistry, Command, Disposable, DisposableCollection, CommandService } from '@theia/core/lib/common'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; import { ScmService } from './scm-service'; import { ScmWidget } from '../browser/scm-widget'; @@ -51,10 +54,22 @@ export namespace SCM_COMMANDS { export const ACCEPT_INPUT = { id: 'scm.acceptInput' }; + export const TREE_VIEW_MODE = { + id: 'scm.viewmode.tree', + tooltip: 'Toggle to Tree View', + iconClass: 'codicon codicon-list-tree', + label: 'Toggle to Tree View', + }; + export const FLAT_VIEW_MODE = { + id: 'scm.viewmode.flat', + tooltip: 'Toggle to Flat View', + iconClass: 'codicon codicon-list-flat', + label: 'Toggle to Flat View', + }; } @injectable() -export class ScmContribution extends AbstractViewContribution implements FrontendApplicationContribution, ColorContribution { +export class ScmContribution extends AbstractViewContribution implements FrontendApplicationContribution, TabBarToolbarContribution, ColorContribution { @inject(StatusBar) protected readonly statusBar: StatusBar; @inject(ScmService) protected readonly scmService: ScmService; @@ -62,6 +77,7 @@ export class ScmContribution extends AbstractViewContribution impleme @inject(ScmQuickOpenService) protected readonly scmQuickOpenService: ScmQuickOpenService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(CommandService) protected readonly commands: CommandService; + @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; protected scmFocus: ContextKey; @@ -117,6 +133,48 @@ export class ScmContribution extends AbstractViewContribution impleme }); } + registerToolbarItems(registry: TabBarToolbarRegistry): void { + const viewModeEmitter = new Emitter(); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const extractScmWidget = (widget: any) => { + if (widget instanceof ViewContainer) { + const layout = widget.containerLayout; + const scmWidgetPart = find(layout.iter(), part => part.wrapped instanceof ScmWidget); + if (scmWidgetPart && scmWidgetPart.wrapped instanceof ScmWidget) { + return scmWidgetPart.wrapped; + } + } + }; + const registerToggleViewItem = (command: Command, mode: 'tree' | 'flat') => { + const id = command.id; + const item: TabBarToolbarItem = { + id, + command: id, + tooltip: command.label, + onDidChange: viewModeEmitter.event + }; + this.commandRegistry.registerCommand({ id, iconClass: command && command.iconClass }, { + execute: widget => { + const scmWidget = extractScmWidget(widget); + if (scmWidget) { + scmWidget.viewMode = mode; + viewModeEmitter.fire(); + } + }, + isVisible: widget => { + const scmWidget = extractScmWidget(widget); + if (scmWidget) { + return scmWidget.viewMode !== mode; + } + return false; + }, + }); + registry.registerItem(item); + }; + registerToggleViewItem(SCM_COMMANDS.TREE_VIEW_MODE, 'tree'); + registerToggleViewItem(SCM_COMMANDS.FLAT_VIEW_MODE, 'flat'); + } + registerKeybindings(keybindings: KeybindingRegistry): void { super.registerKeybindings(keybindings); keybindings.registerKeybinding({ diff --git a/packages/scm/src/browser/scm-frontend-module.ts b/packages/scm/src/browser/scm-frontend-module.ts index 2c7213a2fe1a1..c24417441e234 100644 --- a/packages/scm/src/browser/scm-frontend-module.ts +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -17,15 +17,19 @@ import '../../src/browser/style/index.css'; import '../../src/browser/style/diff.css'; -import { ContainerModule } from 'inversify'; +import { interfaces, ContainerModule } from 'inversify'; import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, ViewContainer, - WidgetManager, ApplicationShellLayoutMigration + WidgetManager, ApplicationShellLayoutMigration, + createTreeContainer, TreeWidget, TreeProps } from '@theia/core/lib/browser'; import { ScmService } from './scm-service'; import { SCM_WIDGET_FACTORY_ID, ScmContribution, SCM_VIEW_CONTAINER_ID, SCM_VIEW_CONTAINER_TITLE_OPTIONS } from './scm-contribution'; import { ScmWidget } from './scm-widget'; +import { ScmTreeWidget } from './scm-tree-widget'; +import { ScmCommitWidget } from './scm-commit-widget'; +import { ScmAmendWidget } from './scm-amend-widget'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { bindDirtyDiff } from './dirty-diff/dirty-diff-module'; import { NavigatorTreeDecorator } from '@theia/navigator/lib/browser'; @@ -34,7 +38,10 @@ import { ScmDecorationsService } from './decorations/scm-decorations-service'; import { ScmAvatarService } from './scm-avatar-service'; import { ScmContextKeyService } from './scm-context-key-service'; import { ScmLayoutVersion3Migration } from './scm-layout-migrations'; +import { ScmTreeLabelProvider } from './scm-tree-label-provider'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; +import { LabelProviderContribution } from '@theia/core/lib/browser/label-provider'; export default new ContainerModule(bind => { bind(ScmContextKeyService).toSelf().inSingletonScope(); @@ -45,6 +52,25 @@ export default new ContainerModule(bind => { id: SCM_WIDGET_FACTORY_ID, createWidget: () => container.get(ScmWidget) })).inSingletonScope(); + + bind(ScmCommitWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ScmCommitWidget.ID, + createWidget: () => container.get(ScmCommitWidget) + })).inSingletonScope(); + + bind(ScmTreeWidget).toDynamicValue(ctx => createFileChangeTreeWidget(ctx.container)); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ScmTreeWidget.ID, + createWidget: () => container.get(ScmTreeWidget) + })).inSingletonScope(); + + bind(ScmAmendWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ScmAmendWidget.ID, + createWidget: () => container.get(ScmAmendWidget) + })).inSingletonScope(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: SCM_VIEW_CONTAINER_ID, createWidget: async () => { @@ -66,6 +92,7 @@ export default new ContainerModule(bind => { bind(ScmQuickOpenService).toSelf().inSingletonScope(); bindViewContribution(bind, ScmContribution); bind(FrontendApplicationContribution).toService(ScmContribution); + bind(TabBarToolbarContribution).toService(ScmContribution); bind(ColorContribution).toService(ScmContribution); bind(NavigatorTreeDecorator).to(ScmNavigatorDecorator).inSingletonScope(); @@ -74,4 +101,22 @@ export default new ContainerModule(bind => { bind(ScmAvatarService).toSelf().inSingletonScope(); bindDirtyDiff(bind); + + bind(ScmTreeLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(ScmTreeLabelProvider); }); + +export function createFileChangeTreeWidget(parent: interfaces.Container): ScmTreeWidget { + const child = createTreeContainer(parent); + + child.unbind(TreeWidget); + child.bind(ScmTreeWidget).toSelf(); + + child.rebind(TreeProps).toConstantValue({ + leftPadding: 8, + expansionTogglePadding: 22, + virtualized: true, + search: true, + }); + return child.get(ScmTreeWidget); +} diff --git a/packages/scm/src/browser/scm-provider.ts b/packages/scm/src/browser/scm-provider.ts index a51a252fb006a..10e71b5281bdd 100644 --- a/packages/scm/src/browser/scm-provider.ts +++ b/packages/scm/src/browser/scm-provider.ts @@ -35,6 +35,7 @@ export interface ScmProvider extends Disposable { readonly amendSupport?: ScmAmendSupport; } +export const ScmResourceGroup = Symbol('ScmResourceGroup'); export interface ScmResourceGroup extends Disposable { readonly id: string; readonly label: string; diff --git a/packages/scm/src/browser/scm-repository.ts b/packages/scm/src/browser/scm-repository.ts index f266e7f00ffe4..829daff7071bb 100644 --- a/packages/scm/src/browser/scm-repository.ts +++ b/packages/scm/src/browser/scm-repository.ts @@ -14,11 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { Disposable, DisposableCollection, Emitter } from '@theia/core/lib/common'; import { ScmInput, ScmInputOptions } from './scm-input'; -import { ScmProvider, ScmResource } from './scm-provider'; +import { ScmProvider } from './scm-provider'; export interface ScmProviderOptions { input?: ScmInputOptions @@ -42,63 +40,13 @@ export class ScmRepository implements Disposable { ) { this.toDispose.pushAll([ this.provider, - this.provider.onDidChange(() => this.updateResources()), this.input = new ScmInput(options.input), this.input.onDidChange(() => this.fireDidChange()) ]); - this.updateResources(); } dispose(): void { this.toDispose.dispose(); } - // TODO replace by TreeModel - protected readonly _resources: ScmResource[] = []; - get resources(): ScmResource[] { - return this._resources; - } - protected updateResources(): void { - this._resources.length = 0; - for (const group of this.provider.groups) { - this._resources.push(...group.resources); - } - this.updateSelection(); - } - - protected selectedIndex: number = -1; - get selectedResource(): ScmResource | undefined { - return this._resources[this.selectedIndex]; - } - set selectedResource(selectedResource: ScmResource | undefined) { - this.selectedIndex = selectedResource ? this._resources.indexOf(selectedResource) : -1; - this.fireDidChange(); - } - protected updateSelection(): void { - this.selectedResource = this.selectedResource; - } - - selectNextResource(): ScmResource | undefined { - const lastIndex = this._resources.length - 1; - if (this.selectedIndex >= 0 && this.selectedIndex < lastIndex) { - this.selectedIndex++; - this.fireDidChange(); - } else if (this._resources.length && (this.selectedIndex === -1 || this.selectedIndex === lastIndex)) { - this.selectedIndex = 0; - this.fireDidChange(); - } - return this.selectedResource; - } - - selectPreviousResource(): ScmResource | undefined { - if (this.selectedIndex > 0) { - this.selectedIndex--; - this.fireDidChange(); - } else if (this.selectedIndex === 0) { - this.selectedIndex = this._resources.length - 1; - this.fireDidChange(); - } - return this.selectedResource; - } - } diff --git a/packages/scm/src/browser/scm-service.ts b/packages/scm/src/browser/scm-service.ts index 5240789fd1855..457e6d35014d3 100644 --- a/packages/scm/src/browser/scm-service.ts +++ b/packages/scm/src/browser/scm-service.ts @@ -66,9 +66,7 @@ export class ScmService { } this.toDisposeOnSelected.dispose(); this._selectedRepository = repository; - this.updateContextKeys(); if (this._selectedRepository) { - this.toDisposeOnSelected.push(this._selectedRepository.onDidChange(() => this.updateContextKeys())); if (this._selectedRepository.provider.onDidChangeStatusBarCommands) { this.toDisposeOnSelected.push(this._selectedRepository.provider.onDidChangeStatusBarCommands(() => this.fireDidChangeStatusBarCommands())); } @@ -107,14 +105,4 @@ export class ScmService { return repository; } - protected updateContextKeys(): void { - if (this._selectedRepository) { - this.contextKeys.scmProvider.set(this._selectedRepository.provider.id); - this.contextKeys.scmResourceGroup.set(this._selectedRepository.selectedResource && this._selectedRepository.selectedResource.group.id); - } else { - this.contextKeys.scmProvider.reset(); - this.contextKeys.scmResourceGroup.reset(); - } - } - } diff --git a/packages/scm/src/browser/scm-tree-label-provider.ts b/packages/scm/src/browser/scm-tree-label-provider.ts new file mode 100644 index 0000000000000..6bd19c56292b3 --- /dev/null +++ b/packages/scm/src/browser/scm-tree-label-provider.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (C) 2020 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { LabelProviderContribution, LabelProvider } from '@theia/core/lib/browser/label-provider'; +import { TreeNode } from '@theia/core/lib/browser/tree'; +import { FileChangeFolderNode, FileChangeNode } from './scm-tree-widget'; + +@injectable() +export class ScmTreeLabelProvider implements LabelProviderContribution { + + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + + canHandle(element: object): number { + return TreeNode.is(element) ? 60 : 0; + } + + getName(node: TreeNode): string | undefined { + if (FileChangeFolderNode.is(node) || FileChangeNode.is(node)) { + return this.labelProvider.getName(new URI(node.sourceUri)); + } + } +} diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx new file mode 100644 index 0000000000000..bbac8100f2523 --- /dev/null +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -0,0 +1,803 @@ +/******************************************************************************** + * Copyright (C) 2020 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/* eslint-disable no-null/no-null, @typescript-eslint/no-explicit-any */ + +import * as React from 'react'; +import { injectable, inject, postConstruct } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; +import { TreeWidget, TreeNode, TreeProps, NodeProps, TreeModel, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode, + TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; +import { MenuModelRegistry, ActionMenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; +import { ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider'; +import { ScmService } from './scm-service'; +import { CommandRegistry } from '@theia/core/lib/common/command'; +import { ScmRepository } from './scm-repository'; +import { ContextMenuRenderer, LabelProvider, CorePreferences, DiffUris} from '@theia/core/lib/browser'; +import { ScmContextKeyService } from './scm-context-key-service'; +import { EditorWidget } from '@theia/editor/lib/browser'; +import { EditorManager, DiffNavigatorProvider } from '@theia/editor/lib/browser'; + +export interface FileChangeGroupRoot extends CompositeTreeNode { + rootUri: string; + children: FileChangeGroupNode[]; +} + +export interface FileChangeGroupNode extends ExpandableTreeNode { + groupId: string; + children: (FileChangeFolderNode | FileChangeNode)[]; +} + +export namespace FileChangeGroupNode { + export function is(node: TreeNode): node is FileChangeGroupNode { + return 'groupId' in node && 'children' in node + && !FileChangeFolderNode.is(node); + } +} + +export interface FileChangeFolderNode extends ExpandableTreeNode, SelectableTreeNode { + groupId: string; + path: string; + sourceUri: string; + children: (FileChangeFolderNode | FileChangeNode)[]; +} + +export namespace FileChangeFolderNode { + export function is(node: TreeNode): node is FileChangeFolderNode { + return 'groupId' in node && 'sourceUri' in node && 'path' in node && 'children' in node; + } +} + +export interface FileChangeNode extends SelectableTreeNode { + sourceUri: string; + decorations?: ScmResourceDecorations; +} + +export namespace FileChangeNode { + export function is(node: TreeNode): node is FileChangeNode { + return 'sourceUri' in node + && !FileChangeFolderNode.is(node); + } +} + +export interface ScmTreeProps extends TreeProps { + nestingThreshold?: number; +} + +@injectable() +export class ScmTreeWidget extends TreeWidget { + + static ID = 'scm-resource-widget'; + + static RESOURCE_GROUP_CONTEXT_MENU = ['RESOURCE_GROUP_CONTEXT_MENU']; + static RESOURCE_GROUP_INLINE_MENU = ['RESOURCE_GROUP_INLINE_MENU']; + + static RESOURCE_INLINE_MENU = ['RESOURCE_INLINE_MENU']; + static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU']; + + @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; + @inject(CommandRegistry) protected readonly commands: CommandRegistry; + @inject(CorePreferences) protected readonly corePreferences: CorePreferences; + @inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService; + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; + + protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); + + constructor( + @inject(TreeProps) readonly props: ScmTreeProps, + @inject(TreeModel) readonly model: TreeModel, + @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer, + @inject(ScmService) protected readonly scmService: ScmService, + ) { + super(props, model, contextMenuRenderer); + this.id = 'resource_widget'; + } + + @postConstruct() + protected init(): void { + super.init(); + this.addClass('groups-outer-container'); + + this.refreshOnRepositoryChange(); + this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => { + this.refreshOnRepositoryChange(); + this.forceUpdate(); + })); + this.toDispose.push(this.labelProvider.onDidChange(event => { + this.update(); + })); + } + + protected createTree(repository: ScmRepository): FileChangeGroupRoot { + const root = { + id: 'file-change-tree-root', + parent: undefined, + visible: false, + rootUri: repository.provider.rootUri, + children: [] + } as FileChangeGroupRoot; + + const { groups } = repository.provider; + const groupNodes = groups + .filter(group => !!group.resources.length || !group.hideWhenEmpty) + .map(group => this.toGroupNode(group, root)); + root.children = groupNodes; + + return root; + } + + protected async openResource(resource: ScmResource): Promise { + try { + await resource.open(); + } catch (e) { + console.error('Failed to open a SCM resource', e); + return undefined; + } + + let standaloneEditor: EditorWidget | undefined; + const resourcePath = resource.sourceUri.path.toString(); + for (const widget of this.editorManager.all) { + const resourceUri = widget.getResourceUri(); + const editorResourcePath = resourceUri && resourceUri.path.toString(); + if (resourcePath === editorResourcePath) { + if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME) { + // prefer diff editor + return widget; + } else { + standaloneEditor = widget; + } + } + if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME + && String(widget.getResourceUri()) === resource.sourceUri.toString()) { + return widget; + } + } + // fallback to standalone editor + return standaloneEditor; + } + + protected _viewMode: 'tree' | 'flat' = 'flat'; + set viewMode(id: 'tree' | 'flat') { + const oldSelection = this.model.selectedNodes; + this._viewMode = id; + const repository = this.scmService.selectedRepository; + if (repository) { + this.model.root = this.createTree(repository); + + for (const oldSelectedNode of oldSelection) { + const newNode = this.model.getNode(oldSelectedNode.id); + if (SelectableTreeNode.is(newNode)) { + this.revealNode(newNode); // this call can run asynchronously + } + } + } + } + get viewMode(): 'tree' | 'flat' { + return this._viewMode; + } + + protected async revealNode(node: TreeNode): Promise { + if (FileChangeFolderNode.is(node) || FileChangeNode.is(node)) { + const parentNode = node.parent; + if (ExpandableTreeNode.is(parentNode)) { + await this.revealNode(parentNode); + if (!parentNode.expanded) { + await this.model.expandNode(parentNode); + } + } + } + } + + protected refreshOnRepositoryChange(): void { + this.toDisposeOnRepositoryChange.dispose(); + + const repository = this.scmService.selectedRepository; + if (repository) { + const provider = repository.provider; + this.model.root = this.createTree(repository); + this.toDisposeOnRepositoryChange.push(provider.onDidChange(() => { + this.model.root = this.createTree(repository); + })); + } + } + + protected toGroupNode(group: ScmResourceGroup, parent: CompositeTreeNode): FileChangeGroupNode { + const groupNode: FileChangeGroupNode = { + id: `${group.id}`, + groupId: group.id, + parent, + children: [], + expanded: true, + }; + + switch (this._viewMode) { + case 'flat': + groupNode.children = group.resources.map(fileChange => this.toFileChangeNode(fileChange, groupNode)); + break; + case 'tree': + const rootUri = group.provider.rootUri; + if (rootUri) { + const resourcePaths = group.resources.map(resource => { + const relativePath = new URI(rootUri).relative(resource.sourceUri); + const pathParts = relativePath ? relativePath.toString().split('/') : []; + return { resource, pathParts }; + }); + groupNode.children = this.buildFileChangeTree(resourcePaths, 0, group.resources.length, 0, groupNode); + } + break; + } + + return groupNode; + } + + protected buildFileChangeTree( + resources: { resource: ScmResource, pathParts: string[] }[], + start: number, + end: number, + level: number, + parent: (FileChangeGroupNode | FileChangeFolderNode) + ): (FileChangeFolderNode | FileChangeNode)[] { + const result: (FileChangeFolderNode | FileChangeNode)[] = []; + + let folderStart = start; + while (folderStart < end) { + const firstFileChange = resources[folderStart]; + if (level === firstFileChange.pathParts.length - 1) { + result.push(this.toFileChangeNode(firstFileChange.resource, parent)); + folderStart++; + } else { + let index = folderStart + 1; + while (index < end) { + if (resources[index].pathParts[level] !== firstFileChange.pathParts[level]) { + break; + } + index++; + } + const folderEnd = index; + + const nestingThreshold = this.props.nestingThreshold || 1; + if (folderEnd - folderStart < nestingThreshold) { + // Inline these (i.e. do not create another level in the tree) + for (let i = folderStart; i < folderEnd; i++) { + result.push(this.toFileChangeNode(resources[i].resource, parent)); + } + } else { + const firstFileParts = firstFileChange.pathParts; + const lastFileParts = resources[folderEnd - 1].pathParts; + // Multiple files with first folder. + // See if more folder levels match and include those if so. + let thisLevel = level + 1; + while (thisLevel < firstFileParts.length - 1 && thisLevel < lastFileParts.length - 1 && firstFileParts[thisLevel] === lastFileParts[thisLevel]) { + thisLevel++; + } + const nodeRelativePath = firstFileParts.slice(level, thisLevel).join('/'); + result.push(this.toFileChangeFolderNode(resources, folderStart, folderEnd, thisLevel, nodeRelativePath, parent)); + } + folderStart = folderEnd; + } + }; + + return result; + } + + protected toFileChangeFolderNode( + resources: { resource: ScmResource, pathParts: string[] }[], + start: number, + end: number, + level: number, + nodeRelativePath: string, + parent: (FileChangeGroupNode | FileChangeFolderNode) + ): FileChangeFolderNode { + const rootUri = this.getRoot(parent).rootUri; + let parentPath: string = rootUri; + if (FileChangeFolderNode.is(parent)) { + parentPath = parent.sourceUri; + } + const sourceUri = new URI(parentPath).resolve(nodeRelativePath); + + const id = `${parent.groupId}:${String(sourceUri)}`; + const oldNode = this.model.getNode(id); + const folderNode: FileChangeFolderNode = { + id, + groupId: parent.groupId, + path: nodeRelativePath, + sourceUri: String(sourceUri), + children: [], + parent, + expanded: ExpandableTreeNode.is(oldNode) && oldNode.expanded, + selected: SelectableTreeNode.is(oldNode) && oldNode.selected, + }; + folderNode.children = this.buildFileChangeTree(resources, start, end, level, folderNode); + return folderNode; + } + + protected getRoot(node: FileChangeGroupNode | FileChangeFolderNode): FileChangeGroupRoot { + let parent = node.parent!; + while (FileChangeGroupNode.is(parent) && FileChangeFolderNode.is(parent)) { + parent = parent.parent!; + } + return parent as FileChangeGroupRoot; + } + + protected toFileChangeNode(resource: ScmResource, parent: CompositeTreeNode): FileChangeNode { + const id = `${resource.group.id}:${String(resource.sourceUri)}`; + const oldNode = this.model.getNode(id); + const node = { + id, + sourceUri: String(resource.sourceUri), + decorations: resource.decorations, + parent, + selected: SelectableTreeNode.is(oldNode) && oldNode.selected, + }; + if (node.selected) { + this.selectionService.selection = node; + } + return node; + } + + /** + * Render the node given the tree node and node properties. + * @param node the tree node. + * @param props the node properties. + */ + protected renderNode(node: TreeNode, props: NodeProps): React.ReactNode { + const repository = this.scmService.selectedRepository; + if (!repository) { + return undefined; + } + + if (!TreeNode.isVisible(node)) { + return undefined; + } + + const attributes = this.createNodeAttributes(node, props); + + if (FileChangeGroupNode.is(node)) { + const group = repository.provider.groups.find(g => g.id === node.groupId)!; + const content = this.renderExpansionToggle(node, props) } + contextMenuRenderer={this.contextMenuRenderer} + commands={this.commands} + menus={this.menus} + contextKeys={this.contextKeys} + labelProvider={this.labelProvider} + corePreferences={this.corePreferences} />; + + return React.createElement('div', attributes, content); + + } + if (FileChangeFolderNode.is(node)) { + const group = repository.provider.groups.find(g => g.id === node.groupId)!; + const content = this.renderExpansionToggle(node, props) } + contextMenuRenderer={this.contextMenuRenderer} + commands={this.commands} + menus={this.menus} + contextKeys={this.contextKeys} + labelProvider={this.labelProvider} + corePreferences={this.corePreferences} />; + + return React.createElement('div', attributes, content); + } + if (FileChangeNode.is(node)) { + const parentNode = node.parent; + if (!(parentNode && (FileChangeFolderNode.is(parentNode) || FileChangeGroupNode.is(parentNode)))) { + return ''; + } + const groupId = parentNode.groupId; + const group = repository.provider.groups.find(g => g.id === groupId)!; + const name = this.labelProvider.getName(new URI(node.sourceUri)); + const parentPath = + (node.parent && FileChangeFolderNode.is(node.parent)) + ? new URI(node.parent.sourceUri) : new URI(repository.provider.rootUri); + + const content = this.renderExpansionToggle(node, props), + }} + />; + return React.createElement('div', attributes, content); + } + return super.renderNode(node, props); + } + + protected createContainerAttributes(): React.HTMLAttributes { + const repository = this.scmService.selectedRepository; + if (repository) { + const select = () => { + const selectedResource = this.selectionService.selection; + if (!TreeNode.is(selectedResource) || !FileChangeFolderNode.is(selectedResource) && !FileChangeNode.is(selectedResource)) { + const nonEmptyGroup = repository.provider.groups + .find(g => g.resources.length !== 0); + if (nonEmptyGroup) { + this.selectionService.selection = nonEmptyGroup.resources[0]; + } + } + }; + return { + ...super.createContainerAttributes(), + onFocus: select, + tabIndex: 0, + id: ScmTreeWidget.ID, + }; + } + return super.createContainerAttributes(); + } + + storeState(): any { + console.warn('saving state'); + const state: object = { + mode: this._viewMode, + tree: super.storeState(), + }; + console.warn('state is ' + JSON.stringify(state)); + return state; + } + + restoreState(oldState: any): void { + const { mode, tree } = oldState; + this._viewMode = mode === 'tree' ? 'tree' : 'flat'; + super.restoreState(tree); + } + +} + +export namespace ScmTreeWidget { + export namespace Styles { + export const GROUPS_CONTAINER = 'groups-outer-container'; // also in ScmWidget + export const NO_SELECT = 'no-select'; + } + + // This is an 'abstract' base interface for all the element component props. + export interface Props { + repository: ScmRepository; + commands: CommandRegistry; + menus: MenuModelRegistry; + contextKeys: ScmContextKeyService; + labelProvider: LabelProvider; + contextMenuRenderer: ContextMenuRenderer; + corePreferences?: CorePreferences; + } +} + +export abstract class ScmElement

extends React.Component { + + constructor(props: P) { + super(props); + this.state = { + hover: false + }; + + const setState = this.setState.bind(this); + this.setState = newState => { + if (!this.toDisposeOnUnmount.disposed) { + setState(newState); + } + }; + } + + protected readonly toDisposeOnUnmount = new DisposableCollection(); + componentDidMount(): void { + this.toDisposeOnUnmount.push(Disposable.create(() => { /* mark as mounted */ })); + } + componentWillUnmount(): void { + this.toDisposeOnUnmount.dispose(); + } + + protected detectHover = (element: HTMLElement | null) => { + if (element) { + window.requestAnimationFrame(() => { + const hover = element.matches(':hover'); + this.setState({ hover }); + }); + } + }; + protected showHover = () => this.setState({ hover: true }); + protected hideHover = () => this.setState({ hover: false }); + + protected renderContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + const { group, contextKeys, contextMenuRenderer } = this.props; + const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); + contextKeys.scmResourceGroup.set(group.id); + try { + contextMenuRenderer.render({ + menuPath: this.contextMenuPath, + anchor: event.nativeEvent, + args: this.contextMenuArgs + }); + } finally { + contextKeys.scmResourceGroup.set(currentScmResourceGroup); + } + }; + + protected abstract get contextMenuPath(): MenuPath; + protected abstract get contextMenuArgs(): any[]; + +} +export namespace ScmElement { + export interface Props extends ScmTreeWidget.Props { + group: ScmResourceGroup + renderExpansionToggle: () => React.ReactNode + } + export interface State { + hover: boolean + } +} + +export class ScmResourceComponent extends ScmElement { + + render(): JSX.Element | undefined { + const { hover } = this.state; + const { name, group, parentPath, sourceUri, decorations, labelProvider, commands, menus, contextKeys } = this.props; + const resourceUri = new URI(sourceUri); + + const icon = labelProvider.getIcon(resourceUri); + const color = decorations && decorations.color || ''; + const letter = decorations && decorations.letter || ''; + const tooltip = decorations && decorations.tooltip || ''; + const relativePath = parentPath.relative(resourceUri.parent); + const path = relativePath ? relativePath.toString() : labelProvider.getLongName(resourceUri.parent); + return

+ + {this.props.renderExpansionToggle()} +
+ {name} + {path} +
+ +
+ {letter} +
+
+
; + } + + protected open = () => { + const selectedResource = this.props.group.resources.find(r => String(r.sourceUri) === this.props.sourceUri)!; + selectedResource.open(); + }; + + // TODO this is incorrect. It must be done when the model fires a selection change. + protected selectChange = () => this.updateContextKeys(); + + protected updateContextKeys(): void { + if (this.props.repository) { + console.warn('switching group context to ' + this.props.group.id + ' from ' + this.props.contextKeys.scmResourceGroup.get()); + this.props.contextKeys.scmProvider.set(this.props.repository.provider.id); + this.props.contextKeys.scmResourceGroup.set(this.props.group.id); + } else { + this.props.contextKeys.scmProvider.reset(); + this.props.contextKeys.scmResourceGroup.reset(); + } + } + + protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_CONTEXT_MENU; + protected get contextMenuArgs(): any[] { + return [String(this.props.sourceUri)]; // TODO support multiselection + } + + /** + * Handle the single clicking of nodes present in the widget. + */ + protected handleClick = () => { + // Determine the behavior based on the preference value. + const isSingle = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'singleClick'; + this.selectChange(); + if (isSingle) { + this.open(); + } + }; + + /** + * Handle the double clicking of nodes present in the widget. + */ + protected handleDoubleClick = () => { + // Determine the behavior based on the preference value. + const isDouble = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'doubleClick'; + // Nodes should only be opened through double clicking if the correct preference is set. + if (isDouble) { + this.open(); + } + }; +} +export namespace ScmResourceComponent { + export interface Props extends ScmElement.Props { + name: string; + parentPath: URI; + sourceUri: string; + decorations?: ScmResourceDecorations; + } +} + +export class ScmResourceGroupElement extends ScmElement { + + render(): JSX.Element { + const { hover } = this.state; + const { group, menus, commands, contextKeys } = this.props; + return
+ {this.props.renderExpansionToggle()} +
{group.label}
+ + {this.renderChangeCount()} + +
; + } + + protected renderChangeCount(): React.ReactNode { + return
+ {this.props.group.resources.length} +
; + } + + protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU; + protected get contextMenuArgs(): any[] { + return [this.props.group]; + } +} + +export class ScmResourceFolderElement extends ScmElement { + + render(): JSX.Element { + const { hover } = this.state; + const { group, sourceUri, path, labelProvider, commands, menus, contextKeys } = this.props; + const icon = labelProvider.getIcon(sourceUri); + + return
+ {this.props.renderExpansionToggle()} + +
+ {name} + {path} +
+ + +
; + + } + + protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU; + protected get contextMenuArgs(): any[] { + return [String(this.props.sourceUri), this.props.group.id]; + } +} + +export namespace ScmResourceFolderElement { + export interface Props extends ScmElement.Props { + sourceUri: URI; + path: string; + } +} + +export class ScmInlineActions extends React.Component { + render(): React.ReactNode { + const { hover, menu, args, commands, group, contextKeys, children } = this.props; + return
+
+ {hover && menu.children.map((node, index) => node instanceof ActionMenuNode && )} +
+ {children} +
; + } +} +export namespace ScmInlineActions { + export interface Props { + hover: boolean; + menu: CompositeMenuNode; + commands: CommandRegistry; + group: ScmResourceGroup; + contextKeys: ScmContextKeyService; + args: any[]; + children?: React.ReactNode; + } +} + +export class ScmInlineAction extends React.Component { + render(): React.ReactNode { + const { node, args, commands, group, contextKeys } = this.props; + const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); + contextKeys.scmResourceGroup.set(group.id); + try { + if (!commands.isVisible(node.action.commandId, ...args) || !contextKeys.match(node.action.when)) { + return false; + } + return
+ +
; + } finally { + contextKeys.scmResourceGroup.set(currentScmResourceGroup); + } + } + + protected execute = (event: React.MouseEvent) => { + event.stopPropagation(); + + const { commands, node, args } = this.props; + commands.executeCommand(node.action.commandId, ...args); + }; +} +export namespace ScmInlineAction { + export interface Props { + node: ActionMenuNode; + commands: CommandRegistry; + group: ScmResourceGroup; + contextKeys: ScmContextKeyService; + args: any[]; + } +} diff --git a/packages/scm/src/browser/scm-widget.tsx b/packages/scm/src/browser/scm-widget.tsx index e0242a89fffde..891e002b78ed4 100644 --- a/packages/scm/src/browser/scm-widget.tsx +++ b/packages/scm/src/browser/scm-widget.tsx @@ -17,82 +17,66 @@ /* eslint-disable no-null/no-null, @typescript-eslint/no-explicit-any */ import * as React from 'react'; -import TextareaAutosize from 'react-autosize-textarea'; import { Message } from '@phosphor/messaging'; -import { ElementExt } from '@phosphor/domutils'; -import { injectable, inject, postConstruct } from 'inversify'; -import URI from '@theia/core/lib/common/uri'; -import { CommandRegistry } from '@theia/core/lib/common/command'; -import { MenuModelRegistry, ActionMenuNode, CompositeMenuNode, MenuPath } from '@theia/core/lib/common/menu'; -import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; +import { injectable, inject, postConstruct, interfaces } from 'inversify'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { - ContextMenuRenderer, SELECTED_CLASS, StorageService, - ReactWidget, Key, LabelProvider, DiffUris, KeybindingRegistry, Widget, StatefulWidget, CorePreferences -} from '@theia/core/lib/browser'; + BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, MessageLoop} from '@theia/core/lib/browser'; import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message'; -import { EditorManager, DiffNavigatorProvider, EditorWidget } from '@theia/editor/lib/browser'; -import { ScmAvatarService } from './scm-avatar-service'; -import { ScmAmendComponent } from './scm-amend-component'; -import { ScmContextKeyService } from './scm-context-key-service'; +import { ScmCommitWidget } from './scm-commit-widget'; +import { ScmAmendWidget } from './scm-amend-widget'; import { ScmService } from './scm-service'; -import { ScmInput } from './scm-input'; -import { ScmRepository } from './scm-repository'; -import { ScmResource, ScmResourceGroup } from './scm-provider'; +import { ScmTreeWidget } from './scm-tree-widget'; @injectable() -export class ScmWidget extends ReactWidget implements StatefulWidget { +export class ScmWidget extends BaseWidget implements StatefulWidget { - static ID = 'scm-view'; - - static RESOURCE_GROUP_CONTEXT_MENU = ['RESOURCE_GROUP_CONTEXT_MENU']; - static RESOURCE_GROUP_INLINE_MENU = ['RESOURCE_GROUP_INLINE_MENU']; + protected panel: Panel; - static RESOURCE_INLINE_MENU = ['RESOURCE_INLINE_MENU']; - static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU']; + static ID = 'scm-view'; - @inject(CorePreferences) protected readonly corePreferences: CorePreferences; @inject(ScmService) protected readonly scmService: ScmService; - @inject(CommandRegistry) protected readonly commands: CommandRegistry; - @inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry; - @inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry; - @inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService; - @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; - @inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService; - @inject(StorageService) protected readonly storageService: StorageService; - @inject(LabelProvider) protected readonly labelProvider: LabelProvider; - @inject(EditorManager) protected readonly editorManager: EditorManager; - @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; + @inject(ScmCommitWidget) protected readonly commitWidget: ScmCommitWidget; + @inject(ScmTreeWidget) protected readonly resourceWidget: ScmTreeWidget; + @inject(ScmAmendWidget) protected readonly amendWidget: ScmAmendWidget; - // TODO: a hack to install DOM listeners, replace it with React, i.e. use TreeWidget instead - protected _scrollContainer: string; - protected set scrollContainer(id: string) { - this._scrollContainer = id + Date.now(); + set viewMode(mode: 'tree' | 'flat') { + this.resourceWidget.viewMode = mode; } - protected get scrollContainer(): string { - return this._scrollContainer; + get viewMode(): 'tree' | 'flat' { + return this.resourceWidget.viewMode; } - /** don't modify DOM use React! only exposed for `focusInput` */ - protected readonly inputRef = React.createRef(); - constructor() { super(); this.node.tabIndex = 0; this.id = ScmWidget.ID; this.addClass('theia-scm'); - this.scrollContainer = ScmWidget.Styles.GROUPS_CONTAINER; + this.addClass('theia-scm-main-container'); } @postConstruct() protected init(): void { + const layout = new PanelLayout(); + this.layout = layout; + this.panel = new Panel({ + layout: new PanelLayout ({ + }) + }); + this.panel.node.tabIndex = -1; + this.panel.node.setAttribute('style', 'overflow: visible;'); + layout.addWidget(this.panel); + + this.containerLayout.addWidget(this.commitWidget); + this.containerLayout.addWidget(this.resourceWidget); + this.containerLayout.addWidget(this.amendWidget); + this.refresh(); this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refresh())); - this.toDispose.push(this.labelProvider.onDidChange(e => { - const repository = this.scmService.selectedRepository; - if (repository && repository.resources.some(resource => e.affects(resource.sourceUri))) { - this.update(); - } - })); + } + + get containerLayout(): PanelLayout { + return this.panel.layout as PanelLayout; } protected readonly toDisposeOnRefresh = new DisposableCollection(); @@ -114,11 +98,7 @@ export class ScmWidget extends ReactWidget implements StatefulWidget { protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); - (this.inputRef.current || this.node).focus(); - } - - protected onAfterShow(msg: Message): void { - super.onAfterShow(msg); + this.commitWidget.focus(); this.update(); } @@ -130,21 +110,20 @@ export class ScmWidget extends ReactWidget implements StatefulWidget { if (!this.isAttached || !this.isVisible) { return; } - this.onRender.push(Disposable.create(() => async () => { - const selected = this.node.getElementsByClassName(SELECTED_CLASS)[0]; - if (selected) { - ElementExt.scrollIntoViewIfNeeded(this.node, selected); - } - })); + MessageLoop.sendMessage(this.commitWidget, msg); + MessageLoop.sendMessage(this.resourceWidget, msg); + MessageLoop.sendMessage(this.amendWidget, msg); super.onUpdateRequest(msg); } - protected addScmListKeyListeners = (id: string) => { - const container = document.getElementById(id); - if (container) { - this.addScmListNavigationKeyListeners(container); - } - }; + protected onAfterAttach(msg: Message): void { + this.node.appendChild(this.commitWidget.node); + this.node.appendChild(this.resourceWidget.node); + this.node.appendChild(this.amendWidget.node); + + super.onAfterAttach(msg); + this.update(); + } protected render(): React.ReactNode { const repository = this.scmService.selectedRepository; @@ -154,540 +133,45 @@ export class ScmWidget extends ReactWidget implements StatefulWidget { header='No repository found' />; } - const input = repository.input; - const amendSupport = repository.provider.amendSupport; - - return
-
- {this.renderInput(input, repository)} -
- - {amendSupport && } -
; - } - - protected renderInput(input: ScmInput, repository: ScmRepository): React.ReactNode { - const validationStatus = input.issue ? input.issue.type : 'idle'; - const validationMessage = input.issue ? input.issue.message : ''; - const format = (value: string, ...args: string[]): string => { - if (args.length !== 0) { - return value.replace(/{(\d+)}/g, (found, n) => { - const i = parseInt(n); - return isNaN(i) || i < 0 || i >= args.length ? found : args[i]; - }); - } - return value; - }; - - const keybinding = this.keybindings.acceleratorFor(this.keybindings.getKeybindingsForCommand('scm.acceptInput')[0]).join('+'); - const message = format(input.placeholder || '', keybinding); - return
- - -
{validationMessage}
-
; } protected focusInput(): void { - if (this.inputRef.current) { - this.inputRef.current.focus(); - } - } - - protected setInputValue = (event: React.FormEvent | React.ChangeEvent | string) => { - const repository = this.scmService.selectedRepository; - if (repository) { - repository.input.value = typeof event === 'string' ? event : event.currentTarget.value; - } - }; - - protected acceptInput = () => this.commands.executeCommand('scm.acceptInput'); - - protected addScmListNavigationKeyListeners(container: HTMLElement): void { - this.addKeyListener(container, Key.ARROW_LEFT, () => this.openPreviousChange()); - this.addKeyListener(container, Key.ARROW_RIGHT, () => this.openNextChange()); - this.addKeyListener(container, Key.ARROW_UP, () => this.selectPreviousResource()); - this.addKeyListener(container, Key.ARROW_DOWN, () => this.selectNextResource()); - this.addKeyListener(container, Key.ENTER, () => this.openSelected()); - } - - protected async openPreviousChange(): Promise { - const repository = this.scmService.selectedRepository; - if (!repository) { - return; - } - const selected = repository.selectedResource; - if (selected) { - const widget = await this.openResource(selected); - if (widget) { - const diffNavigator = this.diffNavigatorProvider(widget.editor); - if (diffNavigator.canNavigate() && diffNavigator.hasPrevious()) { - diffNavigator.previous(); - } else { - const previous = repository.selectPreviousResource(); - if (previous) { - previous.open(); - } - } - } else { - const previous = repository.selectPreviousResource(); - if (previous) { - previous.open(); - } - } - } - } - - protected async openNextChange(): Promise { - const repository = this.scmService.selectedRepository; - if (!repository) { - return; - } - const selected = repository.selectedResource; - if (selected) { - const widget = await this.openResource(selected); - if (widget) { - const diffNavigator = this.diffNavigatorProvider(widget.editor); - if (diffNavigator.canNavigate() && diffNavigator.hasNext()) { - diffNavigator.next(); - } else { - const next = repository.selectNextResource(); - if (next) { - next.open(); - } - } - } else { - const next = repository.selectNextResource(); - if (next) { - next.open(); - } - } - } else if (repository && repository.resources.length) { - repository.selectedResource = repository.resources[0]; - repository.selectedResource.open(); - } - } - - protected async openResource(resource: ScmResource): Promise { - try { - await resource.open(); - } catch (e) { - console.error('Failed to open a SCM resource', e); - return undefined; - } - - let standaloneEditor: EditorWidget | undefined; - const resourcePath = resource.sourceUri.path.toString(); - for (const widget of this.editorManager.all) { - const resourceUri = widget.getResourceUri(); - const editorResourcePath = resourceUri && resourceUri.path.toString(); - if (resourcePath === editorResourcePath) { - if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME) { - // prefer diff editor - return widget; - } else { - standaloneEditor = widget; - } - } - if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME - && String(widget.getResourceUri()) === resource.sourceUri.toString()) { - return widget; - } - } - // fallback to standalone editor - return standaloneEditor; - } - - protected selectPreviousResource(): ScmResource | undefined { - const repository = this.scmService.selectedRepository; - return repository && repository.selectPreviousResource(); - } - - protected selectNextResource(): ScmResource | undefined { - const repository = this.scmService.selectedRepository; - return repository && repository.selectNextResource(); - } - - protected openSelected(): void { - const repository = this.scmService.selectedRepository; - const resource = repository && repository.selectedResource; - if (resource) { - resource.open(); - } + this.commitWidget.focus(); } storeState(): any { - const repository = this.scmService.selectedRepository; - return repository && repository.input; + console.warn('saving state'); + const state: object = { + commitState: this.commitWidget.storeState(), + changesTreeState: this.resourceWidget.storeState(), + }; + console.warn('state is ' + JSON.stringify(state)); + return state; } restoreState(oldState: any): void { - const repository = this.scmService.selectedRepository; - if (repository) { - repository.input.fromJSON(oldState); - } + const { commitState, changesTreeState } = oldState; + this.commitWidget.restoreState(commitState); + this.resourceWidget.restoreState(changesTreeState); } } export namespace ScmWidget { - export namespace Styles { - export const MAIN_CONTAINER = 'theia-scm-main-container'; - export const PROVIDER_CONTAINER = 'theia-scm-provider-container'; - export const PROVIDER_NAME = 'theia-scm-provider-name'; - export const GROUPS_CONTAINER = 'groups-outer-container'; - export const INPUT_MESSAGE_CONTAINER = 'theia-scm-input-message-container'; - export const INPUT_MESSAGE = 'theia-scm-input-message'; - export const VALIDATION_MESSAGE = 'theia-scm-input-validation-message'; - export const NO_SELECT = 'no-select'; - } - export interface Props { - repository: ScmRepository; - commands: CommandRegistry; - menus: MenuModelRegistry; - contextKeys: ScmContextKeyService; - labelProvider: LabelProvider; - contextMenuRenderer: ContextMenuRenderer; - corePreferences?: CorePreferences; - } - -} - -export abstract class ScmElement

extends React.Component { - - constructor(props: P) { - super(props); - this.state = { - hover: false - }; - - const setState = this.setState.bind(this); - this.setState = newState => { - if (!this.toDisposeOnUnmount.disposed) { - setState(newState); - } - }; - } - - protected readonly toDisposeOnUnmount = new DisposableCollection(); - componentDidMount(): void { - this.toDisposeOnUnmount.push(Disposable.create(() => { /* mark as mounted */ })); - } - componentWillUnmount(): void { - this.toDisposeOnUnmount.dispose(); - } - - protected detectHover = (element: HTMLElement | null) => { - if (element) { - window.requestAnimationFrame(() => { - const hover = element.matches(':hover'); - this.setState({ hover }); - }); - } - }; - protected showHover = () => this.setState({ hover: true }); - protected hideHover = () => this.setState({ hover: false }); - - protected renderContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - const { group, contextKeys, contextMenuRenderer } = this.props; - const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); - contextKeys.scmResourceGroup.set(group.id); - try { - contextMenuRenderer.render({ - menuPath: this.contextMenuPath, - anchor: event.nativeEvent, - args: this.contextMenuArgs - }); - } finally { - contextKeys.scmResourceGroup.set(currentScmResourceGroup); - } - }; - - protected abstract get contextMenuPath(): MenuPath; - protected abstract get contextMenuArgs(): any[]; - -} -export namespace ScmElement { - export interface Props extends ScmWidget.Props { - group: ScmResourceGroup - } - export interface State { - hover: boolean - } -} - -export class ScmResourceComponent extends ScmElement { - - render(): JSX.Element | undefined { - const { hover } = this.state; - const { name, repository, resource, labelProvider, commands, menus, contextKeys } = this.props; - const rootUri = resource.group.provider.rootUri; - if (!rootUri) { - return undefined; - } - const decorations = resource.decorations; - const icon = labelProvider.getIcon(resource.sourceUri); - const color = decorations && decorations.color || ''; - const letter = decorations && decorations.letter || ''; - const tooltip = decorations && decorations.tooltip || ''; - const relativePath = new URI(rootUri).relative(resource.sourceUri.parent); - const path = relativePath ? relativePath.toString() : labelProvider.getLongName(resource.sourceUri.parent); - return

- -
- {name} - {path} -
- -
- {letter} -
-
-
; - } + export namespace Factory { - protected open = () => this.props.resource.open(); - - protected selectChange = () => this.props.repository.selectedResource = this.props.resource; - - protected readonly contextMenuPath = ScmWidget.RESOURCE_CONTEXT_MENU; - protected get contextMenuArgs(): any[] { - return [this.props.resource]; // TODO support multiselection - } - - /** - * Handle the single clicking of nodes present in the widget. - */ - protected handleClick = () => { - // Determine the behavior based on the preference value. - const isSingle = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'singleClick'; - this.selectChange(); - if (isSingle) { - this.open(); + export interface WidgetOptions { + readonly order?: number; + readonly weight?: number; + readonly initiallyCollapsed?: boolean; + readonly canHide?: boolean; + readonly initiallyHidden?: boolean; } - }; - /** - * Handle the double clicking of nodes present in the widget. - */ - protected handleDoubleClick = () => { - // Determine the behavior based on the preference value. - const isDouble = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'doubleClick'; - // Nodes should only be opened through double clicking if the correct preference is set. - if (isDouble) { - this.open(); + export interface WidgetDescriptor { + readonly widget: Widget | interfaces.ServiceIdentifier; + readonly options?: WidgetOptions; } - }; -} -export namespace ScmResourceComponent { - export interface Props extends ScmElement.Props { - name: string; - resource: ScmResource; - } -} - -export class ScmResourceGroupsContainer extends React.Component { - render(): JSX.Element { - const { groups } = this.props.repository.provider; - return
- {groups && this.props.repository.provider.groups.map(group => this.renderGroup(group))} -
; - } - - protected select = () => { - const selectedResource = this.props.repository.selectedResource; - if (!selectedResource && this.props.repository.resources.length) { - this.props.repository.selectedResource = this.props.repository.resources[0]; - } - }; - - protected renderGroup(group: ScmResourceGroup): React.ReactNode { - const visible = !!group.resources.length || !group.hideWhenEmpty; - return visible && ; - } - - componentDidMount(): void { - this.props.addScmListKeyListeners(this.props.id); - } -} -export namespace ScmResourceGroupsContainer { - export interface Props extends ScmWidget.Props { - id: string; - style?: React.CSSProperties; - addScmListKeyListeners: (id: string) => void - } -} - -export class ScmResourceGroupContainer extends ScmElement { - - render(): JSX.Element { - const { hover } = this.state; - const { group, menus, commands, contextKeys } = this.props; - return
-
-
{group.label}
- - {this.renderChangeCount()} - -
-
{group.resources.map(resource => this.renderScmResourceItem(resource))}
-
; - } - - protected renderChangeCount(): React.ReactNode { - return
- {this.props.group.resources.length} -
; - } - - protected renderScmResourceItem(resource: ScmResource): React.ReactNode { - const name = this.props.labelProvider.getName(resource.sourceUri); - return ; - } - - protected readonly contextMenuPath = ScmWidget.RESOURCE_GROUP_CONTEXT_MENU; - protected get contextMenuArgs(): any[] { - return [this.props.group]; - } -} - -export class ScmInlineActions extends React.Component { - render(): React.ReactNode { - const { hover, menu, args, commands, group, contextKeys, children } = this.props; - return
-
- {hover && menu.children.map((node, index) => node instanceof ActionMenuNode && )} -
- {children} -
; - } -} -export namespace ScmInlineActions { - export interface Props { - hover: boolean; - menu: CompositeMenuNode; - commands: CommandRegistry; - group: ScmResourceGroup; - contextKeys: ScmContextKeyService; - args: any[]; - children?: React.ReactNode; - } -} - -export class ScmInlineAction extends React.Component { - render(): React.ReactNode { - const { node, args, commands, group, contextKeys } = this.props; - const currentScmResourceGroup = contextKeys.scmResourceGroup.get(); - contextKeys.scmResourceGroup.set(group.id); - try { - if (!commands.isVisible(node.action.commandId, ...args) || !contextKeys.match(node.action.when)) { - return false; - } - return
; - } finally { - contextKeys.scmResourceGroup.set(currentScmResourceGroup); - } - } - - protected execute = (event: React.MouseEvent) => { - event.stopPropagation(); - - const { commands, node, args } = this.props; - commands.executeCommand(node.action.commandId, ...args); - }; -} -export namespace ScmInlineAction { - export interface Props { - node: ActionMenuNode; - commands: CommandRegistry; - group: ScmResourceGroup; - contextKeys: ScmContextKeyService; - args: any[]; } } diff --git a/packages/scm/src/browser/style/index.css b/packages/scm/src/browser/style/index.css index 8505f5243141c..90091dbb6bbd1 100644 --- a/packages/scm/src/browser/style/index.css +++ b/packages/scm/src/browser/style/index.css @@ -13,6 +13,14 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +.theia-ScmCommit { + overflow: hidden; + font-size: var(--theia-ui-font-size1); + max-height: calc(100% - var(--theia-border-width)); + position: relative; +} + .theia-scm { padding: 5px; box-sizing: border-box; @@ -190,8 +198,6 @@ } .theia-scm .scmItem:hover { - background-color: var(--theia-list-hoverBackground); - color: var(--theia-list-hoverForeground); cursor: pointer; } @@ -238,8 +244,6 @@ } .scm-theia-header:hover { - background-color: var(--theia-list-hoverBackground); - color: var(--theia-list-hoverForeground); cursor: pointer; }