从零实现一个 VS Code TODO 插件

本文将介绍如何实现一个 VS Code 插件,通过树形结构可视化展示项目中的所有 TODO 注释,并支持按文件夹分组、快速跳转等功能。

功能包括:

  • 在 VSCode 活动栏中,新增一个图标
  • 点击图标后,会在侧边栏中显示一个名为 "TODO LIST" 的面板
  • 这个面板中的内容以树形结构展示的,包括:
    • 文件夹(显示文件夹图标)
    • 文件(显示文件图标)
    • TODO 项(显示复选框图标)
      • 可展开/折叠文件夹
      • 点击 TODO 项跳转到对应的代码位置

先贴效果:

基本知识

先简单介绍之后会出现的类。

TreeDataProvider

TreeDataProvider 用于实现树形结构的数据展示。

  1. 数据结构定义:

    • 定义树的节点结构
    • 处理节点之间的父子关系
  2. 核心方法:

    • getChildren : 获取指定节点的子节点
    • getTreeItem : 获取节点的显示信息(图标、标签等)
    • onDidChangeTreeData : 数据变化时通知 VSCode 刷新视图

TreeItem

TreeItem 是 VS Code 扩展开发中一个重要的基础类,用于构建树形视图(TreeView)中的节点。

它的主要作用包括:

  1. 定义节点显示:

    • 设置节点的标签(label)
    • 配置节点的图标(iconPath)
    • 设置节点的描述信息(description)
    • 控制节点的展开/折叠状态(collapsibleState)
  2. 节点交互:

    • 定义点击行为(command)
    • 设置上下文菜单(contextValue)
  3. 节点状态:

    • 控制节点是否可见(visible)
    • 设置节点是否可选择(selected)
    • 管理节点是否可展开(collapsible)

开始

下面针对这些功能点一个个来实现:

  • 配置活动栏
  • 打开文件
  • 实现树形视图

省略创建插件项目,直接快进到配置。

配置

package.json 中添加如下配置,新增一个activity bar项。

$(tasklist) 是 VS Code 内置的图标之一,它显示为一个任务列表的图标。

可以在这个网址查看所有可用的内置图标: microsoft.github.io/vscode-codi...

配置完后,启动插件调试。

发现在活动栏最下方多出了一个任务列表图标, 点击后打开视图(此时内容还没实现)。

注册打开文件命令

实现打开文件功能。

typescript 复制代码
/**
 * 
 * @param filePath 文件相对路径
 * @param line 行数
 */
export const openFile = async (filePath: string, line: number) => {
    const workspaceFolders = vscode.workspace.workspaceFolders;
    if (!workspaceFolders) {
        return;
    }
    
    // `workspaceFolders[0].uri`  获取当前工作区(项目根目录)的路径
    const absolutePath = vscode.Uri.joinPath(workspaceFolders[0].uri, filePath);
    
    try {
        // 加载文件内容到 VS Code 的文档对象中
        const document = await vscode.workspace.openTextDocument(absolutePath);
        // 在 VS Code 编辑器中打开这个文档
        const editor = await vscode.window.showTextDocument(document); 

        const position = new vscode.Position(line - 1, 0);
        // 设置编辑器的光标位置
        editor.selection = new vscode.Selection(position, position);
        // 自动滚动到该行
        editor.revealRange(new vscode.Range(position, position));
    }
    catch(e) {
        console.log(e);
        vscode.window.showErrorMessage('文件打开失败,请刷新重试');
    }
};

注册一个openFile命令

typescript 复制代码
export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(vscode.commands.registerCommand('todolist.openFile', openFile));
}

实现TodoTreeItem类

这个类是整个 TODO 插件的视图层核心,负责每个树节点的显示和交互。

typescript 复制代码
export class TodoTreeItem extends vscode.TreeItem {
    constructor(
        label: string,                    // 显示的文本
        public readonly collapsibleState: vscode.TreeItemCollapsibleState,  // 可折叠状态
        public readonly type: "folder" | "file" | "todo",  // 项目类型
        public readonly todo?: TodoComment,  // TODO 注释信息(可选)
        public readonly filePath?: string,   // 文件路径(可选)
    )
}

设置文件夹和文件的图标

typescript 复制代码
  if (type === "folder") {
      this.iconPath = new vscode.ThemeIcon("folder");
      this.resourceUri = vscode.Uri.joinPath(
        workspaceFolder.uri,
        fullPath || label
      );
    } else if (type === "file") {
      this.iconPath = new vscode.ThemeIcon("file");
      this.resourceUri = vscode.Uri.joinPath(
        workspaceFolder.uri,
        fullPath || label
      );
    }

TODO 项处理:

typescript 复制代码
      // 设置 TODO 项的图标
     this.iconPath = new vscode.ThemeIcon(
        "circle-large-outline",
        new vscode.ThemeColor("foreground")
      );
      // 点击 TODO 项时跳转到对应行
      this.command = {
        command: "todolist.openFile",
        title: "Open File",
        arguments: [todo?.fileName, todo?.lineNumber],
      };
    }

每一项的效果如下:

当然,现在还不能运行出上图的结果,因为只是定义每一类节点,还没使用该类。

实现TodoProvider类

通过树形视图展示项目中的所有 TODO 注释。

typescript 复制代码
export class TodoProvider implements vscode.TreeDataProvider<TodoTreeItem> {
   ......
   constructor() {
        this.fileWatcher = vscode.workspace.createFileSystemWatcher(
          "**/*.{ts,js,tsx,jsx,vue,java,py,go,cpp,c,h,hpp}"
        );

        this.fileWatcher.onDidCreate(this.onFileChange.bind(this));
        this.fileWatcher.onDidChange(this.onFileChange.bind(this));
        this.fileWatcher.onDidDelete(this.onFileDelete.bind(this));

        this.initializeStructure().then(() => {
          console.log("初始化完成");
          console.log("文件结构大小:", this.fileStructure.size);
          console.log("TODO文件数量:", this.hasTodoFiles.size);
          this.refresh();
        });
  }
  
  ......
  
}
  1. 创建一个文件系统监听器,用于监听文件的变化
  2. 监听文件创建、文件修改、文件删除
  3. 扫描和初始化项目文件结构

initializeStructure 方法,负责初始化整个 TODO 列表的文件结构

typescript 复制代码
private async initializeStructure() {
    console.log("开始初始化...");
    
    // ......
    
    // 搜索工作区中所有支持的文件类型
    const files = await vscode.workspace.findFiles(
      "**/*.{ts,js,tsx,jsx,vue,java,py,go,cpp,c,h,hpp}",
      `{${ignorePatterns.join(",")}}`
    );
   

    // 并行处理所有文件
    const promises = files.map(async (file) => {
      try {
        const document = await vscode.workspace.openTextDocument(file);
        const text = document.getText();
        const relativePath = vscode.workspace.asRelativePath(file);

        // 构建文件结构
        const parts = relativePath.split("/");
        if (parts.length > 1) {
          const root = parts[0];
          if (!this.fileStructure.has(root)) {
            this.fileStructure.set(root, new Set());
          }
          this.fileStructure.get(root)?.add(parts[1]);
        }

        // 检查并缓存 TODO
        if (text.includes("TODO") && TodoProvider.TODO_REGEX.test(text)) {
          this.hasTodoFiles.add(relativePath);
          // 预加载文件内容到缓存
          await this.loadFileContent(relativePath);
        }
      } catch (error) {
        console.error("处理文件失败:", vscode.workspace.asRelativePath(file));
      }
    });

    await Promise.all(promises);
  }

对于每个文件,会执行如下工作:

  • 读取文件内容
  • 构建两层目录结构(根目录 -> 子目录/文件)
  • 检查是否包含 TODO 注释
  • 如果有 TODO,预加载并缓存文件内容

loadFileContent方法,用来加载文件内容,主要负责加载和解析文件中的 TODO 注释。

  1. 通过文件修改时间来判断缓存是否有效:
typescript 复制代码
    const uri = vscode.Uri.joinPath(workspaceFolder.uri, filePath);
    const stat = await vscode.workspace.fs.stat(uri);
    const cachedData = this.fileCache.get(filePath);

    if (cachedData && cachedData.mtime === stat.mtime) {
      return cachedData.todos;
    }
  1. 读取文件、按行分割
typescript 复制代码
    const document = await vscode.workspace.openTextDocument(uri);
    const text = document.getText();
    const lines = text.split("\n");
    const todos: TodoComment[] = [];
  1. 提取文件内todo注释:
typescript 复制代码
    lines.forEach((line, index) => {
      const match = line.match(TodoProvider.TODO_REGEX);
      if (match) {
        todos.push({
          label: line.substring(line.indexOf("TODO") + 4).trim(), // 注释内容(去掉 "TODO" 前缀)
          fileName: filePath,
          lineNumber: index + 1, // 行号
          originalLine: line, // 行内容 
        });
      }
    });
  1. 更新缓存:
typescript 复制代码
    this.fileCache.set(filePath, { mtime: stat.mtime, todos });

getTreeItem

这是 继承 TreeDataProvider 后必须实现的方法,用于渲染树视图中的每个节点

kotlin 复制代码
 getTreeItem(element: TodoTreeItem): vscode.TreeItem {
      return element;
  }
  

getChildren同样是继承 TreeDataProvider 后必须实现的方法,负责构建树的层级结构。

根据不同情况返回子节点: (每次展开树节点时,VS Code 都会自动调用这个方法来获取子节点)

  • 当 element 为 undefined 时:
    • 需要获取根节点
    • 调用 getRootItems() 获取顶层目录和文件
  • 当 element.type 为 folder 时:
    • 表示需要获取文件夹的内容
    • 调用 getFolderItems() 获取该文件夹下的文件和子文件夹
  • 当 element.type 为 file 时:
    • 表示需要获取文件中的 TODO 项
    • 调用 getFileItems() 获取该文件中的所有 TODO 注释
typescript 复制代码
  async getChildren(element?: TodoTreeItem): Promise<TodoTreeItem[]> {
    if (!element) {
      const roots = await this.getRootItems();
      return roots;
    }

    if (element.type === "folder") {
      return this.getFolderItems(element.fullPath || element.getLabelText());
    }

    if (element.type === "file") {
      return this.getFileItems(element.fullPath || element.getLabelText());
    }

    return [];
  }

getRootItems这个方法负责构建 TODO 树视图的根节点

typescript 复制代码
   // 遍历所有文件夹
   this.fileStructure.forEach((subPaths, folderName) => {
      // 检查文件夹下是否有包含 TODO 的文件
      const hasTodoInFolder = Array.from(this.hasTodoFiles).some((file) =>
        file.startsWith(folderName + "/")
      );

    // 如果有,创建一个折叠状态的文件夹节点
      if (hasTodoInFolder) {
        result.push(
          new TodoTreeItem(
            folderName,
            vscode.TreeItemCollapsibleState.Collapsed,
            "folder",
            undefined,
            folderName
          )
        );
      }
    });

处理处于根目录的文件(叶子节点)

  • 找出直接位于根目录的文件(不包含 "/" 的文件路径)
  • 为每个文件创建一个节点
typescript 复制代码
   Array.from(this.hasTodoFiles)
      .filter((file) => !file.includes("/"))
      .forEach((fileName) => {
        result.push(
          new TodoTreeItem(
            fileName,
            vscode.TreeItemCollapsibleState.Collapsed,
            "file",
            undefined,
            fileName
          )
        );
      });

getFolderItems

负责获取指定文件夹下的所有文件和子文件夹

  1. 收集文件夹内容
typescript 复制代码
// 遍历所有包含 TODO 的文件
for (const file of Array.from(this.hasTodoFiles)) {
  if (!file.startsWith(folderPath + "/")) {
    continue;
  }

  const parts = file.slice(folderPath.length + 1).split("/");
  if (parts.length === 1) {
    // 处理直接位于当前文件夹下的文件
    items.set(parts[0], {
      type: "file",
      todos: await this.loadFileContent(file),
    });
  } else {
    // 处理子文件夹
    const subFolder = parts[0];
    if (!items.has(subFolder)) {
      items.set(subFolder, { type: "folder", todos: [] });
    }
  }
}
  1. 创建树节点
  • 为每个项目创建 TreeItem 节点
  • 设置折叠状态:
    • 文件夹可折叠
    • 有 TODO 的文件是可折叠的
    • 没有 TODO 的文件是不可折叠的
typescript 复制代码
 items.forEach(({ type, todos }, name) => {
      const fullPath = `${folderPath}/${name}`;
      result.push(
        new TodoTreeItem(
          name,
          type === "folder"
            ? vscode.TreeItemCollapsibleState.Collapsed
            : todos.length > 0
            ? vscode.TreeItemCollapsibleState.Collapsed
            : vscode.TreeItemCollapsibleState.None,
          type,
          undefined,
          fullPath
        )
      );
    });

getFileItems

这个方法的作用是获取单个文件中的所有 TODO 注释项,并将它们转换为节点。

typescript 复制代码
    try {
      const todos = await this.loadFileContent(filePath);
      return todos.map(
        (todo) =>
          new TodoTreeItem(
            todo.label, // TODO 注释的内容
            vscode.TreeItemCollapsibleState.None, // 设置为不可折叠
            "todo",
            todo
          )
      );
    } catch (error) {
      console.error("加载文件内容失败:", filePath);
      return [];
    }

在插件入口创建TodoProvider实例

typescript 复制代码
    const todoProvider = new TodoProvider();
    // 注册为 VS Code 的树视图提供者,ID 为 "todolist" , 这里同文章开头的配置
    vscode.window.registerTreeDataProvider("todolist", todoProvider);

总结

通过本文,我们实现了一个功能完整的 VS Code TODO 插件,主要包括:

  • 在活动栏添加自定义图标

  • 实现树形结构的视图展示

  • 支持文件夹/文件/TODO 项的层级显示

参考资料

相关推荐
刺客-Andy2 分钟前
React Vue 项开发中组件封装原则及注意事项
前端·vue.js·react.js
marzdata_lily11 分钟前
从零到上线!7天搭建高并发体育比分网站全记录(附Java+Vue开源代码)
前端·后端
小君25 分钟前
让 Cursor 更加聪明
前端·人工智能·后端
顾林海35 分钟前
Flutter Dart 异常处理全面解析
android·前端·flutter
残轩1 小时前
JavaScript/TypeScript异步任务并发实用指南
前端·javascript·typescript
用户88442839014251 小时前
xterm + socket.io 实现 Web Terminal
前端
helloYaJing1 小时前
代码封装:超时重传方法
前端
literature16881 小时前
隐藏的git文件夹
前端·git
12码力1 小时前
使用 Three.js + Three-Tile 实现地球场景与几何体
前端
前端大雄1 小时前
图片加载慢?前端性能优化中的「瘦身」秘籍大揭秘!
前端·javascript·面试