从 0 到 1:我用 VS Code API + Git 命令写了一个"代码统计"插件

项目地址:github.com/wangyang-tp...
一个会"算账"的编辑器插件:统计代码行数、回溯每次提交的代码量变化、在光标行尾显示 Git blame、把选中文件夹的代码一键导出成 Word。本文记录它的完整实现过程与核心代码。可以直接在vscode的插件市场中根据关键词检索 wang yang.coder counter即可使用此插件。


一、为什么做这个插件

开发过程中我常遇到几个琐碎又重复的需求:

  • 代码量统计:想知道一个文件夹到底有多少行代码,注释和空行各占多少。现有工具要么要装依赖,要么统计口径不合我意。
  • 代码量变化:领导/团队想知道某个文件"最近有没有瘦身"。这需要回溯 Git 历史里每次提交时这个文件的行数。
  • 谁改的这一行:编辑器里想快速看到当前行是哪次提交、谁写的(GitLens 风格),但 GitLens 太重了。
  • 代码存档:评审/归档时想把整个项目的代码合并导出一个 Word 文档。

于是我用 VS Code 扩展 API + 直接调用 git 命令行 的方式,写了 coder-counter 这个插件。不依赖任何 Git 库,所有 Git 操作都是 child_process.exec('git ...'),轻量、可控、可调试。


二、整体架构

插件 main 入口是 out/extension.js,工程用 TypeScript + tsc 编译(module: Node16)。为了好维护,源码按功能拆成了 8 个模块:

bash 复制代码
src/
├── types.ts        # 5 个共享接口(CountResult / HistoryItem / GitCommitStat 等)
├── analyzer.ts     # 行数统计、历史快照对比、CSV 导出
├── git.ts          # Git 层:所有 git 命令封装 + 历史内容提供器
├── wordExport.ts   # 代码合并导出 Word
├── webviews.ts     # 两个 Webview 页面 HTML 构建器
├── blame.ts        # 行尾 Git blame 装饰
├── commands.ts     # 7 个命令注册
└── extension.ts    # 入口,只有 activate / deactivate

依赖关系是单向的,没有循环:

dart 复制代码
types.ts ← analyzer / git / webviews / blame / commands
git.ts   ← blame / commands / extension
commands.ts ← git + analyzer + wordExport + webviews
extension.ts ← git + blame + commands

extension.ts 入口:

ts 复制代码
export function activate(context: vscode.ExtensionContext) {
  registerBlameFeature(context);   // 行尾 blame 装饰
  registerCommands(context);       // 7 个命令
  context.subscriptions.push(
    vscode.workspace.registerTextDocumentContentProvider(DIFF_SCHEME, new GitRevisionContentProvider())
  );
}
export function deactivate() {}

三、核心功能实现

1. 代码行数统计:一个"逐行判读"的解析器

统计口径:// 行注释、/* */ 块注释(支持跨行)、空行、其余算有效代码。

ts 复制代码
export function parseContent(content: string): { code: number; comment: number; blank: number } {
  const lines = content.split(/\r?\n/);
  let code = 0, comment = 0, blank = 0;
  let inBlockComment = false; // 是否处于跨行块注释中

  for (const raw of lines) {
    const line = raw.trimStart();
    if (line.length === 0) { blank++; continue; }
    if (inBlockComment) {            // 上一行开了 /* 还没闭合
      comment++;
      if (line.indexOf('*/') !== -1) inBlockComment = false;
      continue;
    }
    const blockStart = line.indexOf('/*');
    const lineComment = line.indexOf('//');
    if (blockStart !== -1) {          // 本行有块注释开头
      comment++;
      if (line.indexOf('*/', blockStart + 2) === -1) inBlockComment = true;
      continue;
    }
    if (lineComment === 0) { comment++; continue; } // // 行注释
    code++;
  }
  return { code, comment, blank };
}

walkDir 递归遍历目录,跳过 node_modules / .git / dist / out,只统计白名单后缀(ts/js/java/c/cpp/py/go/rust/vue/html/css...),每个文件一行输出到 Output 面板的 Markdown 表格里:

lua 复制代码
| 文件名 | 总行数 | 有效代码行 | 注释行 | 空行 |
|--------|--------|------------|--------|------|

入口挂在资源管理器右键菜单(explorer/context),和"统计代码行数"同一组:

json 复制代码
"contributes": {
  "menus": {
    "explorer/context": [
      { "command": "code-counter.countCode", "group": "navigation", "when": "resourceScheme == file" },
      { "command": "code-counter.exportWord", "group": "navigation", "when": "resourceScheme == file" }
    ]
  }
}

2. 历史快照:存到 globalState,与上次对比

每次统计完,把结果连同时间戳存进 context.globalState(插件自己的持久化 KV 存储),最多保留 50 条:

ts 复制代码
const GLOBAL_KEY = "codeCounterStore";

function loadStore(): ExtensionGlobalState {
  const raw = context.globalState.get<ExtensionGlobalState>(GLOBAL_KEY);
  if (raw && Array.isArray(raw.historyList)) return raw;
  return { historyList: [] };
}

function saveStore(state: ExtensionGlobalState) {
  context.globalState.update(GLOBAL_KEY, state);
}

统计输出里会带上"与上一次快照对比",用 diffResult 算出差值,+/- 一目了然:

makefile 复制代码
=====与上一次快照对比=====
文件数: +3
总行数: +120
有效代码行: +98
注释行: +10
空行: +12

历史还能一键导出 CSV,buildCsv 里用双引号包裹路径、转义内部引号,避免路径含逗号破坏列结构:

ts 复制代码
const p = `"${item.targetPath.replace(/"/g, '""')}"`;

3. Word 导出:零依赖,HTML 塞进 .doc

需求很简单------"把所有文件的代码合并导出,没有排版要求"。最省事又兼容 Word/WPS 的办法:生成一个带 Office 头标记的 HTML,扩展名存成 .doc。不需要引入任何 docx 库。

ts 复制代码
export function buildWordHtml(fsPath: string, files) {
  const title = '代码合并导出 - ' + path.basename(fsPath);
  const sections = files.map(f => `<h2>${escapeHtml(f.rel)}</h2>\n<pre>${escapeHtml(f.content)}</pre>`).join('\n');
  return `<html xmlns:o="...office:office" xmlns:w="...office:word" xmlns="...REC-html40">
<head>
<meta charset="utf-8">
<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]-->
...
<body><h1>${escapeHtml(title)}</h1>${sections}</body></html>`;
}

关键点是 <!--[if gte mso 9]>... 这段 Word 专用标记,加上 <pre>white-space: pre-wrap,代码里的缩进和换行就完整保留了。导出前用 collectCodeFiles 递归收集白名单文件,同样跳过 node_modules

4. 提交历史窗口:一个可筛选的 Webview

这是插件里最"重"的一个 UI。右侧点击文件 → 左侧列出该文件的提交历史,顶部还能按 日期 / 分支 / 作者 筛选。

Git 侧 :用一条 git log 拿到元数据,字段分隔符用 %x1e(控制符),而不是逗号------因为提交摘要里什么都可能有:

ts 复制代码
const logOut = await runGitCmd(repoRoot,
  `git log --format=%H%x1e%an%x1e%ad%x1e%s --date=iso-strict${ref}${author}${since}${until} -- "${relativePath}"`);

筛选参数直接拼进命令:

ts 复制代码
if (opts?.all) ref = ' --all';
else if (opts?.ref) ref = ` ${opts.ref}`;
const author = opts?.author ? ` --author="${opts.author}"` : '';
const since = opts?.since ? ` --since="${opts.since}"` : '';
const until = opts?.until ? ` --until="${opts.until}"` : '';

Webview 侧 :主进程和页面通过 postMessage / onDidReceiveMessage 通信。页面点提交 → 发 getFiles → 主进程 git show --name-only → 回 files → 右侧渲染涉及文件列表;点文件 → 发 openDiff → 主进程拼绝对路径后复用 openFileGitDiff 命令打开 diff 窗口。

这里有个很典型的坑 :分支下拉框的"当前分支"和"所有分支"。之前测试发现 git log <branch> -- file 会把该分支的所有祖先提交也算进来(这是 git 的正确语义),所以默认(不筛分支)和 ref=master 的结果是一样的------写测试时别把预期搞反了。

5. 代码量统计图表:ECharts + 自定义 scheme

需求:展示一个文件在每次提交时的代码量变化趋势。实现:

  1. git log --format=... 拿到该文件的所有提交(新→旧)
  2. 对每条提交执行 git show "<hash>:<path>" 取出当时文件内容
  3. parseContent 算出当时的代码/注释/空行
  4. 翻转成旧→新,作为折线图的 X 轴
  5. 末尾追加一条"当前工作区"的合成记录,保证打开图表就有数据
ts 复制代码
const current = await fs.promises.readFile(fileUri.fsPath, 'utf8');
result.push({ hash: '', shortHash: '工作区', author: '当前状态',
  dateLabel: '当前工作区', stats: computeStats(current), isWorkingTree: true });

图表用 ECharts,从多个 CDN 依次尝试加载(jsdelivr → unpkg → bootcdn),全失败也不影响下方的提交列表。点列表里某条提交,图表会画一条 markLine 标出当时的位置:

ts 复制代码
const markLine = {
  symbol: 'none', silent: true,
  label: { color: '#4fc1ff', formatter: commits[idx].shortHash + ' ' + commits[idx].dateLabel },
  lineStyle: { color: '#4fc1ff', width: 2 },
  data: [{ xAxis: idx }]
};

最难的一步 是"点击提交历史里涉及的文件 → 打开该文件的提交差异"。VS Code 内置的 git:// scheme 是 git 扩展私有的,格式特殊、依赖其仓库加载状态,我们直接 vscode.diff(oldUri, newUri) 读不到历史版本。解决办法是注册一个自己的 scheme

ts 复制代码
export const DIFF_SCHEME = 'code-counter-git';

export class GitRevisionContentProvider implements vscode.TextDocumentContentProvider {
  async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
    const fileUri = vscode.Uri.from({ scheme: 'file', path: uri.path });
    const hash = uri.query;               // hash 放在 query 里传
    const repoRoot = await getGitRepoRoot(fileUri);
    const relativePath = getRepoRelativePath(repoRoot, fileUri);
    return getFileContentAtCommit(repoRoot, relativePath, hash); // git show hash:path
  }
}

调用 diff 时,左侧旧版本用这个 scheme 拼 URI:

ts 复制代码
const oldUri = fileUri.with({ scheme: DIFF_SCHEME, query: hash });
await vscode.commands.executeCommand('vscode.diff', oldUri, fileUri,
  `提交 ${hash.substring(0, 7)}【${author}】:${summary}`, { viewColumn: vscode.ViewColumn.Two });

这就是为什么 extension.ts 里要显式 registerTextDocumentContentProvider(DIFF_SCHEME, ...)

6. 行尾 blame 装饰:GitLens 的最简实现

光标所在行的行尾显示 短hash 作者 · 摘要,光标移走即消失,悬停还能弹出三个命令按钮(看差异 / 看历史 / 看图表)。

Git 侧git blame --porcelain 拿结构化输出:

ts 复制代码
const out = await execGit(dir, `git blame --porcelain "${relFile}"`);

porcelain 格式里,提交元数据(hash、author、summary)只在每个提交块的第一行出现一次,后续代码行要沿用上一次的值------解析时必须记住,不能清空:

ts 复制代码
for (const l of lines) {
  if (/^[0-9a-f]{40} /.test(l)) { hash = parts[0]; lineNum = parseInt(parts[2], 10); continue; }
  if (l.startsWith('author ')) { author = l.replace(/^author /, ''); continue; }
  if (l.startsWith('summary ')) { subject = l.replace(/^summary /, ''); continue; }
  if (l.startsWith('\t')) {
    result.push({ lineNumber: lineNum, shortHash: hash.substring(0, 7), fullHash: hash, author, subject });
  }
}

装饰侧TextEditorDecorationType + after.contentText 在行尾画字,hoverMessage 放 MarkdownString,命令用 URI 编码的参数传参:

ts 复制代码
const args = encodeURIComponent(JSON.stringify([{ file: fsPath, hash: item.fullHash }]));
const hover = new vscode.MarkdownString();
hover.appendMarkdown(`**提交** \`${item.fullHash}\`  \n\n**作者** ${item.author}  \n**摘要** ${item.subject}  \n\n
  [🔀](command:code-counter.openFileGitDiff?${args} "查看提交差异")  [📜](command:code-counter.openFileCommitHistory?${historyArgs} "查看文件提交历史")  [📈](command:code-counter.openChart?${chartArgs} "代码量统计图表")`);
hover.isTrusted = true;

性能上做了两层保护:按文件+mtime 缓存 blame 结果,以及同文件并发去重blameInFlight),光标快速上下移动时不会重复执行 git 命令。三个事件触发刷新:切换编辑器、光标移动、保存文档。


四、工程里踩过的一些坑

  1. %x1e 字段分隔符git log --format 用逗号/管道都会撞上摘要里的内容,用控制符 \x1e 最稳,解析时 split('\x1e')
  2. Windows 路径 :git 输出的是正斜杠 /fsPath 是反斜杠 \,盘符还分大小写。统一转 / 后做不区分大小写的前缀匹配再截取相对路径。
  3. 大文件 git show :Node 的 exec 默认 stdout 缓冲只有 1MB,大文件会爆,必须 maxBuffer: 100 * 1024 * 1024
  4. Webview 注入 :页面里 const data = ${JSON.stringify(data)} 时,如果路径/摘要含 </script> 会直接破掉页面,构建前先把 < 转义成 <
  5. 模块化后的测试 :webview 的脚本没法在 Node 里直接跑,做法是把构建出的 HTML 里的 <script> 抽出来用 new Function(script) 只做语法校验(不执行)。
  6. git 分支的祖先语义git log <branch> -- file 会把该分支所有祖先提交算进来,写筛选逻辑和测试断言前务必想清楚,否则"默认 2 条 vs 指定分支 3 条"这种断言会挂。

五、测试:没有真实 VS Code 也能测

插件代码依赖 vscode 模块,单测里用一个 Proxy 桩 替换掉它:require('vscode') 时返回一个"要啥给啥"的对象,注册命令返回带 dispose 的假对象,window.activeTextEditor 返回 undefined 让 blame 逻辑安全短路。

js 复制代码
const Module = require('module');
const origRequire = Module.prototype.require;
Module.prototype.require = function (request) {
  if (request === 'vscode') return vscodeStub;   // Proxy 桩
  return origRequire.apply(this, arguments);
};

然后直接 require('./out/extension.js'),用 { subscriptions: [], globalState: {...} } 这种假 context 调 activate(),断言不抛错、subscriptions 有内容。Git 相关函数则针对一个临时构造的双分支仓库(控制好提交时间、作者)验证筛选和 blame 结果。这样整个插件在没有 VS Code 的情况下也能自动回归。


六、小结

这个插件让我体会到:

  • VS Code 扩展开发比想象中简单 :API 覆盖了编辑器、Webview、命令、菜单、持久化,TextDocumentContentProvider 这类机制把"读取历史文件内容"这种看似不可能的事也变得可行。
  • 直接调 git 命令不丢人:对 Git 这种成熟工具,用 CLI 反而清晰可控,还能少引一堆依赖。
  • Webview 是"浏览器 + 双向通信":把复杂 UI 交给 HTML/JS,主进程只负责数据,职责非常干净。
  • 拆模块 + 桩测试,让插件代码真的可维护:1262 行单体拆成 8 个文件后,每个功能都能单独读、单独测。

如果你也常被"统计代码量 / 看提交历史 / 查 blame"这种事烦到,不妨也动手写一个------源码已经在这里,功能全覆盖。

相关推荐
云浪1 小时前
如何让大模型操作 MySQL 数据库?
javascript·人工智能·后端
lilian2332 小时前
Harmony os 技术实战|拼豆制图46:图片解码失败为何不能悄悄返回示例图
前端·javascript·华为·harmonyos
threerocks11 小时前
AI 原生软件开发生命周期手册 - 如何借助 AI,逐阶段改造软件开发生命周期
前端·javascript·后端
ly768912 小时前
JavaScript 从入门到进阶:核心语法、异步编程与工程化实践
开发语言·javascript·ecmascript
沙盘客14 小时前
AFSIM 示例解读(03)· 六自由度飞行仿真 six_dof(下):机场运作与弹道导弹投送
java·javascript·经验分享
默_笙16 小时前
👍 我的代码被 ESLint 抓包了:单引号、var、没分号——全被当场点名
前端·javascript
oh,huoyuyan16 小时前
JS 动态网页抓不到?试试火车采集器网页抓取工具
开发语言·javascript·ecmascript
汉堡大王952718 小时前
用 Trae Work 自动化任务,6 分钟生成一份前端生态周报
前端·javascript·人工智能
章鱼小丸子逃跑中18 小时前
【2025最新版】如何将fnm与node.js安装在D盘?【保姆级安装及人性话理解教程】
前端·javascript·npm·node.js