Vscode插件开发-代码段查看器

吴康平
2023-12-01

前言

最近在研究低代码,发现很多的低代码都是表单生成器。自己也照猫画虎地造了一下轮子,因为一开始的方向就是生成代码,并不是通过json再次渲染,结果在网页端生成代码,最后只能生成一个文件,发现了局限性很大。之前也写过通过eletron和nodejs就能通过模板生成多个文件,然后就想到了代码段,其实自己写过的代码也是一个知识库, 很多代码其实都可以复用,例如通用表单,图片上传等。但在vscode里是看不到自己配置了那些代码段,所以我明明装了很多的代码段扩展,却因为不知道里面的代码段导致每次都是看官方文档再复制,于是产生了写一个插件能够看到vscode里面所配置的代码段并且可以点击使用。

功能需求

  • 能显示扩展,vscode用户自定义,外部自定义的代码段列表
  • 鼠标悬停能查看代码段内容
  • 点击列表项能插入代码段

项目搭建

从命令行安装Yeoman和VSCode扩展生成器

npm install -g yo generator-code

在命令行中输入如下命令来启动生成器

yo code

配置选择

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WgjO3G6Q-1661322420979)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9726f61977ad4f10ac9933551427cd37~tplv-k3u1fbpfcp-zoom-1.image)]

视图开发

因为要展示的是类似目录的视图,可以使用树视图,也可以使用webview来实现。最后选择树视图,因为想着就放在侧边栏使用就好,比较方便。
Tree View文档:https://code.visualstudio.com/api/extension-guides/tree-view

package.json配置

"contributes": {
   // 图标栏
    "viewsContainers": {
      "activitybar": [
        {
          "id": "snippet-viwer", // id对应视图id
          "title": "代码段查看器",
          "icon": "img/left_icon.svg"
        }
      ]
    },
   // 视图栏
    "views": {
      "snippet-viwer": [
        {
          "id": "plugin-view",
          "name": "扩展目录"
        }
      ]
    }
  },

第二步是向你注册的视图提供数据,以便 VSCode 可以在视图中显示数据。

树节点类

主要设置节点的图标,label名称等

export class TreeItemNode extends TreeItem {
  constructor(
    // readonly 只可读
    public readonly label: string,
    public readonly icon: string,
    public readonly body: string,
    public readonly children: TreeChild[] | string,
    public collapsibleState: TreeItemCollapsibleState
  ) {
    super(label, collapsibleState);
    this.iconPath = Uri.file(join(__filename, "..", "..", icon));
    // command: 为每项添加点击事件的命令
    if (this.collapsibleState === TreeItemCollapsibleState.None) {
      this.command = {
        title: this.label, // 标题
        command: "itemClick", // 命令 ID
        tooltip: this.body, // 鼠标覆盖时的小小提示框
        arguments: [
          // 向 registerCommand 传递的参数。
          this.body,
        ],
      };
    }
  }

  tooltip = this.body;
}

TreeDataProvider实现类

主要通过实现getChildren方法来返回每一级的列表数据

export class TreeViewProvider implements TreeDataProvider<TreeItemNode> {
  private _onDidChangeTreeData: EventEmitter<
    TreeItemNode | undefined | null | void
  > = new EventEmitter<TreeItemNode | undefined | null | void>();
  private treeList: Tree[] = [];
  private context: ExtensionContext;
  private customDisposableList: Disposable[] = [];

  constructor(context: ExtensionContext) {
    this.context = context;
    this.initList();
  }

  /**
   * 初始化列表
   */
  initList() {
    this.treeList = [];
  }

  onDidChangeTreeData?:
    | import("vscode").Event<TreeItemNode | undefined | null | void> =
    this._onDidChangeTreeData.event;

  getTreeItem(element: TreeItemNode): TreeItem | Thenable<TreeItem> {
    console.log("获取节点", element.label);
    return element;
  }

  getChildren(
    element?: TreeItemNode | undefined
  ): ProviderResult<TreeItemNode[]> {
    // 返回树子节点类列表
  }

  public static initTreeViewItem(context: ExtensionContext) {
    // 实例化 TreeViewProvider
    const treeViewProvider = new TreeViewProvider(context);
    // registerTreeDataProvider:注册树视图
    window.registerTreeDataProvider("plugin-view", treeViewProvider);
    return treeViewProvider;
  }
}

代码段数据获取

获取扩展的代码段数据

extensions文档:https://code.visualstudio.com/api/references/vscode-api#extensions

通过extensions.all便可以得到所有的扩展数据,数据结构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UK6ndA1v-1661322420981)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f93e70ca8009442cacf56c3c420d0a55~tplv-k3u1fbpfcp-zoom-1.image)]
通过其数据结构分析,extensionPath为扩展的目录,packageJSON->contributes->snippets为代码段的数据,包含代码段生效的编程语言和代码段文件的路径,这样就可以获取所有的代码段文件并将其载入就行

    // 加载扩展代码段
    let extensionsList = extensions.all;
    extensionsList = extensionsList.filter(
      (item) => !!item?.packageJSON?.contributes?.snippets
    );
    extensionsList.forEach((item) => {
      this.treeList.push({
        name: item?.packageJSON?.name,
        icon: "img/folder_type_plugin.svg",
        children: item.packageJSON.contributes.snippets.map(
          (snippetItem: SnippetManifest) => ({
            name: snippetItem.language,
            icon: "img/folder_type_src.svg",
            children: path.join(item?.extensionPath || "", snippetItem.path),
          })
        ),
      });
    });

获取外部自定义的代码段数据

反复查文档也找不到能够获取设置的用户自定义代码段的获取方法,但由于用户自定义的代码段都会被存放在一个文件夹下,可以通过配置路径的方法来实现。后来出现了把自定义的代码段作为一个项目的想法,这样就可以实时的同步代码段等,感觉这样子管理也很好。就只提供配置自定义代码段文件夹的方式来实现自定义代码段的导入。

this.treeList = [];
    for (const disposable of this.customDisposableList) {
      disposable.dispose();
    }
    this.customDisposableList = [];
    // 加载自定义代码段
    const customConfig = workspace.getConfiguration("SnippetViewer");
    if (customConfig.customUrl) {
      try {
        delete require.cache[join(customConfig.customUrl, "config.js")];
        const customConfigList: Tree[] = JSON5.parse(
          fs.readFileSync(
            path.join(customConfig.customUrl, "config.json"),
            "utf8"
          )
        );
      } catch (error) {
        console.error("自定义配置错误");
        window.showErrorMessage("自定义代码段配置错误");
      }
    }

代码段的目录结构

snippets                          
├─ custom    
│  └─ vue.json
│  └─ javacript.json                 
└─ config.json               

config.json 数据结构

[
  {
    "name": "custom",
    "children": [
      {
        "name": "vue",
        "children": "custom/vue.json"
      }
    ]
  }
]

一级name表示名称,二级name表示语言

获取用户自定义的代码段数据

因为在开发过程中也在不断查看学习其他插件的代码,然后在Settings Sync发现新大陆,在他的environmentPath.ts文件里面有个USER_FOLDER的常量,他就是用户的数据存放的目录路径,而用户自定义代码段就是存放在其下面的snippets文件夹下,这样,用户自定义代码段数据也能解决了。

constructor(context: vscode.ExtensionContext) {
  this.isPortable = !!process.env.VSCODE_PORTABLE;
  if (!this.isPortable) {
    this.path = resolve(context.globalStorageUri.fsPath, "../../..").concat(
      normalize("/")
    );
    this.userFolder = resolve(this.path, "User").concat(normalize("/"));
  } else {
    this.userFolder = resolve(this.path, "user-data/User").concat(
      normalize("/")
    );
  }
  this.snippetsFolder = this.userFolder.concat("/snippets/");
}

// 加载用户自定义代码段
    try {
      this.treeList.push({
        name: "user-snippets",
        icon: "vscode",
        body: "user-snippets",
        isOutCustomRoot: false,
        expression: `${this.treeList.length}`,
        disabled: false,
        children: fs
          .readdirSync(this.environment.snippetsFolder)
          .map((fileName, fileIndex) => {
            return {
              name: fileName.substring(0, fileName.lastIndexOf(".")),
              icon: "src",
              isOutCustomRoot: false,
              disabled: false,
              expression: `${this.treeList.length}.${fileIndex}`,
              children: join(this.environment.snippetsFolder, fileName),
            };
          }),
      });
    } catch (error) {}

数据显示

更新TreeViewProvidergetChildren 逻辑

getChildren(
    element?: TreeItemNode | undefined
  ): ProviderResult<TreeItemNode[]> {
    if (element) {
      if (Array.isArray(element.children)) {
        return element.children.map((item) => {
          return new TreeItemNode(
            item.name,
            item.icon,
            item.name,
            item.children,
            TreeItemCollapsibleState.Collapsed as TreeItemCollapsibleState
          );
        });
      } else {
        let resultArr: string[] = [];
        let json: { [key: string]: SnippetJSON } = {};
        try {
          json = JSON5.parse(
            fs.readFileSync(path.join(element.children), "utf8")
          );
          resultArr = Object.keys(json);
        } catch (error) {
          console.log(error);
          window.showErrorMessage("代码段文件错误");
        }
        return resultArr.map(
          (key) =>
            new TreeItemNode(
              key,
              "img/code.svg",
              Array.isArray(json[key].body)
                ? (json[key].body as string[]).join("\n")
                : (json[key].body as string),
              "",
              TreeItemCollapsibleState.None as TreeItemCollapsibleState
            )
        );
      }
    } else {
      // 不包含elment, 根节点
      return this.treeList.map((item) => {
        return new TreeItemNode(
          item.name,
          item.icon,
          item.name,
          item.children,
          TreeItemCollapsibleState.Collapsed as TreeItemCollapsibleState
        );
      });
    }
  }

踩坑:

格式化json数据的时候发现有些代码段的json文件格式有错误,一开始发现的是最后有逗号,我用Prettier格式化一下就好了,所以一开始使用Prettier来进行格式化,发现还是报错,我又打开一个代码段json查看,里面竟然有注释,那时我想,json还能这样写的吗,但是vscode读取的时候也是可以读取的,那他肯定是可以读取了,然后查了一会才发现有json5这个东西,是json的一个超集,可以使用逗号结尾和注释等,json5传送门,最后通过json5来格式化json,就没问题了。

外部自定义代码段增加代码补全

扩展的代码段vscode是会自动加载的,我们自己通过配置的代码段目前只能通过侧边栏来点击使用,希望能够像扩展一样同时使用代码补全功能,主要通过languages.registerCompletionItemProvider来实现。

/**
   * 增加自定义代码段
   * @param {string} language
   * @param {object} json
   */
  addCustomSnippets(
    language: string,
    json: { [key: string]: SnippetJSON }
  ): void {
    const disposable = languages.registerCompletionItemProvider(
      { scheme: "file", language },
      {
        provideCompletionItems() {
          return Object.keys(json).map((key) => {
            const snippetCompletion = new CompletionItem(
              {
                label: json[key].prefix,
                description: key,
                detail: "(custom)",
              },
              CompletionItemKind.Snippet
            );
            snippetCompletion.insertText = new SnippetString(
              Array.isArray(json[key].body)
                ? (json[key].body as string[]).join("\n")
                : (json[key].body as string)
            );
            snippetCompletion.detail = key;
            snippetCompletion.documentation =
              new MarkdownString().appendCodeblock(
                snippetCompletion.insertText.value
              );
            return snippetCompletion;
          });
        },
      }
    );
    this.customDisposableList.push(disposable);
    this.context.subscriptions.push(disposable);
  }

最后通过customDisposableList来存储所有的注册,刷新列表时注销之前的注册再重新注册。

增加刷新按钮

更新扩展或者修改自定义代码段时,能够通过刷新按钮刷新可视化列表。

package.json

 "contributes": {
    "commands": [
      {
        "command": "snippets-viewer.refresh",
        "title": "Refresh List",
        "icon": "$(refresh)"
      }
    ],
    "menus": {
      "view/title": [
        {
          "command": "snippets-viewer.refresh",
          "when": "view == plugin-view",
          "group": "navigation"
        }
      ]
    }
}

TreeViewProvider

主要通过_onDidChangeTreeData来实现刷新

/**
   * 刷新列表
   */
  refresh(): void {
    this.initList();
    this._onDidChangeTreeData.fire();
  }

踩坑

一开始自定义的config.json是使用config.js的,通过require的方式引入,require引入之后会有缓存,需要每次重新加载都要清除之前的缓存,嫌麻烦就直接全改成json了。

最后注册代码

export function activate(context: vscode.ExtensionContext) {
  // Use the console to output diagnostic information (console.log) and errors (console.error)
  // This line of code will only be executed once when your extension is activated
  console.log(
    'Congratulations, your extension "snippets viewer" is now active!'
  );

  // 实现树视图的初始化
  const treeViewProvider = TreeViewProvider.initTreeViewItem(context);

  // The command has been defined in the package.json file
  // Now provide the implementation of the command with registerCommand
  // The commandId parameter must match the command field in package.json
  let itemClickDisposable = vscode.commands.registerCommand(
    "itemClick",
    (body) => {
      const editor = vscode.window.activeTextEditor;
      if (editor) {
        editor.insertSnippet(new vscode.SnippetString(body));
      }
    }
  );

  let refreshDisposable = vscode.commands.registerCommand(
    "snippets-viewer.refresh",
    () => treeViewProvider.refresh()
  );

  context.subscriptions.push(itemClickDisposable, refreshDisposable);
}

// this method is called when your extension is deactivated
export function deactivate() {}

图标能够实现展开折叠样式

一开始实现图标是在vscode里面扣了几个出来,但是发现他是一成不变的,然后也想着能够通过修改element的状态来实现,但是他并不会调用getTreeItemgetChildren。也看了leetcode的代码,主要里面有文件夹展开的,但是还是搞不懂他是怎么做到的。最后终于被我搞明白了T_T,在TreeItem里面有个resourceUri属性

/**
 * The {@link Uri} of the resource representing this item.
 *
 * Will be used to derive the {@link TreeItem.label label}, when it is not provided.
 * Will be used to derive the icon from current file icon theme, when {@link TreeItem.iconPath iconPath} has {@link ThemeIcon} value.
 */
resourceUri?: Uri;

就是在不设置icon的情况下,可以通过设置resourceUri来展示系统的图标,如果有主题就会使用主题。resourceUri的类型是Uri,其中能够影响的是path,例如path = ‘/src’,他就会显示src的图标,并且在节点被展开折叠时会自动显示展开折叠样式。

最后

上面的代码仅仅是开发的过程思路,最终的代码也发生了改变。后面也根据issue实现了一下禁用外部自定义代码段和根据语言来分组的功能,感兴趣的话可以自行阅读源码。

代码和插件已发布,搜索vscode-snippets-viewer就能搜到,喜欢的话给个star吧
github:https://github.com/shilim-developer/snippets-viewer
vscode market:https://marketplace.visualstudio.com/items?itemName=shilim.vscode-snippets-viewer

 类似资料: