本文将介绍如何实现一个 VS Code 插件,通过树形结构可视化展示项目中的所有 TODO 注释,并支持按文件夹分组、快速跳转等功能。
功能包括:
- 在 VSCode 活动栏中,新增一个图标
- 点击图标后,会在侧边栏中显示一个名为 "TODO LIST" 的面板
- 这个面板中的内容以树形结构展示的,包括:
- 文件夹(显示文件夹图标)
- 文件(显示文件图标)
- TODO 项(显示复选框图标)
- 可展开/折叠文件夹
- 点击 TODO 项跳转到对应的代码位置
先贴效果:
基本知识
先简单介绍之后会出现的类。
TreeDataProvider
TreeDataProvider 用于实现树形结构的数据展示。
-
数据结构定义:
- 定义树的节点结构
- 处理节点之间的父子关系
-
核心方法:
- getChildren : 获取指定节点的子节点
- getTreeItem : 获取节点的显示信息(图标、标签等)
- onDidChangeTreeData : 数据变化时通知 VSCode 刷新视图
TreeItem
TreeItem 是 VS Code 扩展开发中一个重要的基础类,用于构建树形视图(TreeView)中的节点。
它的主要作用包括:
-
定义节点显示:
- 设置节点的标签(label)
- 配置节点的图标(iconPath)
- 设置节点的描述信息(description)
- 控制节点的展开/折叠状态(collapsibleState)
-
节点交互:
- 定义点击行为(command)
- 设置上下文菜单(contextValue)
-
节点状态:
- 控制节点是否可见(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();
});
}
......
}
- 创建一个文件系统监听器,用于监听文件的变化
- 监听文件创建、文件修改、文件删除
- 扫描和初始化项目文件结构
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 注释。
- 通过文件修改时间来判断缓存是否有效:
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;
}
- 读取文件、按行分割
typescript
const document = await vscode.workspace.openTextDocument(uri);
const text = document.getText();
const lines = text.split("\n");
const todos: TodoComment[] = [];
- 提取文件内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, // 行内容
});
}
});
- 更新缓存:
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
负责获取指定文件夹下的所有文件和子文件夹
- 收集文件夹内容
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: [] });
}
}
}
- 创建树节点
- 为每个项目创建 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 项的层级显示