VS Code插件开发教程(7) 树视图 Tree View

11,122 阅读5分钟

Tree View API允许插件在sidebar中渲染内容,这些内容以树的形状来展示

Tree View API基础

我们通过一个示例来介绍Tree View API相关用法,这个示例利用树视图来展示当前文件夹中所有的Node.js依赖。你可以在 tree-view-sample 查阅此示例的完整代码

配置package.json

首先你要通过 contributes.viewsVS Code知道你要“贡献出”一个视图,下面是package.json的一个初步配置:

{
    "name": "helloworld",
    "displayName": "HelloWorld",
    "description": "",
    "version": "0.0.1",
    "engines": {
        "vscode": "^1.56.0"
    },
    "categories": [
        "Other"
    ],
    "activationEvents": ["onView:nodeDependencies"],
    "main": "./extension.js",
    "contributes": {
        "views": {
            "explorer": [{
                "id": "nodeDependencies",
                "name": "Node Dependencies"
            }]
        }
    },
    "scripts": {
        "lint": "eslint .",
        "pretest": "npm run lint",
        "test": "node ./test/runTest.js"
    },
    "devDependencies": {
        "@types/vscode": "^1.56.0",
        "@types/glob": "^7.1.3",
        "@types/mocha": "^8.0.4",
        "@types/node": "14.x",
        "eslint": "^7.19.0",
        "glob": "^7.1.6",
        "mocha": "^8.2.1",
        "typescript": "^4.1.3",
        "vscode-test": "^1.5.0"
    }
}

仅当用户需要时再去激活插件是十分重要的,例如在本文的示例中,我们可以让插件在用户使用插件视图的时候再去激活。VS Code提供了 onView:${viewId} 事件来告知程序当前用户打开的视图,我们可以在package.json注册一个激活事件"activationEvents": ["onView:nodeDependencies"]

生成数据

第二步是利用 TreeDataProvider 生成树视图所需的Node.js依赖的数据,其中需要实现两个方法:

  • getChildren(element?: T): ProviderResult<T[]>:返回指定节点(如果没有指定就是根节点)的子节点
  • getTreeItem(element: T): TreeItem | Thenable<TreeItem>:返回用于在视图里展示的UI节点

每当用户打开树视图,getChildren会被自动调用(没有参数),你可以在这里返回树视图的第一层级内容。在示例中,我们用TreeItemCollapsibleState.Collapsed(折叠)、TreeItemCollapsibleState.Expanded(展开)、TreeItemCollapsibleState.None(无子节点,不会触发getChildren方法)控制节点的折叠状态,下面是一个TreeDataProvider的实现示例:

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';

export class NodeDependenciesProvider implements vscode.TreeDataProvider<Dependency> {
    constructor(private workspaceRoot: string) { }

    getTreeItem(element: Dependency): vscode.TreeItem {
        return element;
    }

    getChildren(element?: Dependency): Thenable<Dependency[]> {
        if (!this.workspaceRoot) {
            vscode.window.showInformationMessage('No dependency in empty workspace');
            return Promise.resolve([]);
        }

        if (element) {
            return Promise.resolve(
                this.getDepsInPackageJson(
                    path.join(this.workspaceRoot, 'node_modules', element.label, 'package.json')
                )
            );
        } else {
            const packageJsonPath = path.join(this.workspaceRoot, 'package.json');
            if (this.pathExists(packageJsonPath)) {
                return Promise.resolve(this.getDepsInPackageJson(packageJsonPath));
            } else {
                vscode.window.showInformationMessage('Workspace has no package.json');
                return Promise.resolve([]);
            }
        }
    }

    /**
     * Given the path to package.json, read all its dependencies
     */
    private getDepsInPackageJson(packageJsonPath: string): Dependency[] {
        if (this.pathExists(packageJsonPath)) {
            const toDep = (moduleName: string, version: string): Dependency => {
                const depPackageJsonPath = path.join(this.workspaceRoot, 'node_modules', moduleName, 'package.json');
                let collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;
                if (this.pathExists(depPackageJsonPath)) {
                    const depPackageJson = JSON.parse(fs.readFileSync(depPackageJsonPath, 'utf-8'));
                    // 如果依赖的代码包已经安装(node_modules有内容),且这个安装包本身有dependencies或devDependencies,才设置为可展开的
                    if ((!depPackageJson.dependencies || Object.keys(depPackageJson.dependencies).length === 0) &&
                        (!depPackageJson.devDependencies || Object.keys(depPackageJson.devDependencies).length === 0)) {
                        collapsibleState = vscode.TreeItemCollapsibleState.None;
                    }
                }
                return new Dependency(moduleName, version, collapsibleState);
            };
            const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
            const deps = packageJson.dependencies
                ? Object.keys(packageJson.dependencies).map(dep =>
                    toDep(dep, packageJson.dependencies[dep])
                )
                : [];
            const devDeps = packageJson.devDependencies
                ? Object.keys(packageJson.devDependencies).map(dep =>
                    toDep(dep, packageJson.devDependencies[dep])
                )
                : [];
            return deps.concat(devDeps);
        } else {
            return [];
        }
    }

    private pathExists(p: string): boolean {
        try {
            fs.accessSync(p);
        } catch (err) {
            return false;
        }
        return true;
    }
}

class Dependency extends vscode.TreeItem {
    constructor(
        public readonly label: string,
        private version: string,
        public readonly collapsibleState: vscode.TreeItemCollapsibleState
    ) {
        super(label, collapsibleState);
        this.tooltip = `${this.label}-${this.version}`;
        this.description = this.version;
    }

    iconPath = {
        light: path.join(__filename, '..', '..', 'resources', 'light', 'dependency.svg'),
        dark: path.join(__filename, '..', '..', 'resources', 'dark', 'dependency.svg')
    };
}

注册TreeDataProvider

第三步是将生成的依赖数据提供给视图,可以通过两种方式实现:

  • vscode.window.registerTreeDataProvider:注册树数据的provider,需要提供视图ID和数据provider对象

    vscode.window.registerTreeDataProvider(
        'nodeDependencies',
        new NodeDependenciesProvider(vscode.workspace.rootPath)
    );
    
  • vscode.window.createTreeView:通过视图ID和数据provider来创建视树视图,这会提供访问 树视图 的能力,如果你需要使用TreeView API,可以使用createTreeView的方式

    vscode.window.createTreeView('nodeDependencies', {
        treeDataProvider: new NodeDependenciesProvider(vscode.workspace.rootPath)
    });
    

至此一个具备基本目标功能的插件就已经完成,可以看到实际效果如下:

上述代码的完整示例参见 tree-view-test v1

更新视图内容

以命令行方式

目前完成的这个插件仅具备最基本的功能,数依赖数据一经展示便无法更新。如果在视图中有一个刷新按钮将会是非常方便的,为了实现这个目标,我们需要利用 onDidChangeTreeData 事件:

  • onDidChangeTreeData?: Event<T | undefined | null | void>:当依赖数据变更并且你希望更新树视图的时候执行

provider中添加如下代码:

    private _onDidChangeTreeData: vscode.EventEmitter<Dependency | undefined | null | void> = new vscode.EventEmitter<Dependency | undefined | null | void>();
    readonly onDidChangeTreeData: vscode.Event<Dependency | undefined | null | void> = this._onDidChangeTreeData.event;
    refresh(): void {
        this._onDidChangeTreeData.fire();
    }

此时我们有了更新函数,但没有调用它,我们可以在package.json中定义一条更新命令:

    "commands": [
            {
                "command": "nodeDependencies.refreshEntry",
                "title": "Refresh Dependence",
                "icon": {
                    "light": "resources/light/refresh.svg",
                    "dark": "resources/dark/refresh.svg"
                }
            }
    ]

然后注册该命令:

  vscode.commands.registerCommand('nodeDependencies.refreshEntry', () =>
      nodeDependenciesProvider.refresh()
  );

此时我们会看到,当执行了Refresh Dependence命令后,Node.js依赖的树视图会被更新:

以按钮方式

在前文的基础上,如果在视图中添加一个按钮或许操作的时候有会更加直观、友好。我们在package.json中添加:

"menus": {
    "view/title": [
        {
            "command": "nodeDependencies.refreshEntry",
            "when": "view == nodeDependencies",
            "group": "navigation"
        },
    ]
}

此时当我们将鼠标浮在视图上时就会看到刷新按钮,点击效果同执行Refresh Dependence命令:

group属性用于菜单项的排序和分类,其中值为navigationgroup是用来将置顶的,如果不设置,则刷新按钮将会被隐藏在“...”里,效果如下所示:

上述代码的完整示例参见 tree-view-test v2

添加到视图容器(View Container)

创建视图容器

视图容器包含了一系列展示在Activity BarPanel中的视图,如果希望自己的插件自定义一个视图容器,我们可以用 contributes.viewsContainers package.json中注册:

    "contributes": {
        "viewsContainers": {
            "activitybar": [{
                "id": "package-explorer",
                "title": "Package Explorer",
                "icon": "media/dep.svg"
            }]
        }
    }

或者你也可以在panel字段下做配置

    "contributes": {
        "viewsContainers": {
           "panel": [{
                "id": "package-explorer",
                "title": "Package Explorer",
                "icon": "media/dep.svg"
            }]
        }
    }

将视图和视图容器绑定

我们可以在package.json中用 contributes.views 来实现

    "contributes": {
        "views": {
            "package-explorer": [{
                "id": "nodeDependencies",
                "name": "Node Dependencies",
                "icon": "media/dep.svg",
                "contextualTitle": "Package Explorer"
            }]
        }
    }

需要注意的是,一个视图可以设置visibility属性,该属性有三个取值:visiblecollapsedhidden,这三个值仅在首次打开工作台的时候起作用,之后其取值取决于用户的控制。如果你的视图容器里有很多的视图,则可以利用该属性让你的界面更加简洁

现在我们可以看到左侧的视图容器和树视图了:

上述代码的完整示例参见 tree-view-test v3

视图行为解读

视图的行为附着在视图的内联图标上,这些图标可以在树视图中的每一个节点上、还可以在树视图顶端的标题栏上,我们可以在package.json中对其进行配置:

  • view/title:位置在视图标题栏上,可以用"group": "navigation"来保证其优先级
  • view/item/context:位置在树节点上,可以用"group": "inline"让其内联显示

上述均可用 when clause 控制其生效条件

如果我们想实现上图的效果,可以用如下代码实现:

{
    "contributes": {
        "commands": [{
                "command": "nodeDependencies.refreshEntry",
                "title": "Refresh",
                "icon": {
                    "light": "resources/light/refresh.svg",
                    "dark": "resources/dark/refresh.svg"
                }
            },
            {
                "command": "nodeDependencies.addEntry",
                "title": "Add"
            },
            {
                "command": "nodeDependencies.editEntry",
                "title": "Edit",
                "icon": {
                    "light": "resources/light/edit.svg",
                    "dark": "resources/dark/edit.svg"
                }
            },
            {
                "command": "nodeDependencies.deleteEntry",
                "title": "Delete"
            }
        ],
        "menus": {
            "view/title": [{
                    "command": "nodeDependencies.refreshEntry",
                    "when": "view == nodeDependencies",
                    "group": "navigation"
                },
                {
                    "command": "nodeDependencies.addEntry",
                    "when": "view == nodeDependencies"
                }
            ],
            "view/item/context": [{
                    "command": "nodeDependencies.editEntry",
                    "when": "view == nodeDependencies && viewItem == dependency",
                    "group": "inline"
                },
                {
                    "command": "nodeDependencies.deleteEntry",
                    "when": "view == nodeDependencies && viewItem == dependency"
                }
            ]
        }
    }
}

我们可以在when字段中使用 TreeItem.contextValue 的数据,来控制相应行为的显示

上述代码的完整示例参见 tree-view-test v4

视图欢迎内容

我们可以添加一个欢迎内容,以便当视图内容初始化或为空的时候显示:

    "contributes": {
        "viewsWelcome": [{
            "view": "nodeDependencies",
            "contents": "没有发现依赖内容, [了解更多](https://www.npmjs.com/).\n[添加依赖](command:nodeDependencies.addEntry)"
        }]

contributes.viewsWelcome.contents支持链接,如果链接单起一行,会被渲染为按钮。每个viewsWelcome支持 when clause

上述代码的完整示例参见 tree-view-test v5

相关文章