From afb455844c5378c8901760ee2be60e8f89420cd1 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 --- CHANGELOG.md | 6 + packages/git/src/browser/git-contribution.ts | 67 +- packages/git/src/browser/git-scm-provider.ts | 93 ++- .../menus/menus-contribution-handler.ts | 11 +- .../scm/src/browser/scm-amend-component.tsx | 5 +- packages/scm/src/browser/scm-amend-widget.tsx | 86 ++ .../scm/src/browser/scm-commit-widget.tsx | 164 ++++ packages/scm/src/browser/scm-contribution.ts | 67 +- .../scm/src/browser/scm-frontend-module.ts | 67 +- .../src/browser/scm-no-repository-widget.tsx | 40 + 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 | 35 + packages/scm/src/browser/scm-tree-model.ts | 301 +++++++ packages/scm/src/browser/scm-tree-widget.tsx | 738 ++++++++++++++++++ packages/scm/src/browser/scm-widget.tsx | 675 ++-------------- packages/scm/src/browser/style/index.css | 19 +- .../src/browser/styles/index.css | 2 +- 19 files changed, 1719 insertions(+), 724 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-no-repository-widget.tsx create mode 100644 packages/scm/src/browser/scm-tree-label-provider.ts create mode 100644 packages/scm/src/browser/scm-tree-model.ts create mode 100644 packages/scm/src/browser/scm-tree-widget.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6b8c4f04011..af475d0be2db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## v1.2.0 + +Breaking changes: + +- [scm] support file tree mode in Source Control view. Classes that extend ScmWidget will likely require changes [#7505](https://github.com/eclipse-theia/theia/pull/7505) + ## v1.1.0 - [plugin-ext] fixed custom Icon Themes & plugin Icons [#7583](https://github.com/eclipse-theia/theia/pull/7583) diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index 62b986e08653e..4662e249da1e1 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', { @@ -264,9 +265,37 @@ export class GitContribution implements CommandContribution, MenuContribution, T when: 'scmProvider == git && scmResourceGroup == merge' }); + const registerResourceFolderAction = (group: string, action: MenuAction) => { + menus.registerMenuAction(ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU, action); + menus.registerMenuAction([...ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU, group], action); + }; + + registerResourceFolderAction('1_modification', { + commandId: GIT_COMMANDS.DISCARD.id, + when: 'scmProvider == git && scmResourceGroup == workingTree' + }); + registerResourceFolderAction('1_modification', { + commandId: GIT_COMMANDS.STAGE.id, + when: 'scmProvider == git && scmResourceGroup == workingTree' + }); + + registerResourceFolderAction('1_modification', { + commandId: GIT_COMMANDS.UNSTAGE.id, + when: 'scmProvider == git && scmResourceGroup == index' + }); + + registerResourceFolderAction('1_modification', { + commandId: GIT_COMMANDS.DISCARD.id, + when: 'scmProvider == git && scmResourceGroup == merge' + }); + registerResourceFolderAction('1_modification', { + commandId: GIT_COMMANDS.STAGE.id, + when: 'scmProvider == git && scmResourceGroup == merge' + }); + 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', { @@ -382,26 +411,35 @@ export class GitContribution implements CommandContribution, MenuContribution, T isEnabled: () => !!this.repositoryTracker.selectedRepository }); registry.registerCommand(GIT_COMMANDS.UNSTAGE, { - execute: (arg: string | ScmResource) => { - const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString(); + execute: (arg: string | ScmResource[] | ScmResource) => { + const uris = + typeof arg === 'string' ? [ arg ] : + Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : + [ arg.sourceUri.toString() ]; const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.unstage(uri)); + return provider && this.withProgress(() => provider.unstage(uris)); }, isEnabled: () => !!this.repositoryProvider.selectedScmProvider }); registry.registerCommand(GIT_COMMANDS.STAGE, { - execute: (arg: string | ScmResource) => { - const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString(); + execute: (arg: string | ScmResource[] | ScmResource) => { + const uris = + typeof arg === 'string' ? [ arg ] : + Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : + [ arg.sourceUri.toString() ]; const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.stage(uri)); + return provider && this.withProgress(() => provider.stage(uris)); }, isEnabled: () => !!this.repositoryProvider.selectedScmProvider }); registry.registerCommand(GIT_COMMANDS.DISCARD, { - execute: (arg: string | ScmResource) => { - const uri = typeof arg === 'string' ? arg : arg.sourceUri.toString(); + execute: (arg: string | ScmResource[] | ScmResource) => { + const uris = + typeof arg === 'string' ? [ arg ] : + Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : + [ arg.sourceUri.toString() ]; const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.discard(uri)); + return provider && this.withProgress(() => provider.discard(uris)); }, isEnabled: () => !!this.repositoryProvider.selectedScmProvider }); @@ -409,7 +447,8 @@ export class GitContribution implements CommandContribution, MenuContribution, T execute: (arg: string | ScmResource) => { const uri = typeof arg === 'string' ? new URI(arg) : arg.sourceUri; this.editorManager.open(uri, { mode: 'reveal' }); - } + }, + isVisible: (arg: string | ScmResource, isFolder: boolean) => !isFolder }); registry.registerCommand(GIT_COMMANDS.STASH, { execute: () => this.quickOpenService.stash(), diff --git a/packages/git/src/browser/git-scm-provider.ts b/packages/git/src/browser/git-scm-provider.ts index b0a931ad087cf..b2760a6ea5532 100644 --- a/packages/git/src/browser/git-scm-provider.ts +++ b/packages/git/src/browser/git-scm-provider.ts @@ -255,14 +255,21 @@ export class GitScmProvider implements ScmProvider { this.gitErrorHandler.handleError(error); } } - async stage(uri: string): Promise { + async stage(uriArg: string | string[]): Promise { try { const { repository, unstagedChanges, mergeChanges } = this; - const hasUnstagedChanges = unstagedChanges.some(change => change.uri === uri) || mergeChanges.some(change => change.uri === uri); - if (hasUnstagedChanges) { + const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ]; + const unstagedUris = uris + .filter(uri => { + const resourceUri = new URI(uri); + return unstagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri))) + || mergeChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri))); + } + ); + if (unstagedUris.length !== 0) { // TODO resolve deletion conflicts // TODO confirm staging of a unresolved file - await this.git.add(repository, uri); + await this.git.add(repository, uris); } } catch (error) { this.gitErrorHandler.handleError(error); @@ -278,11 +285,18 @@ export class GitScmProvider implements ScmProvider { this.gitErrorHandler.handleError(error); } } - async unstage(uri: string): Promise { + async unstage(uriArg: string | string[]): Promise { try { const { repository, stagedChanges } = this; - if (stagedChanges.some(change => change.uri === uri)) { - await this.git.unstage(repository, uri); + const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ]; + const stagedUris = uris + .filter(uri => { + const resourceUri = new URI(uri); + return stagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri))); + } + ); + if (stagedUris.length !== 0) { + await this.git.unstage(repository, uris); } } catch (error) { this.gitErrorHandler.handleError(error); @@ -303,31 +317,66 @@ export class GitScmProvider implements ScmProvider { } } } - async discard(uri: string): Promise { + async discard(uriArg: string | string[]): Promise { const { repository } = this; + const uris = Array.isArray(uriArg) ? uriArg : [ uriArg ]; + const status = this.getStatus(); - if (!(status && status.changes.some(change => change.uri === uri))) { + if (!status) { return; } - // Allow deletion, only iff the same file is not yet in the Git index. - if (await this.git.lsFiles(repository, uri, { errorUnmatch: true })) { - if (await this.confirm(uri)) { - try { - await this.git.unstage(repository, uri, { treeish: 'HEAD', reset: 'working-tree' }); - } catch (error) { - this.gitErrorHandler.handleError(error); - } + + const pairs = await Promise.all( + uris + .filter(uri => { + const uriAsUri = new URI(uri); + return status.changes.some(change => uriAsUri.isEqualOrParent(new URI(change.uri))); + }) + .map(uri => { + const includeIndexFlag = async () => { + // Allow deletion, only iff the same file is not yet in the Git index. + const isInIndex = await this.git.lsFiles(repository, uri, { errorUnmatch: true }); + return { uri, isInIndex }; + }; + return includeIndexFlag(); + }) + ); + + const urisInIndex = pairs.filter(pair => pair.isInIndex).map(pair => pair.uri); + if (urisInIndex.length !== 0) { + if (!await this.confirm(urisInIndex)) { + return; } - } else { - await this.commands.executeCommand(WorkspaceCommands.FILE_DELETE.id, new URI(uri)); } + + await Promise.all( + pairs.map(pair => { + const discardSingle = async () => { + if (pair.isInIndex) { + try { + await this.git.unstage(repository, pair.uri, { treeish: 'HEAD', reset: 'working-tree' }); + } catch (error) { + this.gitErrorHandler.handleError(error); + } + } else { + await this.commands.executeCommand(WorkspaceCommands.FILE_DELETE.id, new URI(pair.uri)); + } + }; + return discardSingle(); + }) + ); } - protected confirm(path: string): Promise { - const uri = new URI(path); + protected confirm(paths: string[]): Promise { + let fileText: string; + if (paths.length <= 3) { + fileText = paths.map(path => new URI(path).displayName).join(', '); + } else { + fileText = `${paths.length} files`; + } return new ConfirmDialog({ title: 'Discard changes', - msg: `Do you really want to discard changes in ${uri.displayName}?` + msg: `Do you really want to discard changes in ${fileText}?` }).open(); } 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..31a85a97f3460 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,19 @@ 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/resourceFolder/context') { + for (const menu of allMenus[location]) { + const inline = menu.group && /^inline/.test(menu.group) || false; + const menuPath = inline ? ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU : ScmTreeWidget.RESOURCE_FOLDER_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-component.tsx b/packages/scm/src/browser/scm-amend-component.tsx index df77fa6acc141..ac3d13ddb325c 100644 --- a/packages/scm/src/browser/scm-amend-component.tsx +++ b/packages/scm/src/browser/scm-amend-component.tsx @@ -25,7 +25,6 @@ import { ScmRepository } from './scm-repository'; import { ScmAmendSupport, ScmCommit } from './scm-provider'; export interface ScmAmendComponentProps { - id: string, style: React.CSSProperties | undefined, repository: ScmRepository, scmAmendSupport: ScmAmendSupport, @@ -288,7 +287,7 @@ export class ScmAmendComponent extends React.Component +
{ this.state.amendingCommits.length > 0 || (this.state.lastCommit && this.state.transition.state !== 'none' && this.state.transition.direction === 'down') ? this.renderAmendingCommits() @@ -297,7 +296,7 @@ export class ScmAmendComponent extends React.Component -
+
HEAD Commit
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..e9ea1c16fa47c --- /dev/null +++ b/packages/scm/src/browser/scm-amend-widget.tsx @@ -0,0 +1,86 @@ +/******************************************************************************** + * 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 }, + 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..b636795da9070 --- /dev/null +++ b/packages/scm/src/browser/scm-commit-widget.tsx @@ -0,0 +1,164 @@ +/******************************************************************************** + * 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 } 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-scm-commit'); + this.id = ScmCommitWidget.ID; + } + + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.focus(); + } + + public focus(): void { + (this.inputRef.current || this.node).focus(); + } + + 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 2a4b96eb88be7..7cba496336c3f 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,6 +54,18 @@ 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', + }; } export namespace ScmColors { @@ -60,7 +75,7 @@ export namespace ScmColors { } @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; @@ -68,6 +83,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; @@ -123,6 +139,49 @@ 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 !!this.scmService.selectedRepository + && 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..a0f5ebd31e2fd 100644 --- a/packages/scm/src/browser/scm-frontend-module.ts +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -17,15 +17,21 @@ import '../../src/browser/style/index.css'; import '../../src/browser/style/diff.css'; -import { ContainerModule } from 'inversify'; +import { interfaces, ContainerModule, Container } from 'inversify'; import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, ViewContainer, - WidgetManager, ApplicationShellLayoutMigration + WidgetManager, ApplicationShellLayoutMigration, + createTreeContainer, TreeWidget, TreeModel, TreeModelImpl, 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 { ScmNoRepositoryWidget } from './scm-no-repository-widget'; +import { ScmTreeModel, ScmTreeModelProps } from './scm-tree-model'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { bindDirtyDiff } from './dirty-diff/dirty-diff-module'; import { NavigatorTreeDecorator } from '@theia/navigator/lib/browser'; @@ -34,7 +40,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 +54,34 @@ 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 => { + const child = createFileChangeTreeContainer(ctx.container); + return child.get(ScmTreeWidget); + }); + 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(ScmNoRepositoryWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ScmNoRepositoryWidget.ID, + createWidget: () => container.get(ScmNoRepositoryWidget) + })).inSingletonScope(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: SCM_VIEW_CONTAINER_ID, createWidget: async () => { @@ -66,6 +103,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 +112,29 @@ export default new ContainerModule(bind => { bind(ScmAvatarService).toSelf().inSingletonScope(); bindDirtyDiff(bind); + + bind(ScmTreeLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(ScmTreeLabelProvider); }); + +export function createFileChangeTreeContainer(parent: interfaces.Container): Container { + const child = createTreeContainer(parent); + + child.unbind(TreeWidget); + child.bind(ScmTreeWidget).toSelf(); + + child.unbind(TreeModelImpl); + child.bind(ScmTreeModel).toSelf(); + child.rebind(TreeModel).toService(ScmTreeModel); + + child.rebind(TreeProps).toConstantValue({ + leftPadding: 8, + expansionTogglePadding: 22, + virtualized: true, + search: true, + }); + child.bind(ScmTreeModelProps).toConstantValue({ + defaultExpansion: 'expanded', + }); + return child; +} diff --git a/packages/scm/src/browser/scm-no-repository-widget.tsx b/packages/scm/src/browser/scm-no-repository-widget.tsx new file mode 100644 index 0000000000000..f6f98cea0f59c --- /dev/null +++ b/packages/scm/src/browser/scm-no-repository-widget.tsx @@ -0,0 +1,40 @@ +/******************************************************************************** + * 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 } from 'inversify'; +import * as React from 'react'; +import { ReactWidget } from '@theia/core/lib/browser'; +import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message'; + +@injectable() +export class ScmNoRepositoryWidget extends ReactWidget { + + static ID = 'scm-no-repository-widget'; + + constructor() { + super(); + this.addClass('theia-scm-no-repository'); + this.id = ScmNoRepositoryWidget.ID; + } + + protected render(): React.ReactNode { + return ; + } + +} 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..dbd21e4008b61 --- /dev/null +++ b/packages/scm/src/browser/scm-tree-label-provider.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * 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 { ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; + +@injectable() +export class ScmTreeLabelProvider implements LabelProviderContribution { + + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + + canHandle(element: object): number { + return TreeNode.is(element) && (ScmFileChangeFolderNode.is(element) || ScmFileChangeNode.is(element)) ? 60 : 0; + } + + getName(node: ScmFileChangeFolderNode | ScmFileChangeNode): string { + return this.labelProvider.getName(new URI(node.sourceUri)); + } +} diff --git a/packages/scm/src/browser/scm-tree-model.ts b/packages/scm/src/browser/scm-tree-model.ts new file mode 100644 index 0000000000000..67c54a94d14a6 --- /dev/null +++ b/packages/scm/src/browser/scm-tree-model.ts @@ -0,0 +1,301 @@ +/******************************************************************************** + * 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 { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { TreeModelImpl, TreeNode, TreeProps, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser/tree'; +import URI from '@theia/core/lib/common/uri'; +import { ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider'; +import { ScmRepository } from './scm-repository'; +import { ScmProvider } from './scm-provider'; + +export const ScmTreeModelProps = Symbol('ScmTreeModelProps'); +export interface ScmTreeModelProps { + defaultExpansion?: 'collapsed' | 'expanded'; + nestingThreshold?: number; +} + +export interface ScmFileChangeRootNode extends CompositeTreeNode { + rootUri: string; + children: ScmFileChangeGroupNode[]; +} + +export interface ScmFileChangeGroupNode extends ExpandableTreeNode { + groupId: string; + children: (ScmFileChangeFolderNode | ScmFileChangeNode)[]; +} + +export namespace ScmFileChangeGroupNode { + export function is(node: TreeNode): node is ScmFileChangeGroupNode { + return 'groupId' in node && 'children' in node + && !ScmFileChangeFolderNode.is(node); + } +} + +export interface ScmFileChangeFolderNode extends ExpandableTreeNode, SelectableTreeNode { + groupId: string; + path: string; + sourceUri: string; + children: (ScmFileChangeFolderNode | ScmFileChangeNode)[]; +} + +export namespace ScmFileChangeFolderNode { + export function is(node: TreeNode): node is ScmFileChangeFolderNode { + return 'groupId' in node && 'sourceUri' in node && 'path' in node && 'children' in node; + } +} + +export interface ScmFileChangeNode extends SelectableTreeNode { + sourceUri: string; + decorations?: ScmResourceDecorations; +} + +export namespace ScmFileChangeNode { + export function is(node: TreeNode): node is ScmFileChangeNode { + return 'sourceUri' in node + && !ScmFileChangeFolderNode.is(node); + } +} + +@injectable() +export class ScmTreeModel extends TreeModelImpl { + + private _languageId: string | undefined; + + protected readonly toDisposeOnRepositoryChange = new DisposableCollection(); + + @inject(TreeProps) protected readonly props: ScmTreeModelProps; + + get languageId(): string | undefined { + return this._languageId; + } + + protected _viewMode: 'tree' | 'flat' = 'flat'; + set viewMode(id: 'tree' | 'flat') { + const oldSelection = this.selectedNodes; + this._viewMode = id; + if (this._provider) { + this.root = this.createTree(); + + for (const oldSelectedNode of oldSelection) { + const newNode = this.getNode(oldSelectedNode.id); + if (SelectableTreeNode.is(newNode)) { + this.revealNode(newNode); // this call can run asynchronously + } + } + } + } + get viewMode(): 'tree' | 'flat' { + return this._viewMode; + } + + protected _provider: ScmProvider | undefined; + set repository(repository: ScmRepository | undefined) { + this.toDisposeOnRepositoryChange.dispose(); + if (repository) { + this._provider = repository.provider; + if (this._provider) { + this.toDisposeOnRepositoryChange.push(this._provider.onDidChange(() => { + this.root = this.createTree(); + })); + } + } else { + this._provider = undefined; + } + this.root = this.createTree(); + } + + protected createTree(): ScmFileChangeRootNode | undefined { + if (!this._provider) { + return; + } + const root = { + id: 'file-change-tree-root', + parent: undefined, + visible: false, + rootUri: this._provider.rootUri, + children: [] + } as ScmFileChangeRootNode; + + const { groups } = this._provider; + const groupNodes = groups + .filter(group => !!group.resources.length || !group.hideWhenEmpty) + .map(group => this.toGroupNode(group, root)); + root.children = groupNodes; + + return root; + } + + protected toGroupNode(group: ScmResourceGroup, parent: CompositeTreeNode): ScmFileChangeGroupNode { + const groupNode: ScmFileChangeGroupNode = { + 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: (ScmFileChangeGroupNode | ScmFileChangeFolderNode) + ): (ScmFileChangeFolderNode | ScmFileChangeNode)[] { + const result: (ScmFileChangeFolderNode | ScmFileChangeNode)[] = []; + + 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.sort(this.compareNodes); + } + + protected compareNodes = (a: ScmFileChangeFolderNode | ScmFileChangeNode, b: ScmFileChangeFolderNode | ScmFileChangeNode) => this.doCompareNodes(a, b); + protected doCompareNodes(a: ScmFileChangeFolderNode | ScmFileChangeNode, b: ScmFileChangeFolderNode | ScmFileChangeNode): number { + const isFolderA = ScmFileChangeFolderNode.is(a); + const isFolderB = ScmFileChangeFolderNode.is(b); + if (isFolderA && !isFolderB) { + return -1; + } + if (isFolderB && !isFolderA) { + return 1; + } + return a.sourceUri.localeCompare(b.sourceUri); + } + + protected toFileChangeFolderNode( + resources: { resource: ScmResource, pathParts: string[] }[], + start: number, + end: number, + level: number, + nodeRelativePath: string, + parent: (ScmFileChangeGroupNode | ScmFileChangeFolderNode) + ): ScmFileChangeFolderNode { + const rootUri = this.getRoot(parent).rootUri; + let parentPath: string = rootUri; + if (ScmFileChangeFolderNode.is(parent)) { + parentPath = parent.sourceUri; + } + const sourceUri = new URI(parentPath).resolve(nodeRelativePath); + + const defaultExpansion = this.props.defaultExpansion ? (this.props.defaultExpansion === 'expanded') : true; + const id = `${parent.groupId}:${String(sourceUri)}`; + const oldNode = this.getNode(id); + const folderNode: ScmFileChangeFolderNode = { + id, + groupId: parent.groupId, + path: nodeRelativePath, + sourceUri: String(sourceUri), + children: [], + parent, + expanded: ExpandableTreeNode.is(oldNode) ? oldNode.expanded : defaultExpansion, + selected: SelectableTreeNode.is(oldNode) && oldNode.selected, + }; + folderNode.children = this.buildFileChangeTree(resources, start, end, level, folderNode); + return folderNode; + } + + protected getRoot(node: ScmFileChangeGroupNode | ScmFileChangeFolderNode): ScmFileChangeRootNode { + let parent = node.parent!; + while (ScmFileChangeGroupNode.is(parent) && ScmFileChangeFolderNode.is(parent)) { + parent = parent.parent!; + } + return parent as ScmFileChangeRootNode; + } + + protected toFileChangeNode(resource: ScmResource, parent: CompositeTreeNode): ScmFileChangeNode { + const id = `${resource.group.id}:${String(resource.sourceUri)}`; + const oldNode = this.getNode(id); + const node = { + id, + sourceUri: String(resource.sourceUri), + decorations: resource.decorations, + parent, + selected: SelectableTreeNode.is(oldNode) && oldNode.selected, + }; + if (node.selected) { + this.selectionService.addSelection(node); + } + return node; + } + + protected async revealNode(node: TreeNode): Promise { + if (ScmFileChangeFolderNode.is(node) || ScmFileChangeNode.is(node)) { + const parentNode = node.parent; + if (ExpandableTreeNode.is(parentNode)) { + await this.revealNode(parentNode); + if (!parentNode.expanded) { + await this.expandNode(parentNode); + } + } + } + } + +} 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..ad085212976d8 --- /dev/null +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -0,0 +1,738 @@ +/******************************************************************************** + * 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, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; +import { ScmTreeModel } from './scm-tree-model'; +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'; +import { FileStat } from '@theia/filesystem/lib/common'; +import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service'; +import { ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model'; + +@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_FOLDER_CONTEXT_MENU = ['RESOURCE_FOLDER_CONTEXT_MENU']; + static RESOURCE_FOLDER_INLINE_MENU = ['RESOURCE_FOLDER_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(EditorManager) protected readonly editorManager: EditorManager; + @inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider; + @inject(IconThemeService) protected readonly iconThemeService: IconThemeService; + + constructor( + @inject(TreeProps) readonly props: TreeProps, + @inject(ScmTreeModel) readonly model: ScmTreeModel, + @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(); + })); + } + + set viewMode(id: 'tree' | 'flat') { + this.model.viewMode = id; + } + get viewMode(): 'tree' | 'flat' { + return this.model.viewMode; + } + + protected refreshOnRepositoryChange(): void { + const repository = this.scmService.selectedRepository; + this.model.repository = repository; + if (repository) { + this.contextKeys.scmProvider.set(repository.provider.id); + } else { + this.contextKeys.scmProvider.reset(); + } + } + + /** + * 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 (ScmFileChangeGroupNode.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 (ScmFileChangeFolderNode.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 (ScmFileChangeNode.is(node)) { + const parentNode = node.parent; + if (!(parentNode && (ScmFileChangeFolderNode.is(parentNode) || ScmFileChangeGroupNode.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 && ScmFileChangeFolderNode.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) || !ScmFileChangeFolderNode.is(selectedResource) && !ScmFileChangeNode.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(); + } + + /** + * The ARROW_LEFT key controls both the movement around the file tree and also + * the movement through the change chunks within a file. + * + * If the selected tree node is a folder then the ARROW_LEFT key behaves exactly + * as it does in explorer. It collapses the tree node if the folder is expanded and + * it moves the selection up to the parent folder if the folder is collapsed (no-op if no parent folder, as + * group headers are not selectable). This behavior is the default behavior implemented + * in the TreeWidget super class. + * + * If the selected tree node is a file then the ARROW_LEFT key moves up through the + * change chunks within each file. If the selected chunk is the first chunk in the file + * then the file selection is moved to the previous file (no-op if no previous file). + * + * Note that when cursoring through change chunks, the ARROW_LEFT key cannot be used to + * move up through the parent folders of the file tree. If users want to do this, using + * keys only, then they must press ARROW_UP repeatedly until the selected node is the folder + * node and then press ARROW_LEFT. + */ + protected async handleLeft(event: KeyboardEvent): Promise { + const repository = this.scmService.selectedRepository; + if (!repository) { + return; + } + if (this.model.selectedNodes.length === 1) { + const selectedNode = this.model.selectedNodes[0]; + if (ScmFileChangeNode.is(selectedNode)) { + const selectedResource = this.getResourceFromNode(selectedNode); + if (!selectedResource) { + return super.handleLeft(event); + } + const widget = await this.openResource(selectedResource); + + if (widget) { + const diffNavigator = this.diffNavigatorProvider(widget.editor); + if (diffNavigator.canNavigate() && diffNavigator.hasPrevious()) { + diffNavigator.previous(); + } else { + const previousNode = this.moveToPreviousFileNode(); + if (previousNode) { + const previousResource = this.getResourceFromNode(previousNode); + if (previousResource) { + this.openResource(previousResource); + } + } + } + return; + } + } + } + return super.handleLeft(event); + } + + /** + * The ARROW_RIGHT key controls both the movement around the file tree and also + * the movement through the change chunks within a file. + * + * If the selected tree node is a folder then the ARROW_RIGHT key behaves exactly + * as it does in explorer. It expands the tree node if the folder is collapsed and + * it moves the selection to the first child node if the folder is expanded. + * This behavior is the default behavior implemented + * in the TreeWidget super class. + * + * If the selected tree node is a file then the ARROW_RIGHT key moves down through the + * change chunks within each file. If the selected chunk is the last chunk in the file + * then the file selection is moved to the next file (no-op if no next file). + */ + protected async handleRight(event: KeyboardEvent): Promise { + const repository = this.scmService.selectedRepository; + if (!repository) { + return; + } + if (this.model.selectedNodes.length === 1) { + const selectedNode = this.model.selectedNodes[0]; + if (ScmFileChangeNode.is(selectedNode)) { + const selectedResource = this.getResourceFromNode(selectedNode); + if (!selectedResource) { + return super.handleRight(event); + } + const widget = await this.openResource(selectedResource); + + if (widget) { + const diffNavigator = this.diffNavigatorProvider(widget.editor); + if (diffNavigator.canNavigate() && diffNavigator.hasNext()) { + diffNavigator.next(); + } else { + const nextNode = this.moveToNextFileNode(); + if (nextNode) { + const nextResource = this.getResourceFromNode(nextNode); + if (nextResource) { + this.openResource(nextResource); + } + } + } + } + return; + } + } + return super.handleRight(event); + } + + protected handleEnter(event: KeyboardEvent): void { + if (this.model.selectedNodes.length === 1) { + const selectedNode = this.model.selectedNodes[0]; + if (ScmFileChangeNode.is(selectedNode)) { + const selectedResource = this.getResourceFromNode(selectedNode); + if (selectedResource) { + this.openResource(selectedResource); + } + return; + } + } + super.handleEnter(event); + } + + protected getResourceFromNode(node: ScmFileChangeNode): ScmResource | undefined { + const repository = this.scmService.selectedRepository; + if (!repository) { + return; + } + const parentNode = node.parent; + if (!(parentNode && (ScmFileChangeFolderNode.is(parentNode) || ScmFileChangeGroupNode.is(parentNode)))) { + return; + } + const groupId = parentNode.groupId; + const group = repository.provider.groups.find(g => g.id === groupId)!; + return group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + } + + protected moveToPreviousFileNode(): ScmFileChangeNode | undefined { + let previousNode = this.model.getPrevSelectableNode(); + while (previousNode) { + if (ScmFileChangeNode.is(previousNode)) { + this.model.selectNode(previousNode); + return previousNode; + } + this.model.selectNode(previousNode); + previousNode = this.model.getPrevSelectableNode(); + }; + } + + protected moveToNextFileNode(): ScmFileChangeNode | undefined { + let nextNode = this.model.getNextSelectableNode(); + while (nextNode) { + if (ScmFileChangeNode.is(nextNode)) { + this.model.selectNode(nextNode); + return nextNode; + } + this.model.selectNode(nextNode); + nextNode = this.model.getNextSelectableNode(); + }; + } + + 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 needsExpansionTogglePadding(node: TreeNode): boolean { + const theme = this.iconThemeService.getDefinition(this.iconThemeService.current); + if (theme && (theme.hidesExplorerArrows || (theme.hasFileIcons && !theme.hasFolderIcons))) { + return false; + } + return super.needsExpansionTogglePadding(node); + } + + storeState(): any { + const state: object = { + mode: this.model.viewMode, + tree: super.storeState(), + }; + return state; + } + + restoreState(oldState: any): void { + const { mode, tree } = oldState; + this.model.viewMode = mode === 'tree' ? 'tree' : 'flat'; + super.restoreState(tree); + } + +} + +export namespace ScmTreeWidget { + export namespace Styles { + 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(); + }; + + protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_CONTEXT_MENU; + protected get contextMenuArgs(): any[] { + const selectedResource = this.props.group.resources.find(r => String(r.sourceUri) === this.props.sourceUri)!; + return [selectedResource, false]; // 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'; + 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 sourceFileStat: FileStat = { uri: String(sourceUri), isDirectory: true, lastModification: 0 }; + const icon = labelProvider.getIcon(sourceFileStat); + + return
+ {this.props.renderExpansionToggle()} + +
+ {path} +
+ + +
; + + } + + protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU; + protected get contextMenuArgs(): any[] { + const uris: ScmResource[] = []; + this.collectUris(uris, this.props.node); + return [uris, true]; + } + + protected collectUris(uris: ScmResource[], node: TreeNode): void { + if (ScmFileChangeFolderNode.is(node)) { + for (const child of node.children) { + this.collectUris(uris, child); + } + } else if (ScmFileChangeNode.is(node)) { + const resource = this.props.group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + uris.push(resource); + } + } +} + +export namespace ScmResourceFolderElement { + export interface Props extends ScmElement.Props { + node: ScmFileChangeFolderNode; + 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..282145d66e84b 100644 --- a/packages/scm/src/browser/scm-widget.tsx +++ b/packages/scm/src/browser/scm-widget.tsx @@ -16,83 +16,68 @@ /* 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 { 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'; -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'; + BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, MessageLoop} from '@theia/core/lib/browser'; +import { ScmCommitWidget } from './scm-commit-widget'; +import { ScmAmendWidget } from './scm-amend-widget'; +import { ScmNoRepositoryWidget } from './scm-no-repository-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; + @inject(ScmNoRepositoryWidget) protected readonly noRepositoryWidget: ScmNoRepositoryWidget; - // 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('class', 'theia-scm-panel'); + layout.addWidget(this.panel); + + this.containerLayout.addWidget(this.commitWidget); + this.containerLayout.addWidget(this.resourceWidget); + this.containerLayout.addWidget(this.amendWidget); + this.containerLayout.addWidget(this.noRepositoryWidget); + 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(); @@ -109,17 +94,17 @@ export class ScmWidget extends ReactWidget implements StatefulWidget { // see https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465 this.toDisposeOnRefresh.push(repository.input.onDidChange(() => this.updateImmediately())); this.toDisposeOnRefresh.push(repository.input.onDidFocus(() => this.focusInput())); - } - } - protected onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - (this.inputRef.current || this.node).focus(); - } - - protected onAfterShow(msg: Message): void { - super.onAfterShow(msg); - this.update(); + this.commitWidget.show(); + this.resourceWidget.show(); + this.amendWidget.show(); + this.noRepositoryWidget.hide(); + } else { + this.commitWidget.hide(); + this.resourceWidget.hide(); + this.amendWidget.hide(); + this.noRepositoryWidget.show(); + } } protected updateImmediately(): void { @@ -130,564 +115,44 @@ 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); + MessageLoop.sendMessage(this.noRepositoryWidget, 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); + this.node.appendChild(this.noRepositoryWidget.node); - protected render(): React.ReactNode { - const repository = this.scmService.selectedRepository; - if (!repository) { - return ; - } - const input = repository.input; - const amendSupport = repository.provider.amendSupport; - - return
-
- {this.renderInput(input, repository)} -
- - {amendSupport && } -
; + super.onAfterAttach(msg); + this.update(); } - 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 onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.commitWidget.focus(); } 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; - } - - restoreState(oldState: any): void { - const repository = this.scmService.selectedRepository; - if (repository) { - repository.input.fromJSON(oldState); - } - } - -} - -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); - } + const state: object = { + commitState: this.commitWidget.storeState(), + changesTreeState: this.resourceWidget.storeState(), }; + return state; } - 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} -
-
-
; - } - - 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(); - } - }; - - /** - * 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; - 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); - } + restoreState(oldState: any): void { + const { commitState, changesTreeState } = oldState; + this.commitWidget.restoreState(commitState); + this.resourceWidget.restoreState(changesTreeState); } - 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..9ee9a3d9b3d89 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-scm-commit { + 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; @@ -48,7 +56,7 @@ font-weight: bold; } -.theia-scm .changesContainer { +.theia-scm .theia-scm-amend { margin: 5px 0; } @@ -190,8 +198,6 @@ } .theia-scm .scmItem:hover { - background-color: var(--theia-list-hoverBackground); - color: var(--theia-list-hoverForeground); cursor: pointer; } @@ -233,13 +239,10 @@ } .scm-theia-header { - padding: calc(var(--theia-ui-padding)/2); display: flex; } .scm-theia-header:hover { - background-color: var(--theia-list-hoverBackground); - color: var(--theia-list-hoverForeground); cursor: pointer; } @@ -278,3 +281,7 @@ width: 12px; background: var(--theia-icon-open-file) no-repeat center center; } + +.theia-scm-panel { + overflow: visible; +} diff --git a/packages/search-in-workspace/src/browser/styles/index.css b/packages/search-in-workspace/src/browser/styles/index.css index 469a849fbc0e2..ea3748eabbef0 100644 --- a/packages/search-in-workspace/src/browser/styles/index.css +++ b/packages/search-in-workspace/src/browser/styles/index.css @@ -284,7 +284,7 @@ align-self: center; } -.theia-TreeNode:hover .notification-count-container { +.theia-TreeNode:hover .result-head .notification-count-container { display: none; }