diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5bb5c533449..958bc3f061195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.3.0 +- [scm] added support for multi-select in the Source Control view [#7900](https://github.com/eclipse-theia/theia/pull/7900) + Breaking Changes: - [task] Widened the scope of some methods in TaskManager and TaskConfigurations from string to TaskConfigurationScope. This is only breaking for extenders, not callers. [#7928](https://github.com/eclipse-theia/theia/pull/7928) diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts index fd861ee4255a4..34c9955a178d8 100644 --- a/packages/git/src/browser/git-contribution.ts +++ b/packages/git/src/browser/git-contribution.ts @@ -411,47 +411,38 @@ export class GitContribution implements CommandContribution, MenuContribution, T isEnabled: () => !!this.repositoryTracker.selectedRepository }); registry.registerCommand(GIT_COMMANDS.UNSTAGE, { - execute: (arg: string | ScmResource[] | ScmResource) => { - const uris = - typeof arg === 'string' ? [ arg ] : - Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : - [ arg.sourceUri.toString() ]; + execute: (...arg: ScmResource[]) => { + const resources = arg.filter(r => r.sourceUri).map(r => r.sourceUri.toString()); const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.unstage(uris)); + return provider && this.withProgress(() => provider.unstage(resources)); }, - isEnabled: (arg: string | ScmResource[] | ScmResource) => !!this.repositoryProvider.selectedScmProvider - && (!Array.isArray(arg) || arg.length !== 0) + isEnabled: (...arg: ScmResource[]) => !!this.repositoryProvider.selectedScmProvider + && arg.some(r => r.sourceUri) }); registry.registerCommand(GIT_COMMANDS.STAGE, { - execute: (arg: string | ScmResource[] | ScmResource) => { - const uris = - typeof arg === 'string' ? [ arg ] : - Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : - [ arg.sourceUri.toString() ]; + execute: (...arg: ScmResource[]) => { + const resources = arg.filter(r => r.sourceUri).map(r => r.sourceUri.toString()); const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.stage(uris)); + return provider && this.withProgress(() => provider.stage(resources)); }, - isEnabled: (arg: string | ScmResource[] | ScmResource) => !!this.repositoryProvider.selectedScmProvider - && (!Array.isArray(arg) || arg.length !== 0) + isEnabled: (...arg: ScmResource[]) => !!this.repositoryProvider.selectedScmProvider + && arg.some(r => r.sourceUri) }); registry.registerCommand(GIT_COMMANDS.DISCARD, { - execute: (arg: string | ScmResource[] | ScmResource) => { - const uris = - typeof arg === 'string' ? [ arg ] : - Array.isArray(arg) ? arg.map(r => r.sourceUri.toString()) : - [ arg.sourceUri.toString() ]; + execute: (...arg: ScmResource[]) => { + const resources = arg.filter(r => r.sourceUri).map(r => r.sourceUri.toString()); const provider = this.repositoryProvider.selectedScmProvider; - return provider && this.withProgress(() => provider.discard(uris)); + return provider && this.withProgress(() => provider.discard(resources)); }, - isEnabled: (arg: string | ScmResource[] | ScmResource) => !!this.repositoryProvider.selectedScmProvider - && (!Array.isArray(arg) || arg.length !== 0) + isEnabled: (...arg: ScmResource[]) => !!this.repositoryProvider.selectedScmProvider + && arg.some(r => r.sourceUri) }); registry.registerCommand(GIT_COMMANDS.OPEN_CHANGED_FILE, { - 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 + execute: (...arg: ScmResource[]) => { + for (const resource of arg) { + this.editorManager.open(resource.sourceUri, { mode: 'reveal' }); + } + } }); registry.registerCommand(GIT_COMMANDS.STASH, { execute: () => this.quickOpenService.stash(), @@ -862,6 +853,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T } }); } + } export interface GitOpenFileOptions { readonly uri: URI diff --git a/packages/scm/src/browser/scm-frontend-module.ts b/packages/scm/src/browser/scm-frontend-module.ts index abcefc3e237a0..c498d2f1a6f95 100644 --- a/packages/scm/src/browser/scm-frontend-module.ts +++ b/packages/scm/src/browser/scm-frontend-module.ts @@ -123,7 +123,8 @@ export default new ContainerModule(bind => { export function createScmTreeContainer(parent: interfaces.Container): Container { const child = createTreeContainer(parent, { virtualized: true, - search: true + search: true, + multiSelect: true, }); child.unbind(TreeWidget); diff --git a/packages/scm/src/browser/scm-tree-model.ts b/packages/scm/src/browser/scm-tree-model.ts index 7eae7e789a127..1301a0b88de25 100644 --- a/packages/scm/src/browser/scm-tree-model.ts +++ b/packages/scm/src/browser/scm-tree-model.ts @@ -69,6 +69,14 @@ export namespace ScmFileChangeNode { return 'sourceUri' in node && !ScmFileChangeFolderNode.is(node); } + export function getGroupId(node: ScmFileChangeNode): string { + const parentNode = node.parent; + if (!(parentNode && (ScmFileChangeFolderNode.is(parentNode) || ScmFileChangeGroupNode.is(parentNode)))) { + throw new Error('bad node'); + } + return parentNode.groupId; + } + } @injectable() diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx index b6d1b893c1c48..991145c9e1558 100644 --- a/packages/scm/src/browser/scm-tree-widget.tsx +++ b/packages/scm/src/browser/scm-tree-widget.tsx @@ -19,9 +19,10 @@ import * as React from 'react'; import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; +import { isOSX } from '@theia/core/lib/common/os'; import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; import { Message } from '@phosphor/messaging'; -import { TreeWidget, TreeNode, TreeProps, NodeProps, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree'; +import { TreeWidget, TreeNode, SelectableTreeNode, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, 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'; @@ -136,9 +137,10 @@ export class ScmTreeWidget extends TreeWidget { groupId={node.groupId} path={node.path} node={node} - sourceUri={new URI(node.sourceUri)} + sourceUri={node.sourceUri} renderExpansionToggle={() => this.renderExpansionToggle(node, props)} contextMenuRenderer={this.contextMenuRenderer} + model={this.model} commands={this.commands} menus={this.menus} contextKeys={this.contextKeys} @@ -148,11 +150,7 @@ export class ScmTreeWidget extends TreeWidget { 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 groupId = ScmFileChangeNode.getGroupId(node); const name = this.labelProvider.getName(new URI(node.sourceUri)); const parentPath = (node.parent && ScmFileChangeFolderNode.is(node.parent)) @@ -162,6 +160,7 @@ export class ScmTreeWidget extends TreeWidget { key={node.sourceUri} repository={repository} contextMenuRenderer={this.contextMenuRenderer} + model={this.model} commands={this.commands} menus={this.menus} contextKeys={this.contextKeys} @@ -185,19 +184,8 @@ export class ScmTreeWidget extends TreeWidget { 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 }; } @@ -324,11 +312,7 @@ export class ScmTreeWidget extends TreeWidget { if (!repository) { return; } - const parentNode = node.parent; - if (!(parentNode && (ScmFileChangeFolderNode.is(parentNode) || ScmFileChangeGroupNode.is(parentNode)))) { - return; - } - const groupId = parentNode.groupId; + const groupId = ScmFileChangeNode.getGroupId(node); const group = repository.provider.groups.find(g => g.id === groupId)!; return group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; } @@ -474,6 +458,41 @@ export abstract class ScmElement

} }; + protected getSelectionArgs(selectedNodes: Readonly): any[] { + const resources: ScmResource[] = []; + for (const node of selectedNodes) { + if (ScmFileChangeNode.is(node)) { + const groupId = ScmFileChangeNode.getGroupId(node); + const group = this.findGroup(this.props.repository, groupId); + if (group) { + const selectedResource = group.resources.find(r => String(r.sourceUri) === node.sourceUri); + if (selectedResource) { + resources.push(selectedResource); + } + } + } + if (ScmFileChangeFolderNode.is(node)) { + const group = this.findGroup(this.props.repository, node.groupId); + if (group) { + this.collectResources(resources, node, group); + } + } + } + // Remove duplicates which may occur if user selected folder and nested folder + return resources.filter((item1, index) => resources.findIndex(item2 => item1.sourceUri === item2.sourceUri) === index); + } + + protected collectResources(resources: ScmResource[], node: TreeNode, group: ScmResourceGroup): void { + if (ScmFileChangeFolderNode.is(node)) { + for (const child of node.children) { + this.collectResources(resources, child, group); + } + } else if (ScmFileChangeNode.is(node)) { + const resource = group.resources.find(r => String(r.sourceUri) === node.sourceUri)!; + resources.push(resource); + } + } + /* * Normally the group would always be expected to be found. However if the tree is restored * in restoreState then the tree may be rendered before the groups have been created @@ -515,7 +534,7 @@ export class ScmResourceComponent extends ScmElement const relativePath = parentPath.relative(resourceUri.parent); const path = relativePath ? relativePath.toString() : labelProvider.getLongName(resourceUri.parent); return

protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_CONTEXT_MENU; protected get contextMenuArgs(): any[] { + if (!this.props.model.selectedNodes.some(node => ScmFileChangeNode.is(node) && node.sourceUri === this.props.sourceUri)) { + // Clicked node is not in selection, so ignore selection and action on just clicked node + return this.singleNodeArgs; + } else { + return this.getSelectionArgs(this.props.model.selectedNodes); + } + } + protected get singleNodeArgs(): any[] { const group = this.findGroup(this.props.repository, this.props.groupId); if (group) { const selectedResource = group.resources.find(r => String(r.sourceUri) === this.props.sourceUri)!; - return [selectedResource, false]; // TODO support multiselection + return [selectedResource]; } else { // Repository status not yet available. Empty args disables the action. return []; } } + protected hasCtrlCmdOrShiftMask(event: TreeWidget.ModifierAwareEvent): boolean { + const { metaKey, ctrlKey, shiftKey } = event; + return (isOSX && metaKey) || ctrlKey || shiftKey; + } + /** * 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(); + protected handleClick = (event: React.MouseEvent) => { + if (!this.hasCtrlCmdOrShiftMask(event)) { + // Determine the behavior based on the preference value. + const isSingle = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'singleClick'; + if (isSingle) { + this.open(); + } } }; @@ -592,6 +626,7 @@ export namespace ScmResourceComponent { parentPath: URI; sourceUri: string; decorations?: ScmResourceDecorations; + model: ScmTreeModel; } } @@ -649,11 +684,11 @@ export class ScmResourceFolderElement extends ScmElement ScmFileChangeFolderNode.is(node) && node.sourceUri === this.props.sourceUri)) { + // Clicked node is not in selection, so ignore selection and action on just clicked node + return this.singleNodeArgs; + } else { + return this.getSelectionArgs(this.props.model.selectedNodes); + } + } + protected get singleNodeArgs(): any[] { + const resources: ScmResource[] = []; const group = this.findGroup(this.props.repository, this.props.groupId); if (group) { - this.collectUris(uris, this.props.node, group); + this.collectResources(resources, this.props.node, group); } - return [uris, true]; + return resources; } - protected collectUris(uris: ScmResource[], node: TreeNode, group: ScmResourceGroup): void { - if (ScmFileChangeFolderNode.is(node)) { - for (const child of node.children) { - this.collectUris(uris, child, group); - } - } else if (ScmFileChangeNode.is(node)) { - const resource = 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; + sourceUri: string; path: string; + model: ScmTreeModel; } }