scm: folder context actions

This commit is contained in:
Joao Moreno 2019-09-18 11:58:44 +02:00
parent bcd7bcedff
commit ced9fdfa4f
5 changed files with 143 additions and 121 deletions

View file

@ -55,7 +55,7 @@
"command.syncRebase": "Sync (Rebase)",
"command.publish": "Publish Branch",
"command.showOutput": "Show Git Output",
"command.ignore": "Add File to .gitignore",
"command.ignore": "Add to .gitignore",
"command.stashIncludeUntracked": "Stash (Include Untracked)",
"command.stash": "Stash",
"command.stashPop": "Pop Stash...",

View file

@ -6,57 +6,61 @@
import { memoize } from 'vs/base/common/decorators';
import * as paths from 'vs/base/common/path';
import { Iterator } from 'vs/base/common/iterator';
import { relativePath } from 'vs/base/common/resources';
import { relativePath, joinPath } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
export interface ILeafNode<T> {
readonly path: string;
export interface ILeafNode<T, C = void> {
readonly uri: URI;
readonly relativePath: string;
readonly name: string;
readonly element: T;
readonly context: C;
}
export interface IBranchNode<T> {
readonly path: string;
export interface IBranchNode<T, C = void> {
readonly uri: URI;
readonly relativePath: string;
readonly name: string;
readonly size: number;
readonly children: Iterator<INode<T>>;
readonly parent: IBranchNode<T> | undefined;
get(childName: string): INode<T> | undefined;
readonly children: Iterator<INode<T, C>>;
readonly parent: IBranchNode<T, C> | undefined;
readonly context: C;
get(childName: string): INode<T, C> | undefined;
}
export type INode<T> = IBranchNode<T> | ILeafNode<T>;
export type INode<T, C> = IBranchNode<T, C> | ILeafNode<T, C>;
// Internals
class Node {
class Node<C> {
@memoize
get name(): string { return paths.posix.basename(this.path); }
get name(): string { return paths.posix.basename(this.relativePath); }
constructor(readonly path: string) { }
constructor(readonly uri: URI, readonly relativePath: string, readonly context: C) { }
}
class BranchNode<T> extends Node implements IBranchNode<T> {
class BranchNode<T, C> extends Node<C> implements IBranchNode<T, C> {
private _children = new Map<string, BranchNode<T> | LeafNode<T>>();
private _children = new Map<string, BranchNode<T, C> | LeafNode<T, C>>();
get size(): number {
return this._children.size;
}
get children(): Iterator<BranchNode<T> | LeafNode<T>> {
get children(): Iterator<BranchNode<T, C> | LeafNode<T, C>> {
return Iterator.fromIterableIterator(this._children.values());
}
constructor(path: string, readonly parent: IBranchNode<T> | undefined = undefined) {
super(path);
constructor(uri: URI, relativePath: string, context: C, readonly parent: IBranchNode<T, C> | undefined = undefined) {
super(uri, relativePath, context);
}
get(path: string): BranchNode<T> | LeafNode<T> | undefined {
get(path: string): BranchNode<T, C> | LeafNode<T, C> | undefined {
return this._children.get(path);
}
set(path: string, child: BranchNode<T> | LeafNode<T>): void {
set(path: string, child: BranchNode<T, C> | LeafNode<T, C>): void {
this._children.set(path, child);
}
@ -65,22 +69,32 @@ class BranchNode<T> extends Node implements IBranchNode<T> {
}
}
class LeafNode<T> extends Node implements ILeafNode<T> {
class LeafNode<T, C> extends Node<C> implements ILeafNode<T, C> {
constructor(path: string, readonly element: T) {
super(path);
constructor(uri: URI, path: string, context: C, readonly element: T) {
super(uri, path, context);
}
}
export class ResourceTree<T extends NonNullable<any>> {
function collect<T, C>(node: INode<T, C>, result: T[]): T[] {
if (ResourceTree.isBranchNode(node)) {
Iterator.forEach(node.children, child => collect(child, result));
} else {
result.push(node.element);
}
readonly root = new BranchNode<T>('');
return result;
}
static isBranchNode<T>(obj: any): obj is IBranchNode<T> {
export class ResourceTree<T extends NonNullable<any>, C> {
readonly root: BranchNode<T, C>;
static isBranchNode<T, C>(obj: any): obj is IBranchNode<T, C> {
return obj instanceof BranchNode;
}
static getRoot<T>(node: IBranchNode<T>): IBranchNode<T> {
static getRoot<T, C>(node: IBranchNode<T, C>): IBranchNode<T, C> {
while (node.parent) {
node = node.parent;
}
@ -88,13 +102,19 @@ export class ResourceTree<T extends NonNullable<any>> {
return node;
}
constructor(private rootURI: URI = URI.file('/')) { }
static collect<T, C>(node: INode<T, C>): T[] {
return collect(node, []);
}
constructor(context: C, rootURI: URI = URI.file('/')) {
this.root = new BranchNode(rootURI, '', context);
}
add(uri: URI, element: T): void {
const key = relativePath(this.rootURI, uri) || uri.fsPath;
const key = relativePath(this.root.uri, uri) || uri.fsPath;
const parts = key.split(/[\\\/]/).filter(p => !!p);
let node = this.root;
let path = this.root.path;
let path = '';
for (let i = 0; i < parts.length; i++) {
const name = parts[i];
@ -104,10 +124,10 @@ export class ResourceTree<T extends NonNullable<any>> {
if (!child) {
if (i < parts.length - 1) {
child = new BranchNode(path, node);
child = new BranchNode(joinPath(this.root.uri, path), path, this.root.context, node);
node.set(name, child);
} else {
child = new LeafNode(path, element);
child = new LeafNode(uri, path, this.root.context, element);
node.set(name, child);
return;
}
@ -119,7 +139,7 @@ export class ResourceTree<T extends NonNullable<any>> {
}
// replace
node.set(name, new LeafNode(path, element));
node.set(name, new LeafNode(uri, path, this.root.context, element));
return;
} else if (i === parts.length - 1) {
throw new Error('Inconsistent tree: can\'t override branch with leaf.');
@ -130,12 +150,12 @@ export class ResourceTree<T extends NonNullable<any>> {
}
delete(uri: URI): T | undefined {
const key = relativePath(this.rootURI, uri) || uri.fsPath;
const key = relativePath(this.root.uri, uri) || uri.fsPath;
const parts = key.split(/[\\\/]/).filter(p => !!p);
return this._delete(this.root, parts, 0);
}
private _delete(node: BranchNode<T>, parts: string[], index: number): T | undefined {
private _delete(node: BranchNode<T, C>, parts: string[], index: number): T | undefined {
const name = parts[index];
const child = node.get(name);

View file

@ -9,24 +9,24 @@ import { URI } from 'vs/base/common/uri';
suite('ResourceTree', function () {
test('ctor', function () {
const tree = new ResourceTree<string>();
const tree = new ResourceTree<string, null>(null);
assert(ResourceTree.isBranchNode(tree.root));
assert.equal(tree.root.size, 0);
});
test('simple', function () {
const tree = new ResourceTree<string>();
const tree = new ResourceTree<string, null>(null);
tree.add(URI.file('/foo/bar.txt'), 'bar contents');
assert(ResourceTree.isBranchNode(tree.root));
assert.equal(tree.root.size, 1);
let foo = tree.root.get('foo') as IBranchNode<string>;
let foo = tree.root.get('foo') as IBranchNode<string, null>;
assert(foo);
assert(ResourceTree.isBranchNode(foo));
assert.equal(foo.size, 1);
let bar = foo.get('bar.txt') as ILeafNode<string>;
let bar = foo.get('bar.txt') as ILeafNode<string, null>;
assert(bar);
assert(!ResourceTree.isBranchNode(bar));
assert.equal(bar.element, 'bar contents');
@ -34,14 +34,14 @@ suite('ResourceTree', function () {
tree.add(URI.file('/hello.txt'), 'hello contents');
assert.equal(tree.root.size, 2);
let hello = tree.root.get('hello.txt') as ILeafNode<string>;
let hello = tree.root.get('hello.txt') as ILeafNode<string, null>;
assert(hello);
assert(!ResourceTree.isBranchNode(hello));
assert.equal(hello.element, 'hello contents');
tree.delete(URI.file('/foo/bar.txt'));
assert.equal(tree.root.size, 1);
hello = tree.root.get('hello.txt') as ILeafNode<string>;
hello = tree.root.get('hello.txt') as ILeafNode<string, null>;
assert(hello);
assert(!ResourceTree.isBranchNode(hello));
assert.equal(hello.element, 'hello contents');

View file

@ -91,6 +91,7 @@
.scm-viewlet .monaco-list-row .resource-group {
display: flex;
height: 100%;
align-items: center;
}
.scm-viewlet .monaco-list-row .resource-group > .name {
@ -99,6 +100,7 @@
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: underline;
}
.scm-viewlet .monaco-list-row .resource {
@ -125,6 +127,7 @@
.scm-viewlet .monaco-list-row .resource-group > .count {
padding: 0 8px;
display: flex;
}
.scm-viewlet .monaco-list-row .resource > .decoration-icon {

View file

@ -48,14 +48,14 @@ import { FuzzyScore, createMatches } from 'vs/base/common/filters';
import { IViewDescriptor } from 'vs/workbench/common/views';
import { localize } from 'vs/nls';
type TreeElement = ISCMResourceGroup | IBranchNode<ISCMResource> | ISCMResource;
type TreeElement = ISCMResourceGroup | IBranchNode<ISCMResource, ISCMResourceGroup> | ISCMResource;
interface ResourceGroupTemplate {
name: HTMLElement;
count: CountBadge;
actionBar: ActionBar;
readonly name: HTMLElement;
readonly count: CountBadge;
readonly actionBar: ActionBar;
elementDisposables: IDisposable;
disposables: IDisposable;
readonly disposables: IDisposable;
}
class ResourceGroupRenderer implements ICompressibleTreeRenderer<ISCMResourceGroup, FuzzyScore, ResourceGroupTemplate> {
@ -130,23 +130,36 @@ class MultipleSelectionActionRunner extends ActionRunner {
super();
}
runAction(action: IAction, context: ISCMResource): Promise<any> {
if (action instanceof MenuItemAction) {
const selection = this.getSelectedResources();
const filteredSelection = selection.filter(s => s !== context);
if (selection.length === filteredSelection.length || selection.length === 1) {
return action.run(context);
}
return action.run(context, ...filteredSelection);
runAction(action: IAction, context: ISCMResource | IBranchNode<ISCMResource, ISCMResourceGroup>): Promise<any> {
if (!(action instanceof MenuItemAction)) {
return super.runAction(action, context);
}
return super.runAction(action, context);
// TODO
// const resources = ResourceTree.isBranchNode(context)
// ? ResourceTree.collect(context)
// : [context];
const selection = this.getSelectedResources();
if (ResourceTree.isBranchNode(context)) {
const selection = this.getSelectedResources();
const resources = ResourceTree.collect(context);
return action.run(...resources, ...selection);
}
const filteredSelection = selection.filter(s => s !== context);
if (selection.length === filteredSelection.length || selection.length === 1) {
return action.run(context);
}
return action.run(context, ...filteredSelection);
}
}
class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IBranchNode<ISCMResource>, FuzzyScore, ResourceTemplate> {
class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IBranchNode<ISCMResource, ISCMResourceGroup>, FuzzyScore, ResourceTemplate> {
static TEMPLATE_ID = 'resource';
get templateId(): string { return ResourceRenderer.TEMPLATE_ID; }
@ -176,16 +189,16 @@ class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IBran
return { element, name, fileLabel, decorationIcon, actionBar, elementDisposables: Disposable.None, disposables };
}
renderElement(node: ITreeNode<ISCMResource, FuzzyScore> | ITreeNode<IBranchNode<ISCMResource>, FuzzyScore>, index: number, template: ResourceTemplate): void {
renderElement(node: ITreeNode<ISCMResource, FuzzyScore> | ITreeNode<IBranchNode<ISCMResource, ISCMResourceGroup>, FuzzyScore>, index: number, template: ResourceTemplate): void {
template.elementDisposables.dispose();
const elementDisposables = new DisposableStore();
const resource = node.element;
const resourceOrFolder = node.element;
const theme = this.themeService.getTheme();
const icon = ResourceTree.isBranchNode(resource) ? undefined : (theme.type === LIGHT ? resource.decorations.icon : resource.decorations.iconDark);
const icon = !ResourceTree.isBranchNode(resourceOrFolder) && (theme.type === LIGHT ? resourceOrFolder.decorations.icon : resourceOrFolder.decorations.iconDark);
const uri = ResourceTree.isBranchNode(resource) ? URI.file(resource.path) : resource.sourceUri;
const fileKind = ResourceTree.isBranchNode(resource) ? FileKind.FOLDER : FileKind.FILE;
const uri = ResourceTree.isBranchNode(resourceOrFolder) ? resourceOrFolder.uri : resourceOrFolder.sourceUri;
const fileKind = ResourceTree.isBranchNode(resourceOrFolder) ? FileKind.FOLDER : FileKind.FILE;
const viewModel = this.viewModelProvider();
template.fileLabel.setFile(uri, {
@ -196,21 +209,19 @@ class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IBran
});
template.actionBar.clear();
template.actionBar.context = resource;
template.actionBar.context = resourceOrFolder;
if (ResourceTree.isBranchNode(resource)) {
const group = viewModel.getResourceGroupOf(resource);
if (group) {
elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(group), template.actionBar));
}
if (ResourceTree.isBranchNode(resourceOrFolder)) {
elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(resourceOrFolder.context), template.actionBar));
removeClass(template.name, 'strike-through');
removeClass(template.element, 'faded');
} else {
elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceMenu(resource.resourceGroup), template.actionBar));
toggleClass(template.name, 'strike-through', resource.decorations.strikeThrough);
toggleClass(template.element, 'faded', resource.decorations.faded);
elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceMenu(resourceOrFolder.resourceGroup), template.actionBar));
toggleClass(template.name, 'strike-through', resourceOrFolder.decorations.strikeThrough);
toggleClass(template.element, 'faded', resourceOrFolder.decorations.faded);
}
const tooltip = (ResourceTree.isBranchNode(resource) ? resource.path : resource.decorations.tooltip) || '';
const tooltip = !ResourceTree.isBranchNode(resourceOrFolder) && resourceOrFolder.decorations.tooltip || '';
if (icon) {
template.decorationIcon.style.display = '';
@ -219,47 +230,48 @@ class ResourceRenderer implements ICompressibleTreeRenderer<ISCMResource | IBran
} else {
template.decorationIcon.style.display = 'none';
template.decorationIcon.style.backgroundImage = '';
template.decorationIcon.title = '';
}
template.element.setAttribute('data-tooltip', tooltip);
template.elementDisposables = elementDisposables;
}
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<ISCMResource> | ICompressedTreeNode<IBranchNode<ISCMResource>>, FuzzyScore>, index: number, template: ResourceTemplate, height: number | undefined): void {
disposeElement(resource: ITreeNode<ISCMResource, FuzzyScore> | ITreeNode<IBranchNode<ISCMResource, ISCMResourceGroup>, FuzzyScore>, index: number, template: ResourceTemplate): void {
template.elementDisposables.dispose();
}
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<ISCMResource> | ICompressedTreeNode<IBranchNode<ISCMResource, ISCMResourceGroup>>, FuzzyScore>, index: number, template: ResourceTemplate, height: number | undefined): void {
template.elementDisposables.dispose();
const disposables = new DisposableStore();
const compressed = node.element as ICompressedTreeNode<IBranchNode<ISCMResource>>;
const branchNode = compressed.elements[compressed.elements.length - 1];
const elementDisposables = new DisposableStore();
const compressed = node.element as ICompressedTreeNode<IBranchNode<ISCMResource, ISCMResourceGroup>>;
const folder = compressed.elements[compressed.elements.length - 1];
const label = compressed.elements.map(e => e.name).join('/');
const uri = URI.file(branchNode.path);
const fileKind = FileKind.FOLDER;
template.fileLabel.setResource({ resource: uri, name: label }, {
template.fileLabel.setResource({ resource: folder.uri, name: label }, {
fileDecorations: { colors: false, badges: true },
fileKind,
matches: createMatches(node.filterData)
});
template.actionBar.clear();
template.actionBar.context = 'what'; // TODO
template.actionBar.context = folder;
const viewModel = this.viewModelProvider();
const group = viewModel.getResourceGroupOf(branchNode);
if (group) {
disposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(group), template.actionBar));
}
elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(folder.context), template.actionBar));
removeClass(template.name, 'strike-through');
removeClass(template.element, 'faded');
template.decorationIcon.style.display = 'none';
template.decorationIcon.style.backgroundImage = '';
template.element.setAttribute('data-tooltip', branchNode.path);
template.elementDisposables = disposables;
template.element.setAttribute('data-tooltip', '');
template.elementDisposables = elementDisposables;
}
disposeElement(resource: ITreeNode<ISCMResource, FuzzyScore> | ITreeNode<IBranchNode<ISCMResource>, FuzzyScore>, index: number, template: ResourceTemplate): void {
disposeCompressedElements(node: ITreeNode<ICompressedTreeNode<ISCMResource> | ICompressedTreeNode<IBranchNode<ISCMResource, ISCMResourceGroup>>, FuzzyScore>, index: number, template: ResourceTemplate, height: number | undefined): void {
template.elementDisposables.dispose();
}
@ -331,25 +343,27 @@ export class SCMTreeKeyboardNavigationLabelProvider implements IKeyboardNavigati
}
}
const scmResourceIdentityProvider = new class implements IIdentityProvider<TreeElement> {
getId(e: TreeElement): string {
if (ResourceTree.isBranchNode(e)) {
return e.path;
} else if (isSCMResource(e)) {
const group = e.resourceGroup;
class SCMResourceIdentityProvider implements IIdentityProvider<TreeElement> {
getId(element: TreeElement): string {
if (ResourceTree.isBranchNode(element)) {
const group = element.context;
return `${group.provider.contextValue}/${group.id}/$FOLDER/${element.uri.toString()}`;
} else if (isSCMResource(element)) {
const group = element.resourceGroup;
const provider = group.provider;
return `${provider.contextValue}/${group.id}/${e.sourceUri.toString()}`;
return `${provider.contextValue}/${group.id}/${element.sourceUri.toString()}`;
} else {
const provider = e.provider;
return `${provider.contextValue}/${e.id}`;
const provider = element.provider;
return `${provider.contextValue}/${element.id}`;
}
}
};
}
interface IGroupItem {
readonly group: ISCMResourceGroup;
readonly resources: ISCMResource[];
readonly tree: ResourceTree<ISCMResource>;
readonly tree: ResourceTree<ISCMResource, ISCMResourceGroup>;
readonly disposable: IDisposable;
}
@ -361,7 +375,7 @@ function groupItemAsTreeElement(item: IGroupItem, mode: ViewModelMode): ICompres
return { element: item.group, children, incompressible: true };
}
function asTreeElement(node: INode<ISCMResource>, incompressible: boolean): ICompressedTreeElement<TreeElement> {
function asTreeElement(node: INode<ISCMResource, ISCMResourceGroup>, incompressible: boolean): ICompressedTreeElement<TreeElement> {
if (ResourceTree.isBranchNode(node)) {
return {
element: node,
@ -406,7 +420,7 @@ class ViewModel {
const itemsToInsert: IGroupItem[] = [];
for (const group of toInsert) {
const tree = new ResourceTree<ISCMResource>(group.provider.rootUri || URI.file('/'));
const tree = new ResourceTree<ISCMResource, ISCMResourceGroup>(group, group.provider.rootUri || URI.file('/'));
const resources: ISCMResource[] = [...group.elements];
const disposable = combinedDisposable(
group.onDidChange(() => this.tree.refilter()),
@ -464,18 +478,6 @@ class ViewModel {
}
}
getResourceGroupOf(node: IBranchNode<ISCMResource>): ISCMResourceGroup | undefined {
const root = ResourceTree.getRoot(node);
for (const item of this.items) {
if (item.tree.root === root) {
return item.group;
}
}
return undefined;
}
dispose(): void {
this.visibilityDisposables.dispose();
this.disposables.dispose();
@ -656,6 +658,7 @@ export class RepositoryPanel extends ViewletPanel {
const filter = new SCMTreeFilter();
const sorter = new SCMTreeSorter();
const keyboardNavigationLabelProvider = new SCMTreeKeyboardNavigationLabelProvider();
const identityProvider = new SCMResourceIdentityProvider();
this.tree = this.instantiationService.createInstance(
WorkbenchCompressibleObjectTree,
@ -664,7 +667,7 @@ export class RepositoryPanel extends ViewletPanel {
delegate,
renderers,
{
identityProvider: scmResourceIdentityProvider,
identityProvider,
horizontalScrolling: false,
filter,
sorter,
@ -786,11 +789,7 @@ export class RepositoryPanel extends ViewletPanel {
let actions: IAction[] = [];
if (ResourceTree.isBranchNode(element)) {
const group = this.viewModel.getResourceGroupOf(element);
if (group) {
actions = this.menus.getResourceFolderContextActions(group);
}
actions = this.menus.getResourceFolderContextActions(element.context);
} else if (isSCMResource(element)) {
actions = this.menus.getResourceContextActions(element);
} else {